From c18286b8746318f614becc9c518452d6b7c47c49 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Mon, 6 Sep 2021 06:05:38 +0200 Subject: Merging upstream version 0.15.7. Signed-off-by: Daniel Baumann --- README.md | 73 +++++++++++-------------- doc/README_CN.md | 54 ++++++++++-------- gita/__main__.py | 143 ++++++++++++++++++++++++++++++++---------------- gita/info.py | 18 +++--- gita/utils.py | 148 +++++++++++++++++++++++++++++++++++++++++--------- requirements.txt | 1 - setup.py | 4 +- tests/mock_group_file | 4 +- tests/test_main.py | 68 +++++++---------------- tests/test_utils.py | 17 +++--- 10 files changed, 324 insertions(+), 206 deletions(-) diff --git a/README.md b/README.md index f18294e..478c432 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,11 @@ See the [customization section](#custom). The additional status symbols denote -- `+`: staged changes -- `*`: unstaged changes -- `_`: untracked files/folders +symbol | meaning +---|--- + `+`| staged changes + `*`| unstaged changes + `_`| untracked files/folders The bookkeeping sub-commands are @@ -67,21 +69,22 @@ The bookkeeping sub-commands are - `gita add -a `: add repo(s) in recursively and automatically generate hierarchical groups. See the [customization section](#custom) for more details. - `gita add -b `: add bare repo(s) to `gita`. See the [customization section](#custom) for more details on setting custom worktree. -- `gita add -m `: add main repo(s) to `gita`. See the [customization section](#custom) for more details. - `gita add -r `: add repo(s) in recursively - `gita clone `: clone repos in `config-file` (generated by `gita freeze`) to current directory. - `gita clone -p `: clone repos in `config-file` to prescribed paths. - `gita context`: context sub-command - `gita context`: show current context - - `gita context none`: remove context - `gita context `: set context to `group-name`, all operations then only apply to repos in this group + - `gita context auto`: set context automatically according to the current working directory + - `gita context none`: remove context - `gita color`: color sub-command - `gita color [ll]`: Show available colors and the current coloring scheme - `gita color set `: Use the specified color for the local-remote situation - `gita flags`: flags sub-command - `gita flags set `: add custom `flags` to repo - `gita flags [ll]`: display repos with custom flags -- `gita freeze`: print information of all repos such as URL, name, and path. +- `gita freeze`: print information of all repos such as URL, name, and path. Use with + `gita clone`. - `gita group`: group sub-command - `gita group add -n `: add repo(s) to a new or existing group - `gita group [ll]`: display existing groups with repos @@ -95,6 +98,7 @@ The bookkeeping sub-commands are - `gita info rm `: disable information item - `gita ll`: display the status of all repos - `gita ll `: display the status of repos in a group +- `gita ll -g`: display the repo summaries by groups - `gita ls`: display the names of all repos - `gita ls `: display the absolute path of one repo - `gita rename `: rename a repo @@ -211,6 +215,20 @@ gita ll gita pull ``` +The most useful context maybe `auto`. +In this mode, the context is automatically determined from the +current working directory (CWD): the context is the group whose member repo's +path contains CWD. To set it, run + +``` +gita context auto +``` + +To remove the context, run +``` +gita context none +``` + It is also possible to recursively add repos within a directory and generate hierarchical groups automatically. For example, running @@ -229,41 +247,13 @@ src │   └── repo5 └── repo6 ``` -gives rise to +gives rise to 3 groups: ``` src:repo1,repo2,repo3,repo4,repo5,repo6 src-project1:repo1,repo2 src-project2:repo4,repo5 ``` - -### define main repos and shadow the global configuration setting with local setting - -The so-called main repos contain `.gita` folder for local configurations. -It works best for the repos-within-repo project structure, for example, - -``` -main-repo -├── sub-repo1 -│   └── sub-sub-repo -├── sub-repo2 -└── sub-repo3 -``` - -When executing `gita` commands within/relative to a main repo, local configurations -are used. And only repos within the current main repos are in the scope. - -To add a main repo, run - -``` -gita add -m main-repo-path -``` - -Subordinate repos are added recursively to the local configuration. -Only the main repo is saved to the global configuration. - -In the `gita ll` display, the main repos are underlined. - ### add user-defined sub-command using json file Custom delegating sub-commands can be defined in `$XDG_CONFIG_HOME/gita/cmds.json` @@ -341,21 +331,24 @@ branch,commit_msg,commit_time ### customize git command flags -One can set custom flags to run `git` commands. For example +One can set custom flags to run `git` commands. For example, with ``` -gita flags set my-repo --git-dir=$HOME/somefolder --work-tree=$HOME +gita flags set my-repo --git-dir=`gita ls dotfiles` --work-tree=$HOME ``` -Then any `git` command/alias triggered from `gita` on `my-repo` will use these flags. +any `git` command/alias triggered from `gita` on `dotfiles` will use these flags. Note that the flags are applied immediately after `git`. For example, -`gita st my-repo` translates to +`gita st dotfiles` translates to ``` git --git-dir=$HOME/somefolder --work-tree=$HOME status ``` -running from the `my-repo` directory. +running from the `dotfiles` directory. + +This feature was originally added to deal with +[bare repo dotfiles](https://www.atlassian.com/git/tutorials/dotfiles). ## Requirements diff --git a/doc/README_CN.md b/doc/README_CN.md index 03d96a7..9666824 100644 --- a/doc/README_CN.md +++ b/doc/README_CN.md @@ -26,50 +26,58 @@ ![gita screenshot](https://github.com/nosarthur/gita/raw/master/doc/screenshot.png) -本地和远程分支之间的关系有5种情况,在这里分别用5种颜色对应着: +在这个截屏里,`gita ll`显示所有库的状态信息,`gita remote dotfiles`等价于在 +`dotfiles`库里运行`git remote -v`,`gita fetch`对所有库做`fetch`操作,在这个例子 +里,两个库有更新. -- 绿色:本地和远程保持一致 -- 红色:本地和远程产生了分叉 -- 黄色:本地落后于远程(适合合并merge) -- 白色:本地没有指定远程 -- 紫色:本地超前于远程(适合推送push) +本地和远程分支之间的5种关系分别用5种颜色对应: -为什么选择了紫色作为超前以及黄色作为落后,绿色作为基准 的理由在这两篇文章中解释: -[blueshift](https://en.wikipedia.org/wiki/Blueshift)、[redshift](https://en.wikipedia.org/wiki/Redshift) +颜色|含义 +----|---- + 绿色|本地和远程保持一致 + 红色|本地和远程产生了分叉 + 黄色|本地落后于远程(适合合并merge) + 白色|本地没有指定远程 + 紫色|本地超前于远程(适合推送push) + +紫色作为超前,黄色作为落后,绿色作为基准的理由取自蓝移和红移: +[blueshift](https://en.wikipedia.org/wiki/Blueshift)、[redshift](https://en.wikipedia.org/wiki/Redshift). 额外的状态符号意义: - `+`: 暂存(staged) -- `*`: 未暂存(unstaged) -- `_`: 未追踪(untracked) +- `*`: 未暂存(unstaged) +- `_`: 未追踪(untracked) 基础指令: - `gita add `: 添加库 -- `gita add -a `: -- `gita add -b `: -- `gita add -m `: -- `gita add -r `: -- `gita clone `: -- `gita clone -p `: +- `gita add -a `: 递归添加路径下的所有库并自动产生层级分组,见 + [customization section](#custom) +- `gita add -b `: 添加bare库 +- `gita add -m `: 添加main库, main库的定义见 + [customization section](#custom) +- `gita add -r `: 递归添加路径下的所有库 +- `gita clone `: 克隆`` (由`gita freeze`生成)里的库 +- `gita clone -p `: 克隆``里的库并放到指定路径 - `gita context`: 情境命令 - `gita context`: 显示当前的情境 - `gita context none`: 去除情境 - `gita context `: 把情境设置成`group-name`, 之后所有的操作只作用到这个组里的库 - `gita color`: - - `gita color [ll]`: - - `gita color set `: + - `gita color [ll]`:显示颜色设置 + - `gita color set `:给本地/远程关系设置颜色 - `gita flags`: - - `gita flags set `: - - `gita flags [ll]`: -- `gita freeze`: + - `gita flags set `:给库设置flags + - `gita flags [ll]`:显示已有的flags +- `gita freeze`:显示URL, 路径之类的库信息(配合`gita clone`使用) - `gita group`: 组群命令 - `gita group add `: 把库加入新的或者已经存在的组 - `gita group [ll]`: 显示已有的组和它们的库 - `gita group ls`: 显示已有的组名 - `gita group rename `: 改组名 - `gita group rm group(s): 删除组 - - `gita group rmrepo -n : + - `gita group rmrepo -n :删除组里的库 - `gita info`: 显示已用的和未用的信息项 - `gita info [ll]` - `gita info add ` @@ -97,7 +105,7 @@ ## 安装指南 -正常人类按装: +正常人类安装: ``` pip3 install -U gita diff --git a/gita/__main__.py b/gita/__main__.py index 8678d36..d38e78d 100644 --- a/gita/__main__.py +++ b/gita/__main__.py @@ -19,6 +19,7 @@ import sys import csv import argparse import subprocess +from functools import partial import pkg_resources from itertools import chain from pathlib import Path @@ -27,21 +28,37 @@ import glob from . import utils, info, common -def _group_name(name: str) -> str: +def _group_name(name: str, exclude_old_names=True) -> str: """ - + Return valid group name """ repos = utils.get_repos() if name in repos: print(f"Cannot use group name {name} since it's a repo name.") sys.exit(1) + if exclude_old_names: + if name in utils.get_groups(): + print(f"Cannot use group name {name} since it's already in use.") + sys.exit(1) + if name in {'none', 'auto'}: + print(f"Cannot use group name {name} since it's a reserved keyword.") + sys.exit(1) return name +def _path_name(name: str) -> str: + """ + Return absolute path without trailing / + """ + if name: + return os.path.abspath(name).rstrip(os.path.sep) + return '' + + def f_add(args: argparse.Namespace): repos = utils.get_repos() paths = args.paths - if args.main: + if 0: # add to global and tag as main main_repos = utils.add_repos(repos, paths, repo_type='m') # add sub-repo recursively and save to local config @@ -53,11 +70,11 @@ def f_add(args: argparse.Namespace): utils.add_repos({}, sub_paths, root=main_path) else: if args.recursive or args.auto_group: - paths = chain.from_iterable( + paths = (p.rstrip(os.path.sep) for p in chain.from_iterable( glob.glob(os.path.join(p, '**/'), recursive=True) - for p in args.paths) + for p in args.paths)) new_repos = utils.add_repos(repos, paths, is_bare=args.bare) - if args.auto_group: + if new_repos and args.auto_group: new_groups = utils.auto_group(new_repos, args.paths) if new_groups: print(f'Created {len(new_groups)} new group(s).') @@ -159,11 +176,24 @@ def f_ll(args: argparse.Namespace): ctx = utils.get_context() if args.group is None and ctx: args.group = ctx.stem + group_repos = None if args.group: # only display repos in this group - group_repos = utils.get_groups()[args.group] + group_repos = utils.get_groups()[args.group]['repos'] repos = {k: repos[k] for k in group_repos if k in repos} - for line in utils.describe(repos, no_colors=args.no_colors): - print(line) + if args.g: # display by group + if group_repos: + print(f'{args.group}:') + for line in utils.describe(repos, no_colors=args.no_colors): + print(' ', line) + else: + for g, g_repos in utils.get_groups().items(): + print(f'{g}:') + g_repos = {k: repos[k] for k in g_repos if k in repos} + for line in utils.describe(g_repos, no_colors=args.no_colors): + print(' ', line) + else: + for line in utils.describe(repos, no_colors=args.no_colors): + print(line) def f_ls(args: argparse.Namespace): @@ -180,69 +210,73 @@ def f_group(args: argparse.Namespace): if cmd == 'll': if 'to_show' in args and args.to_show: gname = args.to_show - print(' '.join(groups[gname])) + print(' '.join(groups[gname]['repos'])) else: - for group, repos in groups.items(): - print(f"{group}: {' '.join(repos)}") + for group, prop in groups.items(): + print(f"{info.Color.underline}{group}{info.Color.end}: {prop['path']}") + for r in prop['repos']: + print(' -', r) 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') # change context ctx = utils.get_context() - if ctx and str(ctx.stem) == gname: - # ctx.rename(ctx.with_stem(new_name)) # only works in py3.9 - ctx.rename(ctx.with_name(f'{new_name}.context')) + if ctx and ctx.stem == gname: + utils.replace_context(ctx, new_name) elif cmd == 'rm': ctx = utils.get_context() for name in args.to_ungroup: del groups[name] if ctx and str(ctx.stem) == name: - ctx.unlink() + utils.replace_context(ctx, '') utils.write_to_groups_file(groups, 'w') elif cmd == 'add': gname = args.gname if gname in groups: - gname_repos = set(groups[gname]) + gname_repos = set(groups[gname]['repos']) gname_repos.update(args.to_group) - groups[gname] = sorted(gname_repos) + groups[gname]['repos'] = sorted(gname_repos) + if 'gpath' in args: + groups[gname]['path'] = args.gpath utils.write_to_groups_file(groups, 'w') else: - utils.write_to_groups_file({gname: sorted(args.to_group)}, 'a+') + gpath = '' + if 'gpath' in args: + gpath = args.gpath + utils.write_to_groups_file( + {gname: {'repos': sorted(args.to_group), + 'path': gpath}}, + 'a+') elif cmd == 'rmrepo': gname = args.gname if gname in groups: - for repo in args.from_group: - try: - groups[gname].remove(repo) - except ValueError as e: - pass + group = {gname: {'repos': groups[gname]['repos'], + 'path': groups[gname]['path'] + }} + for repo in args.to_rm: + utils.delete_repo_from_groups(repo, group) + groups[gname] = group[gname] utils.write_to_groups_file(groups, 'w') def f_context(args: argparse.Namespace): choice = args.choice ctx = utils.get_context() - if choice is None: + if choice is None: # display current context if ctx: group = ctx.stem - print(f"{group}: {' '.join(utils.get_groups()[group])}") + print(f"{group}: {' '.join(utils.get_groups()[group]['repos'])}") + elif (Path(common.get_config_dir()) / 'auto.context').exists(): + print('auto: none detected!') 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: - open(fname, 'w').close() + utils.replace_context(ctx, choice) def f_rm(args: argparse.Namespace): @@ -255,12 +289,19 @@ def f_rm(args: argparse.Namespace): main_paths = [prop['path'] for prop in repos.values() if prop['type'] == 'm'] # TODO: add test case to delete main repo from main repo # only local setting should be affected instead of the global one + group_updated = False for repo in args.repo: del repos[repo] + groups = utils.get_groups() + group_updated = group_updated or utils.delete_repo_from_groups(repo, groups) + if group_updated: + utils.write_to_groups_file(groups, 'w') + # If cwd is relative to any main repo, write to local config cwd = os.getcwd() + # TODO: delete main path mechanism for p in main_paths: - if utils.is_relative_to(cwd, p): + if utils.get_relative_path(cwd, p) is not None: utils.write_to_repo_file(repos, 'w', p) break else: # global config @@ -283,7 +324,7 @@ def f_git_cmd(args: argparse.Namespace): if k in repos: chosen[k] = repos[k] if k in groups: - for r in groups[k]: + for r in groups[k]['repos']: chosen[r] = repos[r] repos = chosen per_repo_cmds = [] @@ -343,7 +384,6 @@ def f_shell(args): chosen[r] = repos[r] repos = chosen cmds = ' '.join(args.man[i:]) # join the shell command into a single string - #cmds = args.man[i:] for name, prop in repos.items(): # TODO: pull this out as a function got = subprocess.run(cmds, cwd=prop['path'], shell=True, @@ -387,12 +427,10 @@ def main(argv=None): # bookkeeping sub-commands p_add = subparsers.add_parser('add', description='add repo(s)', help='add repo(s)') - p_add.add_argument('paths', nargs='+', type=os.path.abspath, help="repo(s) to add") + p_add.add_argument('paths', nargs='+', type=_path_name, help="repo(s) to add") xgroup = p_add.add_mutually_exclusive_group() xgroup.add_argument('-r', '--recursive', action='store_true', help="recursively add repo(s) in the given path(s).") - xgroup.add_argument('-m', '--main', action='store_true', - help="make main repo(s), sub-repos are recursively added.") xgroup.add_argument('-a', '--auto-group', action='store_true', help="recursively add repo(s) in the given path(s) " "and create hierarchical groups based on folder structure.") @@ -504,6 +542,8 @@ def main(argv=None): help="show repos in the chosen group") p_ll.add_argument('-C', '--no-colors', action='store_true', help='Disable coloring on the branch names.') + p_ll.add_argument('-g', action='store_true', + help='Show repo summaries by group.') p_ll.set_defaults(func=f_ll) p_context = subparsers.add_parser('context', @@ -512,8 +552,12 @@ def main(argv=None): ' 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'. ") + choices=set().union(utils.get_groups(), {'none', 'auto'}), + help="Without this argument, show current context. " + "Otherwise choose a group as context, or use 'auto', " + "which sets the context/group automatically based on " + "the current working directory. " + "To remove context, use 'none'. ") p_context.set_defaults(func=f_context) p_ls = subparsers.add_parser( @@ -545,12 +589,16 @@ def main(argv=None): help="repo(s) to be grouped") pg_add.add_argument('-n', '--name', dest='gname', - type=_group_name, + type=partial(_group_name, exclude_old_names=False), metavar='group-name', - required=True, - help="group name") + required=True) + pg_add.add_argument('-p', '--path', + dest='gpath', + type=_path_name, + metavar='group-path') + pg_rmrepo = group_cmds.add_parser('rmrepo', description='remove repo(s) from a group.') - pg_rmrepo.add_argument('from_group', + pg_rmrepo.add_argument('to_rm', nargs='+', metavar='repo', choices=utils.get_repos(), @@ -641,6 +689,5 @@ def main(argv=None): else: p.print_help() # pragma: no cover - if __name__ == '__main__': main() # pragma: no cover diff --git a/gita/info.py b/gita/info.py index d18a097..ee302d0 100644 --- a/gita/info.py +++ b/gita/info.py @@ -1,6 +1,5 @@ import os import csv -import yaml import subprocess from enum import Enum from pathlib import Path @@ -34,6 +33,15 @@ class Color(str, Enum): underline = '\x1B[4m' +default_colors = { + 'no-remote': Color.white.name, + 'in-sync': Color.green.name, + 'diverged': Color.red.name, + 'local-ahead': Color.purple.name, + 'remote-ahead': Color.yellow.name, + } + + def show_colors(): # pragma: no cover """ @@ -61,13 +69,7 @@ def get_color_encoding() -> Dict[str, str]: reader = csv.DictReader(f) colors = next(reader) else: - colors = { - 'no-remote': Color.white.name, - 'in-sync': Color.green.name, - 'diverged': Color.red.name, - 'local-ahead': Color.purple.name, - 'remote-ahead': Color.yellow.name, - } + colors = default_colors return colors diff --git a/gita/utils.py b/gita/utils.py index 34fc435..7a1020c 100644 --- a/gita/utils.py +++ b/gita/utils.py @@ -1,3 +1,4 @@ +import sys import os import json import csv @@ -13,12 +14,27 @@ from . import info from . import common -# TODO: python3.9 pathlib has is_relative_to() function -def is_relative_to(kid: str, parent: str) -> bool: +MAX_INT = sys.maxsize + + +def get_relative_path(kid: str, parent: str) -> Union[List[str], None]: """ - Both the `kid` and `parent` should be absolute path + Return the relative path depth if relative, otherwise MAX_INT. + + Both the `kid` and `parent` should be absolute paths without trailing / """ - return parent == os.path.commonpath((kid, parent)) + # Note that os.path.commonpath has no trailing / + # TODO: python3.9 pathlib has is_relative_to() function + # TODO: Maybe use os.path.commonprefix? since it's faster? + if parent == '': + return None + if parent == os.path.commonpath((kid, parent)): + rel = os.path.normpath(os.path.relpath(kid, parent)).split(os.sep) + if rel == ['.']: + rel = [] + return rel + else: + return None @lru_cache() @@ -43,7 +59,7 @@ def get_repos(root=None) -> Dict[str, Dict[str, str]]: cwd = os.getcwd() for prop in repos.values(): path = prop['path'] - if prop['type'] == 'm' and is_relative_to(cwd, path): + if prop['type'] == 'm' and get_relative_path(cwd, path) != MAX_INT: return get_repos(path) return repos @@ -51,29 +67,94 @@ def get_repos(root=None) -> Dict[str, Dict[str, str]]: @lru_cache() def get_context() -> Union[Path, None]: """ - Return the context: either a group name or 'none' + Return context file path, or None if not set. Note that if in auto context + mode, the return value is not auto.context but the resolved context, + which could be None. + """ 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 + if len(matches) > 1: + print("Cannot have multiple .context file") + sys.exit(1) + if not matches: + return None + ctx = matches[0] + if ctx.stem == 'auto': + cwd = str(Path.cwd()) + repos = get_repos() + # The context is set to be the group with minimal distance to cwd + candidate = None + min_dist = MAX_INT + for gname, prop in get_groups().items(): + rel = get_relative_path(cwd, prop['path']) + if rel is None: + continue + d = len(rel) + if d < min_dist: + candidate = gname + min_dist = d + if not candidate: + ctx = None + else: + ctx = ctx.with_name(f'{candidate}.context') + return ctx @lru_cache() -def get_groups() -> Dict[str, List[str]]: +def get_groups() -> Dict[str, Dict]: """ - Return a `dict` of group name to repo names. + Return a `dict` of group name to group properties such as repo names and + group path. """ fname = common.get_config_fname('groups.csv') groups = {} - # Each line is a repo path and repo name separated by , + # Each line is: group-name:repo1 repo2 repo3:group-path if os.path.isfile(fname) and os.stat(fname).st_size > 0: with open(fname, 'r') as f: - rows = csv.reader(f, delimiter=':') - groups = {r[0]: r[1].split() for r in rows} + rows = csv.DictReader(f, ['name', 'repos', 'path'], + restval='', delimiter=':') + groups = { + r['name']: { + 'repos': r['repos'].split(), + 'path': r['path'] + } + for r in rows} return groups +def delete_repo_from_groups(repo: str, groups: Dict[str, Dict]) -> bool: + """ + Delete repo from groups + """ + deleted = False + for name in groups: + try: + groups[name]['repos'].remove(repo) + except ValueError as e: + pass + else: + deleted = True + return deleted + + +def replace_context(old: Union[Path, None], new: str): + """ + + """ + auto = Path(common.get_config_dir()) / 'auto.context' + if auto.exists(): + old = auto + + if new == 'none': # delete + old and old.unlink() + elif old: + # ctx.rename(ctx.with_stem(new_name)) # only works in py3.9 + old.rename(old.with_name(f'{new}.context')) + else: + open(auto.with_name(f'{new}.context'), 'w').close() + + def get_choices() -> List[Union[str, None]]: """ Return all repo names, group names, and an additional empty list. The empty @@ -117,6 +198,7 @@ def is_git(path: str, is_bare=False) -> bool: return True return False + def rename_repo(repos: Dict[str, Dict[str, str]], repo: str, new_name: str): """ Write new repo name to file @@ -131,8 +213,9 @@ def rename_repo(repos: Dict[str, Dict[str, str]], repo: str, new_name: str): main_paths = (prop['path'] for prop in repos.values() if prop['type'] == 'm') cwd = os.getcwd() is_local_config = True + # TODO: delete for p in main_paths: - if is_relative_to(cwd, p): + if get_relative_path(cwd, p) != MAX_INT: write_to_repo_file(repos, 'w', p) break else: # global config @@ -163,7 +246,8 @@ def write_to_repo_file(repos: Dict[str, Dict[str, str]], mode: str, root=None): writer.writerows(data) -def write_to_groups_file(groups: Dict[str, List[str]], mode: str): +# TODO: combine with the repo writer +def write_to_groups_file(groups: Dict[str, Dict], mode: str): """ """ @@ -174,8 +258,8 @@ def write_to_groups_file(groups: Dict[str, List[str]], mode: str): else: with open(fname, mode, newline='') as f: data = [ - (group, ' '.join(repos)) - for group, repos in groups.items() + (group, ' '.join(prop['repos']), prop['path']) + for group, prop in groups.items() ] writer = csv.writer(f, delimiter=':', quotechar='"', quoting=csv.QUOTE_MINIMAL) writer.writerows(data) @@ -191,11 +275,13 @@ def _make_name(path: str, repos: Dict[str, Dict[str, str]], """ name = os.path.basename(os.path.normpath(path)) if name in repos or name_counts[name] > 1: + # path has no trailing / par_name = os.path.basename(os.path.dirname(path)) return os.path.join(par_name, name) return name +# TODO: delete def _get_repo_type(path, repo_type, root) -> str: """ @@ -236,37 +322,45 @@ def add_repos(repos: Dict[str, Dict[str, str]], new_paths: List[str], return new_repos -def _generate_dir_hash(repo_path: str, paths: List[str]) -> Tuple[str, ...]: +def _generate_dir_hash(repo_path: str, paths: List[str]) -> Tuple[ + Tuple[str, ...], str]: """ - Return relative parent strings + Return relative parent strings, and the parent head string For example, if `repo_path` is /a/b/c/d/here, and one of `paths` is /a/b/ then return (b, c, d) """ for p in paths: - if is_relative_to(repo_path, p): + rel = get_relative_path(repo_path, p)[:-1] + if rel is not None: break else: - return () - return (os.path.basename(p), - *os.path.normpath(os.path.relpath(repo_path, p)).split(os.sep)[:-1]) + return (), '' + head, tail = os.path.split(p) + return (tail, *rel), head def auto_group(repos: Dict[str, Dict[str, str]], paths: List[str] - ) -> Dict[str, List[str]]: + ) -> Dict[str, Dict]: """ + @params repos: repos to be grouped """ # FIXME: the upstream code should make sure that paths are all independent # i.e., each repo should be contained in one and only one path - new_groups = defaultdict(list) + new_groups = defaultdict(dict) for repo_name, prop in repos.items(): - hash = _generate_dir_hash(prop['path'], paths) + hash, head = _generate_dir_hash(prop['path'], paths) if not hash: continue for i in range(1, len(hash)+1): group_name = '-'.join(hash[:i]) - new_groups[group_name].append(repo_name) + prop = new_groups[group_name] + prop['path'] = os.path.join(head, *hash[:i]) + if 'repos' not in prop: + prop['repos'] = [repo_name] + else: + prop['repos'].append(repo_name) # FIXME: need to make sure the new group names don't clash with old ones # or repo names return new_groups diff --git a/requirements.txt b/requirements.txt index 6d2823f..1ca2dbe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,3 @@ pytest-cov>=2.6.1 pytest-xdist>=2.1.0 setuptools>=40.6.3 twine>=1.12.1 -pyyaml>=5.1 diff --git a/setup.py b/setup.py index bc82040..555cf13 100644 --- a/setup.py +++ b/setup.py @@ -7,14 +7,14 @@ with open('README.md', encoding='utf-8') as f: setup( name='gita', packages=['gita'], - version='0.15.2', + version='0.15.7', license='MIT', description='Manage multiple git repos with sanity', long_description=long_description, long_description_content_type='text/markdown', url='https://github.com/nosarthur/gita', platforms=['linux', 'osx', 'win32'], - keywords=['git', 'manage multiple repositories'], + keywords=['git', 'manage multiple repositories', 'cui', 'command-line'], author='Dong Zhou', author_email='zhou.dong@gmail.com', entry_points={'console_scripts': ['gita = gita.__main__:main']}, diff --git a/tests/mock_group_file b/tests/mock_group_file index 1187366..d0d950c 100644 --- a/tests/mock_group_file +++ b/tests/mock_group_file @@ -1,2 +1,2 @@ -xx:a b -yy:a c d +xx:a b +yy:a c d diff --git a/tests/test_main.py b/tests/test_main.py index d000e1a..b39ed47 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -26,7 +26,6 @@ class TestAdd: @pytest.mark.parametrize('input, expected', [ (['add', '.'], ''), - (['add', '-m', '.'], 'm'), ]) @patch('gita.common.get_config_fname') def test_add(self, mock_path_fname, tmp_path, input, expected): @@ -40,35 +39,6 @@ class TestAdd: assert len(got) == 1 assert got['gita']['type'] == expected - @patch('gita.utils.is_git', return_value=True) - def test_add_main(self, _, tmp_path, monkeypatch, tmpdir): - def side_effect(root=None): - if root is None: - return os.path.join(tmp_path, "gita") - else: - return os.path.join(root, ".gita") - - def desc(repos, **_): - print(len(repos), repos.keys()) - assert len(repos) > 0 - for r, prop in repos.items(): - if prop['type'] == 'm': - assert 'test_add_main' in r - break - else: - assert 0, 'no main repo found' - return '' - - monkeypatch.setattr(common, 'get_config_dir', side_effect) - monkeypatch.setattr(utils, 'describe', desc) - - utils.get_repos.cache_clear() - - with tmpdir.as_cwd(): - __main__.main(['add', '-m', '.']) - utils.get_repos.cache_clear() - __main__.main(['ll']) - @pytest.mark.parametrize('path_fname, expected', [ (PATH_FNAME, ''), @@ -293,14 +263,14 @@ def test_shell(mock_run, _, input): class TestContext: @patch('gita.utils.get_context', return_value=None) - def testDisplayNoContext(self, _, capfd): + def test_display_no_context(self, _, capfd): __main__.main(['context']) out, err = capfd.readouterr() assert err == '' assert 'Context is not set\n' == out @patch('gita.utils.get_context', return_value=Path('gname.context')) - @patch('gita.utils.get_groups', return_value={'gname': ['a', 'b']}) + @patch('gita.utils.get_groups', return_value={'gname': {'repos': ['a', 'b']}}) def test_display_context(self, _, __, capfd): __main__.main(['context']) out, err = capfd.readouterr() @@ -353,7 +323,7 @@ class TestGroupCmd: __main__.f_group(args) out, err = capfd.readouterr() assert err == '' - assert 'xx: a b\nyy: a c d\n' == out + assert out == '\x1b[4mxx\x1b[0m: \n - a\n - b\n\x1b[4myy\x1b[0m: \n - a\n - c\n - d\n' @patch('gita.common.get_config_fname', return_value=GROUP_FNAME) def test_ll_with_group(self, _, capfd): @@ -376,21 +346,19 @@ class TestGroupCmd: args.group_cmd = 'rename' utils.get_groups.cache_clear() __main__.f_group(args) - expected = {'yy': ['a', 'c', 'd'], 'zz': ['a', 'b']} + expected = {'yy': {'repos': ['a', 'c', 'd'], 'path': ''}, + 'zz': {'repos': ['a', 'b'], 'path': ''}} mock_write.assert_called_once_with(expected, 'w') + @patch('gita.info.get_color_encoding', return_value=info.default_colors) @patch('gita.common.get_config_fname', return_value=GROUP_FNAME) def test_rename_error(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) + with pytest.raises(SystemExit, match="1"): + __main__.main('group rename xx yy'.split()) @pytest.mark.parametrize('input, expected', [ - ('xx', {'yy': ['a', 'c', 'd']}), + ('xx', {'yy': {'repos': ['a', 'c', 'd'], 'path': ''}}), ("xx yy", {}), ]) @patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''}) @@ -412,7 +380,8 @@ class TestGroupCmd: args.gname = 'zz' utils.get_groups.cache_clear() __main__.f_group(args) - mock_write.assert_called_once_with({'zz': ['a', 'c']}, 'a+') + mock_write.assert_called_once_with( + {'zz': {'repos': ['a', 'c'], 'path': ''}}, 'a+') @patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''}) @patch('gita.common.get_config_fname', return_value=GROUP_FNAME) @@ -425,20 +394,22 @@ class TestGroupCmd: utils.get_groups.cache_clear() __main__.f_group(args) mock_write.assert_called_once_with( - {'xx': ['a', 'b', 'c'], 'yy': ['a', 'c', 'd']}, 'w') + {'xx': {'repos': ['a', 'b', 'c'], 'path': ''}, + 'yy': {'repos': ['a', 'c', 'd'], 'path': ''}}, '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 test_rm_repo(self, mock_write, *_): args = argparse.Namespace() - args.from_group = ['a', 'c'] + args.to_rm = ['a', 'c'] args.group_cmd = 'rmrepo' args.gname = 'xx' utils.get_groups.cache_clear() __main__.f_group(args) mock_write.assert_called_once_with( - {'xx': ['b'], 'yy': ['a', 'c', 'd']}, 'w') + {'xx': {'repos': ['b'], 'path': ''}, + 'yy': {'repos': ['a', 'c', 'd'], 'path': ''}}, 'w') @patch('gita.common.get_config_fname') def test_integration(self, mock_path_fname, tmp_path, capfd): @@ -510,15 +481,16 @@ class TestInfo: def test_set_color(mock_get_fname, tmpdir): args = argparse.Namespace() args.color_cmd = 'set' - args.color = 'redrum' # this color doesn't exist - args.situation = 'in-sync' + args.color = 'b_white' + args.situation = 'no-remote' with tmpdir.as_cwd(): csv_config = Path.cwd() / 'colors.csv' mock_get_fname.return_value = csv_config __main__.f_color(args) + info.get_color_encoding.cache_clear() # avoid side effect items = info.get_color_encoding() info.get_color_encoding.cache_clear() # avoid side effect - assert items == {'no-remote': 'white', 'in-sync': 'redrum', + assert items == {'no-remote': 'b_white', 'in-sync': 'green', 'diverged': 'red', 'local-ahead': 'purple', 'remote-ahead': 'yellow'} diff --git a/tests/test_utils.py b/tests/test_utils.py index 39430b0..3ff0cb1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,7 +11,7 @@ from conftest import ( @pytest.mark.parametrize('repo_path, paths, expected', [ - ('/a/b/c/repo', ['/a/b'], ('b', 'c')), + ('/a/b/c/repo', ['/a/b'], (('b', 'c'), '/a')), ]) def test_generate_dir_hash(repo_path, paths, expected): got = utils._generate_dir_hash(repo_path, paths) @@ -20,11 +20,13 @@ def test_generate_dir_hash(repo_path, paths, expected): @pytest.mark.parametrize('repos, paths, expected', [ ({'r1': {'path': '/a/b//repo1'}, 'r2': {'path': '/a/b/repo2'}}, - ['/a/b'], {'b': ['r1', 'r2']}), + ['/a/b'], {'b': {'repos': ['r1', 'r2'], 'path': '/a/b'}}), ({'r1': {'path': '/a/b//repo1'}, 'r2': {'path': '/a/b/c/repo2'}}, - ['/a/b'], {'b': ['r1', 'r2'], 'b-c': ['r2']}), + ['/a/b'], {'b': {'repos': ['r1', 'r2'], 'path': '/a/b'}, + 'b-c': {'repos': ['r2'], 'path': "/a/b/c"}}), ({'r1': {'path': '/a/b/c/repo1'}, 'r2': {'path': '/a/b/c/repo2'}}, - ['/a/b'], {'b-c': ['r1', 'r2'], 'b': ['r1', 'r2']}), + ['/a/b'], {'b-c': {'repos': ['r1', 'r2'], 'path': '/a/b/c'}, + 'b': {'path': '/a/b', 'repos': ['r1', 'r2']}}), ]) def test_auto_group(repos, paths, expected): got = utils.auto_group(repos, paths) @@ -46,8 +48,8 @@ def test_describe(test_input, diff_return, expected, monkeypatch): monkeypatch.setattr(info, 'get_commit_time', lambda *_: "xx") 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)))) + + info.get_color_encoding.cache_clear() # avoid side effect assert expected == next(utils.describe(*test_input)) @@ -83,7 +85,8 @@ def test_get_context(mock_config_dir): @pytest.mark.parametrize('group_fname, expected', [ - (GROUP_FNAME, {'xx': ['a', 'b'], 'yy': ['a', 'c', 'd']}), + (GROUP_FNAME, {'xx': {'repos': ['a', 'b'], 'path': ''}, + 'yy': {'repos': ['a', 'c', 'd'], 'path': ''}}), ]) @patch('gita.common.get_config_fname') def test_get_groups(mock_group_fname, group_fname, expected): -- cgit v1.2.3