diff options
Diffstat (limited to '')
-rw-r--r-- | README.md | 56 | ||||
-rw-r--r-- | doc/README_CN.md | 18 | ||||
-rw-r--r-- | gita/__main__.py | 193 | ||||
-rw-r--r-- | gita/common.py | 8 | ||||
-rw-r--r-- | gita/info.py | 108 | ||||
-rw-r--r-- | gita/utils.py | 41 | ||||
-rw-r--r-- | requirements.txt | 4 | ||||
-rw-r--r-- | setup.py | 4 | ||||
-rw-r--r-- | tests/test_main.py | 223 | ||||
-rw-r--r-- | tests/test_utils.py | 30 | ||||
-rw-r--r-- | tests/xx.context | 0 |
11 files changed, 528 insertions, 157 deletions
@@ -14,7 +14,7 @@ | | ____ | | | | | ___ | | | \_ ) | | | | | ( ) | | (___) |__) (___ | | | ) ( | -(_______)_______/ )_( |/ \| v0.10 +(_______)_______/ )_( |/ \| v0.11 ``` # Gita: a command-line tool to manage multiple git repos @@ -29,7 +29,14 @@ I also hate to change directories to execute git commands. ![gita screenshot](https://github.com/nosarthur/gita/raw/master/doc/screenshot.png) -Here the branch color distinguishes 5 situations between local and remote branches: +In the screenshot, the `gita remote nowhub` command translates to `git remote -v` +for the `nowhub` repo. +To see the pre-defined sub-commands, run `gita -h` or take a look at +[cmds.yml](https://github.com/nosarthur/gita/blob/master/gita/cmds.yml). +To add your own sub-commands, see the [customization section](#custom). +To run arbitrary `git` command, see the [superman mode section](#superman). + +The branch color distinguishes 5 situations between local and remote branches: - white: local has no remote - green: local is the same as remote @@ -50,32 +57,50 @@ The additional status symbols denote The bookkeeping sub-commands are - `gita add <repo-path(s)>`: add repo(s) to `gita` -- `gita rm <repo-name(s)>`: remove repo(s) from `gita` (won't remove files from disk) -- `gita group`: show grouping of the repos -- `gita group <repo-name(s)>`: group repos -- `gita ungroup <repo-name(s)>`: remove grouping for repos +- `gita context`: context sub-command + - `gita context`: show current context + - `gita context none`: remove context + - `gita context <group-name>`: set context to `group-name`, all operations then only apply to repos in this group +- `gita color`: color sub-command + - `gita color [ll]`: Show available colors and the current coloring scheme + - `gita color set <situation> <color>`: Use the specified color for the local-remote situation +- `gita group`: group sub-command + - `gita group add <repo-name(s)> -n <group-name>`: add repo(s) to a new group or existing group + - `gita group [ll]`: display existing groups with repos + - `gita group ls`: display existing group names + - `gita group rename <group-name> <new-name>`: change group name + - `gita group rm <group-name(s)>`: delete group(s) +- `gita info`: info sub-command + - `gita info [ll]`: display the used and unused information items + - `gita info add <info-item>`: enable information item + - `gita info rm <info-item>`: disable information item - `gita ll`: display the status of all repos - `gita ll <group-name>`: display the status of repos in a group - `gita ls`: display the names of all repos - `gita ls <repo-name>`: display the absolute path of one repo - `gita rename <repo-name> <new-name>`: rename a repo -- `gita info`: display the used and unused information items +- `gita rm <repo-name(s)>`: remove repo(s) from `gita` (won't remove files from disk) - `gita -v`: display gita version -Repo paths are saved in `$XDG_CONFIG_HOME/gita/repo_path` (most likely `~/.config/gita/repo_path`). - The delegating sub-commands are of two formats - `gita <sub-command> [repo-name(s) or group-name(s)]`: - optional repo or group input, and no input means all repos. + optional repo or group input, and **no input means all repos**. - `gita <sub-command> <repo-name(s) or groups-name(s)>`: required repo name(s) or group name(s) input +In either case, the `gita` command translates to running `git <sub-command>` for the corresponding repos. By default, only `fetch` and `pull` take optional input. +To see the pre-defined sub-commands, run `gita -h` or take a look at +[cmds.yml](https://github.com/nosarthur/gita/blob/master/gita/cmds.yml). +To add your own sub-commands, see the [customization section](#custom). +To run arbitrary `git` command, see the [superman mode section](#superman). If more than one repos are specified, the git command will run asynchronously, with the exception of `log`, `difftool` and `mergetool`, which require non-trivial user input. +Repo paths are saved in `$XDG_CONFIG_HOME/gita/repo_path` (most likely `~/.config/gita/repo_path`). + ## Installation To install the latest version, run @@ -110,7 +135,7 @@ or [.gita-completion.zsh](https://github.com/nosarthur/gita/blob/master/.gita-completion.zsh) and source it in the .rc file. -## Superman mode +## <a name='superman'></a> Superman mode The superman mode delegates any git command/alias. Usage: @@ -126,7 +151,7 @@ For example, - `gita super frontend-repo backend-repo commit -am 'implement a new feature'` executes `git commit -am 'implement a new feature'` for `frontend-repo` and `backend-repo` -## Customization +## <a name='custom'></a> Customization Custom delegating sub-commands can be defined in `$XDG_CONFIG_HOME/gita/cmds.yml` (most likely `~/.config/gita/cmds.yml`). @@ -164,8 +189,11 @@ comaster: Another customization is the information items displayed by `gita ll`. The used and unused information items are shown with `gita info` and one can -create `$XDG_CONFIG_HOME/gita/info.yml` to customize it. For example, the -default information items setting corresponds to +create `$XDG_CONFIG_HOME/gita/info.yml` to customize it. +(I am thinking of hiding all these details from user at the moment, which means +you probably don't need to read the rest of this section.) + +For example, the default information items setting corresponds to ```yaml - branch diff --git a/doc/README_CN.md b/doc/README_CN.md index 433aaec..051bbe8 100644 --- a/doc/README_CN.md +++ b/doc/README_CN.md @@ -14,7 +14,7 @@ | | ____ | | | | | ___ | | | \_ ) | | | | | ( ) | | (___) |__) (___ | | | ) ( | -(_______)_______/ )_( |/ \| v0.10 +(_______)_______/ )_( |/ \| v0.11 ``` # Gita:一个管理多个 git 库的命令行工具 @@ -46,15 +46,23 @@ 基础指令: - `gita add <repo-path(s)>`: 添加库 -- `gita rm <repo-name(s)>`: 移除库(不会删除文件) -- `gita group`: 显示库的组群 -- `gita group` <repo-name(s)>: 将库分组 +- `gita context`: 情境命令 + - `gita context`: 显示当前的情境 + - `gita context none`: 去除情境 + - `gita context <group-name>`: 把情境设置成`group-name`, 之后所有的操作只作用到这个组里的库 +- `gita group`: 组群命令 + - `gita group add <repo-name(s)>`: 把库加入新的或者已经存在的组 + - `gita group [ll]`: 显示已有的组和它们的库 + - `gita group ls`: 显示已有的组名 + - `gita group rename <group-name> <new-name>`: 改组名 + - `gita group rm group(s): 删除组 +- `gita info`: 显示已用的和未用的信息项 - `gita ll`: 显示所有库的状态信息 - `gita ll <group-name>`: 显示一个组群中库的状态信息 - `gita ls`: 显示所有库的名字 - `gita ls <repo-name>`: 显示一个库的绝对路径 - `gita rename <repo-name> <new-name>`: 重命名一个库 -- `gita info`: 显示已用的和未用的信息项 +- `gita rm <repo-name(s)>`: 移除库(不会删除文件) - `gita -v`: 显示版本号 库的路径存在`$XDG_CONFIG_HOME/gita/repo_path` (多半是`~/.config/gita/repo_path`)。 diff --git a/gita/__main__.py b/gita/__main__.py index 6e47f8e..9a24bb9 100644 --- a/gita/__main__.py +++ b/gita/__main__.py @@ -15,16 +15,23 @@ https://github.com/nosarthur/gita/blob/master/.gita-completion.bash ''' import os +import sys +import yaml import argparse import subprocess import pkg_resources +from itertools import chain +from pathlib import Path -from . import utils, info +from . import utils, info, common def f_add(args: argparse.Namespace): repos = utils.get_repos() - utils.add_repos(repos, args.paths) + paths = args.paths + if args.recursive: + paths = chain.from_iterable(Path(p).glob('**') for p in args.paths) + utils.add_repos(repos, paths) def f_rename(args: argparse.Namespace): @@ -32,12 +39,33 @@ def f_rename(args: argparse.Namespace): utils.rename_repo(repos, args.repo[0], args.new_name) -def f_info(_): - all_items, to_display = info.get_info_items() - print('In use:', ','.join(to_display)) - unused = set(all_items) - set(to_display) - if unused: - print('Unused:', ' '.join(unused)) +def f_color(args: argparse.Namespace): + cmd = args.color_cmd or 'll' + if cmd == 'll': # pragma: no cover + info.show_colors() + elif cmd == 'set': + print('not implemented') + + +def f_info(args: argparse.Namespace): + to_display = info.get_info_items() + cmd = args.info_cmd or 'll' + if cmd == 'll': + print('In use:', ','.join(to_display)) + unused = set(info.ALL_INFO_ITEMS) - set(to_display) + if unused: + print('Unused:', ' '.join(unused)) + return + if cmd == 'add' and args.info_item not in to_display: + to_display.append(args.info_item) + yml_config = common.get_config_fname('info.yml') + with open(yml_config, 'w') as f: + yaml.dump(to_display, f, default_flow_style=None) + elif cmd == 'rm' and args.info_item in to_display: + to_display.remove(args.info_item) + yml_config = common.get_config_fname('info.yml') + with open(yml_config, 'w') as f: + yaml.dump(to_display, f, default_flow_style=None) def f_ll(args: argparse.Namespace): @@ -45,10 +73,13 @@ def f_ll(args: argparse.Namespace): Display details of all repos """ repos = utils.get_repos() + ctx = utils.get_context() + if args.group is None and ctx: + args.group = ctx.stem if args.group: # only display repos in this group group_repos = utils.get_groups()[args.group] repos = {k: repos[k] for k in group_repos if k in repos} - for line in utils.describe(repos): + for line in utils.describe(repos, no_colors=args.no_colors): print(line) @@ -62,8 +93,26 @@ def f_ls(args: argparse.Namespace): def f_group(args: argparse.Namespace): groups = utils.get_groups() - if args.to_group: - gname = input('group name? ') + cmd = args.group_cmd or 'll' + if cmd == 'll': + for group, repos in groups.items(): + print(f"{group}: {' '.join(repos)}") + elif cmd == 'ls': + print(' '.join(groups)) + elif cmd == 'rename': + new_name = args.new_name + if new_name in groups: + sys.exit(f'{new_name} already exists.') + gname = args.gname + groups[new_name] = groups[gname] + del groups[gname] + utils.write_to_groups_file(groups, 'w') + elif cmd == 'rm': + for name in args.to_ungroup: + del groups[name] + utils.write_to_groups_file(groups, 'w') + elif cmd == 'add': + gname = args.gname if gname in groups: gname_repos = set(groups[gname]) gname_repos.update(args.to_group) @@ -71,31 +120,32 @@ def f_group(args: argparse.Namespace): utils.write_to_groups_file(groups, 'w') else: utils.write_to_groups_file({gname: sorted(args.to_group)}, 'a+') - else: - for group, repos in groups.items(): - print(f"{group}: {' '.join(repos)}") -def f_ungroup(args: argparse.Namespace): - groups = utils.get_groups() - to_ungroup = set(args.to_ungroup) - to_del = [] - for name, repos in groups.items(): - remaining = set(repos) - to_ungroup - if remaining: - groups[name] = list(sorted(remaining)) +def f_context(args: argparse.Namespace): + choice = args.choice + ctx = utils.get_context() + if choice is None: + if ctx: + group = ctx.stem + print(f"{group}: {' '.join(utils.get_groups()[group])}") + else: + print('Context is not set') + elif choice == 'none': # remove context + ctx and ctx.unlink() + else: # set context + fname = Path(common.get_config_dir()) / (choice + '.context') + if ctx: + ctx.rename(fname) else: - to_del.append(name) - for name in to_del: - del groups[name] - utils.write_to_groups_file(groups, 'w') + open(fname, 'w').close() def f_rm(args: argparse.Namespace): """ Unregister repo(s) from gita """ - path_file = utils.get_config_fname('repo_path') + path_file = common.get_config_fname('repo_path') if os.path.isfile(path_file): repos = utils.get_repos() for repo in args.repo: @@ -110,6 +160,9 @@ def f_git_cmd(args: argparse.Namespace): """ repos = utils.get_repos() groups = utils.get_groups() + ctx = utils.get_context() + if not args.repo and ctx: + args.repo = [ctx.stem] if args.repo: # with user specified repo(s) or group(s) chosen = {} for k in args.repo: @@ -170,6 +223,8 @@ def main(argv=None): # bookkeeping sub-commands p_add = subparsers.add_parser('add', help='add repo(s)') p_add.add_argument('paths', nargs='+', help="add repo(s)") + p_add.add_argument('-r', dest='recursive', action='store_true', + help="recursively add repo(s) in the given path.") p_add.set_defaults(func=f_add) p_rm = subparsers.add_parser('rm', help='remove repo(s)') @@ -190,8 +245,38 @@ def main(argv=None): help="new name") p_rename.set_defaults(func=f_rename) - p_info = subparsers.add_parser('info', help='show information items of the ll sub-command') + p_color = subparsers.add_parser('color', + help='display and modify branch coloring of the ll sub-command.') + p_color.set_defaults(func=f_color) + color_cmds = p_color.add_subparsers(dest='color_cmd', + help='additional help with sub-command -h') + color_cmds.add_parser('ll', + description='display available colors and the current branch coloring in the ll sub-command') + pc_set = color_cmds.add_parser('set', + description='Set color for local/remote situation.') + pc_set.add_argument('situation', + choices=info.get_color_encoding(), + help="5 possible local/remote situations") + pc_set.add_argument('color', + choices=[c.name for c in info.Color], + help="available colors") + + p_info = subparsers.add_parser('info', + help='list, add, or remove information items of the ll sub-command.') p_info.set_defaults(func=f_info) + info_cmds = p_info.add_subparsers(dest='info_cmd', + help='additional help with sub-command -h') + info_cmds.add_parser('ll', + description='show used and unused information items of the ll sub-command') + info_cmds.add_parser('add', description='Enable information item.' + ).add_argument('info_item', + choices=('branch', 'commit_msg', 'path'), + help="information item to add") + info_cmds.add_parser('rm', description='Disable information item.' + ).add_argument('info_item', + choices=('branch', 'commit_msg', 'path'), + help="information item to delete") + ll_doc = f''' status symbols: +: staged changes @@ -212,8 +297,19 @@ def main(argv=None): nargs='?', choices=utils.get_groups(), help="show repos in the chosen group") + p_ll.add_argument('-n', '--no-colors', action='store_true', + help='Disable coloring on the branch names.') p_ll.set_defaults(func=f_ll) + p_context = subparsers.add_parser('context', + help='Set and remove context. A context is a group.' + ' When set, all operations apply only to repos in that group.') + p_context.add_argument('choice', + nargs='?', + choices=set().union(utils.get_groups(), {'none'}), + help="Without argument, show current context. Otherwise choose a group as context. To remove context, use 'none'. ") + p_context.set_defaults(func=f_context) + p_ls = subparsers.add_parser( 'ls', help='display names of all repos, or path of a chosen repo') p_ls.add_argument('repo', @@ -223,21 +319,34 @@ def main(argv=None): p_ls.set_defaults(func=f_ls) p_group = subparsers.add_parser( - 'group', help='group repos or display names of all groups if no repo is provided') - p_group.add_argument('to_group', - nargs='*', - choices=utils.get_choices(), - help="repo(s) to be grouped") + 'group', help='list, add, or remove repo group(s)') p_group.set_defaults(func=f_group) - - p_ungroup = subparsers.add_parser( - 'ungroup', help='remove group information for repos', - description="Remove group information on repos") - p_ungroup.add_argument('to_ungroup', - nargs='+', - choices=utils.get_repos(), - help="repo(s) to be ungrouped") - p_ungroup.set_defaults(func=f_ungroup) + group_cmds = p_group.add_subparsers(dest='group_cmd', + help='additional help with sub-command -h') + group_cmds.add_parser('ll', description='List all groups with repos.') + group_cmds.add_parser('ls', description='List all group names.') + pg_add = group_cmds.add_parser('add', description='Add repo(s) to a group.') + pg_add.add_argument('to_group', + nargs='+', + metavar='repo', + choices=utils.get_repos(), + help="repo(s) to be grouped") + pg_add.add_argument('-n', '--name', + dest='gname', + metavar='group-name', + required=True, + help="group name") + pg_rename = group_cmds.add_parser('rename', description='Change group name.') + pg_rename.add_argument('gname', metavar='group-name', + choices=utils.get_groups(), + help="existing group to rename") + pg_rename.add_argument('new_name', metavar='new-name', + help="new group name") + group_cmds.add_parser('rm', + description='Remove group(s).').add_argument('to_ungroup', + nargs='+', + choices=utils.get_groups(), + help="group(s) to delete") # superman mode p_super = subparsers.add_parser( diff --git a/gita/common.py b/gita/common.py index 0a271fc..ef3933d 100644 --- a/gita/common.py +++ b/gita/common.py @@ -6,3 +6,11 @@ def get_config_dir() -> str: os.path.expanduser('~'), '.config') root = os.path.join(parent, "gita") return root + + +def get_config_fname(fname: str) -> str: + """ + Return the file name that stores the repo locations. + """ + root = get_config_dir() + return os.path.join(root, fname) diff --git a/gita/info.py b/gita/info.py index 18d20fd..473127a 100644 --- a/gita/info.py +++ b/gita/info.py @@ -2,14 +2,19 @@ import os import sys import yaml import subprocess +from enum import Enum +from pathlib import Path +from functools import lru_cache from typing import Tuple, List, Callable, Dict + from . import common -class Color: +class Color(str, Enum): """ Terminal color """ + black = '\x1b[30m' red = '\x1b[31m' # local diverges from remote green = '\x1b[32m' # local == remote yellow = '\x1b[33m' # local is behind @@ -18,6 +23,43 @@ class Color: cyan = '\x1b[36m' white = '\x1b[37m' # no remote branch end = '\x1b[0m' + b_black = '\x1b[30;1m' + b_red = '\x1b[31;1m' + b_green = '\x1b[32;1m' + b_yellow = '\x1b[33;1m' + b_blue = '\x1b[34;1m' + b_purple = '\x1b[35;1m' + b_cyan = '\x1b[36;1m' + b_white = '\x1b[37;1m' + + +def show_colors(): # pragma: no cover + """ + + """ + for i, c in enumerate(Color, start=1): + if c != Color.end: + print(f'{c.value}{c.name:<8} ', end='') + if i % 9 == 0: + print() + print(f'{Color.end}') + for situation, c in get_color_encoding().items(): + print(f'{situation:<12}: {c.value}{c.name:<8}{Color.end} ') + + +@lru_cache() +def get_color_encoding(): + """ + + """ + # TODO: add config file + return { + 'no-remote': Color.white, + 'in-sync': Color.green, + 'diverged': Color.red, + 'local-ahead': Color.purple, + 'remote-ahead': Color.yellow, + } def get_info_funcs() -> List[Callable[[str], str]]: @@ -26,35 +68,30 @@ def get_info_funcs() -> List[Callable[[str], str]]: take the repo path as input and return the corresponding information as str. See `get_path`, `get_repo_status`, `get_common_commit` for examples. """ - info_items, to_display = get_info_items() - return [info_items[k] for k in to_display] + to_display = get_info_items() + # This re-definition is to make unit test mocking to work + all_info_items = { + 'branch': get_repo_status, + 'commit_msg': get_commit_msg, + 'path': get_path, + } + return [all_info_items[k] for k in to_display] -def get_info_items() -> Tuple[Dict[str, Callable[[str], str]], List[str]]: +def get_info_items() -> List[str]: """ - Return the available information items for display in the `gita ll` - sub-command, and the ones to be displayed. - It loads custom information functions and configuration if they exist. + Return the information items to be displayed in the `gita ll` command. """ # default settings - info_items = {'branch': get_repo_status, - 'commit_msg': get_commit_msg, - 'path': get_path, } display_items = ['branch', 'commit_msg'] # custom settings - root = common.get_config_dir() - src_fname = os.path.join(root, 'extra_repo_info.py') - yml_fname = os.path.join(root, 'info.yml') - if os.path.isfile(src_fname): - sys.path.append(root) - from extra_repo_info import extra_info_items - info_items.update(extra_info_items) - if os.path.isfile(yml_fname): - with open(yml_fname, 'r') as stream: + yml_config = Path(common.get_config_fname('info.yml')) + if yml_config.is_file(): + with open(yml_config, 'r') as stream: display_items = yaml.load(stream, Loader=yaml.FullLoader) - display_items = [x for x in display_items if x in info_items] - return info_items, display_items + display_items = [x for x in display_items if x in ALL_INFO_ITEMS] + return display_items def get_path(path): @@ -113,13 +150,15 @@ def get_commit_msg(path: str) -> str: return result.stdout.strip() -def get_repo_status(path: str) -> str: +def get_repo_status(path: str, no_colors=False) -> str: head = get_head(path) - dirty, staged, untracked, color = _get_repo_status(path) - return f'{color}{head+" "+dirty+staged+untracked:<10}{Color.end}' + dirty, staged, untracked, color = _get_repo_status(path, no_colors) + if color: + return f'{color}{head+" "+dirty+staged+untracked:<10}{Color.end}' + return f'{head+" "+dirty+staged+untracked:<10}' -def _get_repo_status(path: str) -> Tuple[str]: +def _get_repo_status(path: str, no_colors: bool) -> Tuple[str]: """ Return the status of one repo """ @@ -128,19 +167,30 @@ def _get_repo_status(path: str) -> Tuple[str]: staged = '+' if run_quiet_diff(['--cached']) else '' untracked = '_' if has_untracked() else '' + if no_colors: + return dirty, staged, untracked, '' + + colors = get_color_encoding() diff_returncode = run_quiet_diff(['@{u}', '@{0}']) has_no_remote = diff_returncode == 128 has_no_diff = diff_returncode == 0 if has_no_remote: - color = Color.white + color = colors['no-remote'] elif has_no_diff: - color = Color.green + color = colors['in-sync'] else: common_commit = get_common_commit() outdated = run_quiet_diff(['@{u}', common_commit]) if outdated: diverged = run_quiet_diff(['@{0}', common_commit]) - color = Color.red if diverged else Color.yellow + color = colors['diverged'] if diverged else colors['remote-ahead'] else: # local is ahead of remote - color = Color.purple + color = colors['local-ahead'] return dirty, staged, untracked, color + + +ALL_INFO_ITEMS = { + 'branch': get_repo_status, + 'commit_msg': get_commit_msg, + 'path': get_path, + } diff --git a/gita/utils.py b/gita/utils.py index d14484a..d30a82e 100644 --- a/gita/utils.py +++ b/gita/utils.py @@ -2,19 +2,23 @@ import os import yaml import asyncio import platform -from functools import lru_cache +from functools import lru_cache, partial +from pathlib import Path from typing import List, Dict, Coroutine, Union from . import info from . import common -def get_config_fname(fname: str) -> str: +@lru_cache() +def get_context() -> Union[Path, None]: """ - Return the file name that stores the repo locations. + Return the context: either a group name or 'none' """ - root = common.get_config_dir() - return os.path.join(root, fname) + config_dir = Path(common.get_config_dir()) + matches = list(config_dir.glob('*.context')) + assert len(matches) < 2, "Cannot have multiple .context file" + return matches[0] if matches else None @lru_cache() @@ -22,7 +26,7 @@ def get_repos() -> Dict[str, str]: """ Return a `dict` of repo name to repo absolute path """ - path_file = get_config_fname('repo_path') + path_file = common.get_config_fname('repo_path') repos = {} # Each line is a repo path and repo name separated by , if os.path.isfile(path_file) and os.stat(path_file).st_size > 0: @@ -47,7 +51,7 @@ def get_groups() -> Dict[str, List[str]]: """ Return a `dict` of group name to repo names. """ - fname = get_config_fname('groups.yml') + fname = common.get_config_fname('groups.yml') groups = {} # Each line is a repo path and repo name separated by , if os.path.isfile(fname) and os.stat(fname).st_size > 0: @@ -102,7 +106,7 @@ def write_to_repo_file(repos: Dict[str, str], mode: str): """ """ data = ''.join(f'{path},{name}\n' for name, path in repos.items()) - fname = get_config_fname('repo_path') + fname = common.get_config_fname('repo_path') os.makedirs(os.path.dirname(fname), exist_ok=True) with open(fname, mode) as f: f.write(data) @@ -112,10 +116,13 @@ def write_to_groups_file(groups: Dict[str, List[str]], mode: str): """ """ - fname = get_config_fname('groups.yml') + fname = common.get_config_fname('groups.yml') os.makedirs(os.path.dirname(fname), exist_ok=True) - with open(fname, mode) as f: - yaml.dump(groups, f, default_flow_style=None) + if not groups: # all groups are deleted + open(fname, 'w').close() + else: + with open(fname, mode) as f: + yaml.dump(groups, f, default_flow_style=None) def add_repos(repos: Dict[str, str], new_paths: List[str]): @@ -182,17 +189,23 @@ def exec_async_tasks(tasks: List[Coroutine]) -> List[Union[None, str]]: return errors -def describe(repos: Dict[str, str]) -> str: +def describe(repos: Dict[str, str], no_colors: bool=False) -> str: """ Return the status of all repos """ if repos: name_width = max(len(n) for n in repos) + 1 funcs = info.get_info_funcs() + + get_repo_status = info.get_repo_status + if get_repo_status in funcs and no_colors: + idx = funcs.index(get_repo_status) + funcs[idx] = partial(get_repo_status, no_colors=True) + for name in sorted(repos): path = repos[name] - display_items = ' '.join(f(path) for f in funcs) - yield f'{name:<{name_width}}{display_items}' + info_items = ' '.join(f(path) for f in funcs) + yield f'{name:<{name_width}}{info_items}' def get_cmds_from_files() -> Dict[str, Dict[str, str]]: diff --git a/requirements.txt b/requirements.txt index 3e9e127..6d2823f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -pytest>=4.4.0 +pytest>=6.1.2 pytest-cov>=2.6.1 -pytest-xdist>=1.26.0 +pytest-xdist>=2.1.0 setuptools>=40.6.3 twine>=1.12.1 pyyaml>=5.1 @@ -7,9 +7,9 @@ with open('README.md', encoding='utf-8') as f: setup( name='gita', packages=['gita'], - version='0.10.10', + version='0.11.9', license='MIT', - description='Manage multiple git repos', + description='Manage multiple git repos with sanity', long_description=long_description, long_description_content_type='text/markdown', url='https://github.com/nosarthur/gita', diff --git a/tests/test_main.py b/tests/test_main.py index ff28111..1c30eec 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,22 +1,28 @@ import pytest -from unittest.mock import patch +from unittest.mock import patch, mock_open +from pathlib import Path import argparse import shlex from gita import __main__ -from gita import utils +from gita import utils, info from conftest import ( PATH_FNAME, PATH_FNAME_EMPTY, PATH_FNAME_CLASH, GROUP_FNAME, - async_mock + async_mock, TEST_DIR, ) class TestLsLl: - @patch('gita.utils.get_config_fname') + @patch('gita.common.get_config_fname') def testLl(self, mock_path_fname, capfd, tmp_path): - """ functional test """ + """ + functional test + """ # avoid modifying the local configuration - mock_path_fname.return_value = tmp_path / 'path_config.txt' + def side_effect(input): + return tmp_path / f'{input}.txt' + #mock_path_fname.return_value = tmp_path / 'path_config.txt' + mock_path_fname.side_effect = side_effect __main__.main(['add', '.']) out, err = capfd.readouterr() assert err == '' @@ -34,6 +40,14 @@ class TestLsLl: out, err = capfd.readouterr() assert err == '' assert 'gita' in out + assert info.Color.end in out + + # no color on branch name + __main__.main(['ll', '-n']) + out, err = capfd.readouterr() + assert err == '' + assert 'gita' in out + assert info.Color.end not in out __main__.main(['ls', 'gita']) out, err = capfd.readouterr() @@ -65,10 +79,14 @@ class TestLsLl: @patch('gita.info.get_head', return_value="master") @patch('gita.info._get_repo_status', return_value=("d", "s", "u", "c")) @patch('gita.info.get_commit_msg', return_value="msg") - @patch('gita.utils.get_config_fname') + @patch('gita.common.get_config_fname') def testWithPathFiles(self, mock_path_fname, _0, _1, _2, _3, path_fname, expected, capfd): - mock_path_fname.return_value = path_fname + def side_effect(input): + if input == 'repo_path': + return path_fname + return f'/{input}' + mock_path_fname.side_effect = side_effect utils.get_repos.cache_clear() __main__.main(['ll']) out, err = capfd.readouterr() @@ -78,7 +96,7 @@ class TestLsLl: @patch('os.path.isfile', return_value=True) -@patch('gita.utils.get_config_fname', return_value='some path') +@patch('gita.common.get_config_fname', return_value='some path') @patch('gita.utils.get_repos', return_value={'repo1': '/a/', 'repo2': '/b/'}) @patch('gita.utils.write_to_repo_file') def test_rm(mock_write, *_): @@ -131,34 +149,133 @@ def test_superman(mock_run, _, input): mock_run.assert_called_once_with(expected_cmds, cwd='path7') -@pytest.mark.parametrize('input, expected', [ - ('a', {'xx': ['b'], 'yy': ['c', 'd']}), - ("c", {'xx': ['a', 'b'], 'yy': ['a', 'd']}), - ("a b", {'yy': ['c', 'd']}), -]) -@patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''}) -@patch('gita.utils.get_config_fname', return_value=GROUP_FNAME) -@patch('gita.utils.write_to_groups_file') -def test_ungroup(mock_write, _, __, input, expected): - utils.get_groups.cache_clear() - args = ['ungroup'] + shlex.split(input) - __main__.main(args) - mock_write.assert_called_once_with(expected, 'w') +class TestContext: + @patch('gita.utils.get_context', return_value=None) + def testDisplayNoContext(self, _, capfd): + __main__.main(['context']) + out, err = capfd.readouterr() + assert err == '' + assert 'Context is not set\n' == out -@patch('gita.utils.get_config_fname', return_value=GROUP_FNAME) -def test_group_display(_, capfd): - args = argparse.Namespace() - args.to_group = None - utils.get_groups.cache_clear() - __main__.f_group(args) - out, err = capfd.readouterr() - assert err == '' - assert 'xx: a b\nyy: a c d\n' == out + @patch('gita.utils.get_context', return_value=Path('gname.context')) + @patch('gita.utils.get_groups', return_value={'gname': ['a', 'b']}) + def testDisplayContext(self, _, __, capfd): + __main__.main(['context']) + out, err = capfd.readouterr() + assert err == '' + assert 'gname: a b\n' == out + + @patch('gita.utils.get_context') + def testReset(self, mock_ctx): + __main__.main(['context', 'none']) + mock_ctx.return_value.unlink.assert_called() + + @patch('gita.utils.get_context', return_value=None) + @patch('gita.common.get_config_dir', return_value=TEST_DIR) + @patch('gita.utils.get_groups', return_value={'lala': ['b'], 'kaka': []}) + def testSetFirstTime(self, *_): + ctx = TEST_DIR / 'lala.context' + assert not ctx.is_file() + __main__.main(['context', 'lala']) + assert ctx.is_file() + ctx.unlink() + + @patch('gita.common.get_config_dir', return_value=TEST_DIR) + @patch('gita.utils.get_groups', return_value={'lala': ['b'], 'kaka': []}) + @patch('gita.utils.get_context') + def testSetSecondTime(self, mock_ctx, *_): + __main__.main(['context', 'kaka']) + mock_ctx.return_value.rename.assert_called() + + +class TestGroupCmd: + + @patch('gita.common.get_config_fname', return_value=GROUP_FNAME) + def testLs(self, _, capfd): + args = argparse.Namespace() + args.to_group = None + args.group_cmd = 'ls' + utils.get_groups.cache_clear() + __main__.f_group(args) + out, err = capfd.readouterr() + assert err == '' + assert 'xx yy\n' == out + + @patch('gita.common.get_config_fname', return_value=GROUP_FNAME) + def testLl(self, _, capfd): + args = argparse.Namespace() + args.to_group = None + args.group_cmd = None + utils.get_groups.cache_clear() + __main__.f_group(args) + out, err = capfd.readouterr() + assert err == '' + assert 'xx: a b\nyy: a c d\n' == out + + @patch('gita.common.get_config_fname', return_value=GROUP_FNAME) + @patch('gita.utils.write_to_groups_file') + def testRename(self, mock_write, _): + args = argparse.Namespace() + args.gname = 'xx' + args.new_name = 'zz' + args.group_cmd = 'rename' + utils.get_groups.cache_clear() + __main__.f_group(args) + expected = {'yy': ['a', 'c', 'd'], 'zz': ['a', 'b']} + mock_write.assert_called_once_with(expected, 'w') + + @patch('gita.common.get_config_fname', return_value=GROUP_FNAME) + def testRenameError(self, *_): + args = argparse.Namespace() + args.gname = 'xx' + args.new_name = 'yy' + args.group_cmd = 'rename' + utils.get_groups.cache_clear() + with pytest.raises(SystemExit, match='yy already exists.'): + __main__.f_group(args) + + @pytest.mark.parametrize('input, expected', [ + ('xx', {'yy': ['a', 'c', 'd']}), + ("xx yy", {}), + ]) + @patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''}) + @patch('gita.common.get_config_fname', return_value=GROUP_FNAME) + @patch('gita.utils.write_to_groups_file') + def testRm(self, mock_write, _, __, input, expected): + utils.get_groups.cache_clear() + args = ['group', 'rm'] + shlex.split(input) + __main__.main(args) + mock_write.assert_called_once_with(expected, 'w') + + @patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''}) + @patch('gita.common.get_config_fname', return_value=GROUP_FNAME) + @patch('gita.utils.write_to_groups_file') + def testAdd(self, mock_write, *_): + args = argparse.Namespace() + args.to_group = ['a', 'c'] + args.group_cmd = 'add' + args.gname = 'zz' + utils.get_groups.cache_clear() + __main__.f_group(args) + mock_write.assert_called_once_with({'zz': ['a', 'c']}, 'a+') + + @patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''}) + @patch('gita.common.get_config_fname', return_value=GROUP_FNAME) + @patch('gita.utils.write_to_groups_file') + def testAddToExisting(self, mock_write, *_): + args = argparse.Namespace() + args.to_group = ['a', 'c'] + args.group_cmd = 'add' + args.gname = 'xx' + utils.get_groups.cache_clear() + __main__.f_group(args) + mock_write.assert_called_once_with( + {'xx': ['a', 'b', 'c'], 'yy': ['a', 'c', 'd']}, 'w') @patch('gita.utils.is_git', return_value=True) -@patch('gita.utils.get_config_fname', return_value=PATH_FNAME) +@patch('gita.common.get_config_fname', return_value=PATH_FNAME) @patch('gita.utils.rename_repo') def test_rename(mock_rename, _, __): utils.get_repos.cache_clear() @@ -170,9 +287,39 @@ def test_rename(mock_rename, _, __): 'repo1', 'abc') -@patch('os.path.isfile', return_value=False) -def test_info(mock_isfile, capfd): - __main__.f_info(None) - out, err = capfd.readouterr() - assert 'In use: branch,commit_msg\nUnused: path\n' == out - assert err == '' +class TestInfo: + + @patch('gita.common.get_config_fname', return_value='') + def testLl(self, _, capfd): + args = argparse.Namespace() + args.info_cmd = None + __main__.f_info(args) + out, err = capfd.readouterr() + assert 'In use: branch,commit_msg\nUnused: path\n' == out + assert err == '' + + @patch('gita.common.get_config_fname', return_value='') + @patch('yaml.dump') + def testAdd(self, mock_dump, _): + args = argparse.Namespace() + args.info_cmd = 'add' + args.info_item = 'path' + with patch('builtins.open', mock_open(), create=True): + __main__.f_info(args) + mock_dump.assert_called_once() + args, kwargs = mock_dump.call_args + assert args[0] == ['branch', 'commit_msg', 'path'] + assert kwargs == {'default_flow_style': None} + + @patch('gita.common.get_config_fname', return_value='') + @patch('yaml.dump') + def testRm(self, mock_dump, _): + args = argparse.Namespace() + args.info_cmd = 'rm' + args.info_item = 'commit_msg' + with patch('builtins.open', mock_open(), create=True): + __main__.f_info(args) + mock_dump.assert_called_once() + args, kwargs = mock_dump.call_args + assert args[0] == ['branch'] + assert kwargs == {'default_flow_style': None} diff --git a/tests/test_utils.py b/tests/test_utils.py index 3128041..886ddb9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,17 +4,14 @@ from unittest.mock import patch, mock_open from gita import utils, info from conftest import ( - PATH_FNAME, PATH_FNAME_EMPTY, PATH_FNAME_CLASH, GROUP_FNAME, + PATH_FNAME, PATH_FNAME_EMPTY, PATH_FNAME_CLASH, GROUP_FNAME, TEST_DIR, ) @pytest.mark.parametrize('test_input, diff_return, expected', [ - ({ - 'abc': '/root/repo/' - }, True, 'abc \x1b[31mrepo *+_ \x1b[0m msg'), - ({ - 'repo': '/root/repo2/' - }, False, 'repo \x1b[32mrepo _ \x1b[0m msg'), + ([{'abc': '/root/repo/'}, False], True, 'abc \x1b[31mrepo *+_ \x1b[0m msg'), + ([{'abc': '/root/repo/'}, True], True, 'abc repo *+_ msg'), + ([{'repo': '/root/repo2/'}, False], False, 'repo \x1b[32mrepo _ \x1b[0m msg'), ]) def test_describe(test_input, diff_return, expected, monkeypatch): monkeypatch.setattr(info, 'get_head', lambda x: 'repo') @@ -23,8 +20,8 @@ def test_describe(test_input, diff_return, expected, monkeypatch): monkeypatch.setattr(info, 'has_untracked', lambda: True) monkeypatch.setattr('os.chdir', lambda x: None) print('expected: ', repr(expected)) - print('got: ', repr(next(utils.describe(test_input)))) - assert expected == next(utils.describe(test_input)) + print('got: ', repr(next(utils.describe(*test_input)))) + assert expected == next(utils.describe(*test_input)) @pytest.mark.parametrize('path_fname, expected', [ @@ -41,17 +38,28 @@ def test_describe(test_input, diff_return, expected, monkeypatch): }), ]) @patch('gita.utils.is_git', return_value=True) -@patch('gita.utils.get_config_fname') +@patch('gita.common.get_config_fname') def test_get_repos(mock_path_fname, _, path_fname, expected): mock_path_fname.return_value = path_fname utils.get_repos.cache_clear() assert utils.get_repos() == expected +@patch('gita.common.get_config_dir') +def test_get_context(mock_config_dir): + mock_config_dir.return_value = TEST_DIR + utils.get_context.cache_clear() + assert utils.get_context() == TEST_DIR / 'xx.context' + + mock_config_dir.return_value = '/' + utils.get_context.cache_clear() + assert utils.get_context() == None + + @pytest.mark.parametrize('group_fname, expected', [ (GROUP_FNAME, {'xx': ['a', 'b'], 'yy': ['a', 'c', 'd']}), ]) -@patch('gita.utils.get_config_fname') +@patch('gita.common.get_config_fname') def test_get_groups(mock_group_fname, group_fname, expected): mock_group_fname.return_value = group_fname utils.get_groups.cache_clear() diff --git a/tests/xx.context b/tests/xx.context new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/xx.context |