diff options
-rw-r--r-- | .github/workflows/nos.yml | 2 | ||||
-rw-r--r-- | README.md | 107 | ||||
-rw-r--r-- | doc/README_CN.md | 2 | ||||
-rw-r--r-- | gita/__main__.py | 135 | ||||
-rw-r--r-- | gita/info.py | 37 | ||||
-rw-r--r-- | gita/utils.py | 18 | ||||
-rw-r--r-- | setup.py | 3 | ||||
-rw-r--r-- | tests/test_main.py | 39 |
8 files changed, 252 insertions, 91 deletions
diff --git a/.github/workflows/nos.yml b/.github/workflows/nos.yml index d75defa..e6c012b 100644 --- a/.github/workflows/nos.yml +++ b/.github/workflows/nos.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.6, 3.7, 3.8] + python-version: [3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -14,7 +14,7 @@ | | ____ | | | | | ___ | | | \_ ) | | | | | ( ) | | (___) |__) (___ | | | ) ( | -(_______)_______/ )_( |/ \| v0.11 +(_______)_______/ )_( |/ \| v0.12 ``` # Gita: a command-line tool to manage multiple git repos @@ -24,17 +24,18 @@ This tool does two things - display the status of multiple git repos such as branch, modification, commit message side by side - (batch) delegate git commands/aliases from any working directory -If several repos are related, it helps to see their status together too. +If several repos are related, it helps to see their status together. I also hate to change directories to execute git commands. ![gita screenshot](https://github.com/nosarthur/gita/raw/master/doc/screenshot.png) In the screenshot, the `gita remote nowhub` command translates to `git remote -v` -for the `nowhub` repo. +for the `nowhub` repo, even though we are at the `blog` 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). +To run arbitrary shell command, see the [shell mode section](#shell). The branch color distinguishes 5 situations between local and remote branches: @@ -47,6 +48,8 @@ The branch color distinguishes 5 situations between local and remote branches: The choice of purple for ahead and yellow for behind is motivated by [blueshift](https://en.wikipedia.org/wiki/Blueshift) and [redshift](https://en.wikipedia.org/wiki/Redshift), using green as baseline. +You can change the color scheme using the `gita color` sub-command. +See the [customization section](#custom). The additional status symbols denote @@ -57,6 +60,7 @@ The additional status symbols denote The bookkeeping sub-commands are - `gita add <repo-path(s)>`: add repo(s) to `gita` +- `gita clone <config-file>`: clone repos in `config-file` (generated by `gita freeze`) to current directory. - `gita context`: context sub-command - `gita context`: show current context - `gita context none`: remove context @@ -64,12 +68,14 @@ The bookkeeping sub-commands are - `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 freeze`: print information of all repos such as URL, name, and path. - `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 group rmrepo <repo-name(s)> -n <group-name>`: remove repo(s) from existing group - `gita info`: info sub-command - `gita info [ll]`: display the used and unused information items - `gita info add <info-item>`: enable information item @@ -82,22 +88,24 @@ The bookkeeping sub-commands are - `gita rm <repo-name(s)>`: remove repo(s) from `gita` (won't remove files from disk) - `gita -v`: display gita version -The delegating sub-commands are of two formats +The `git` 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**. - `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. +They translate to `git <sub-command>` for the corresponding repos. +By default, only `fetch` and `pull` take optional input. In other words, +`gita fetch` and `gita pull` apply to all repos. 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 add your own sub-commands or override the default behaviors, 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. +If more than one repos are specified, the `git` command runs 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`). @@ -109,8 +117,7 @@ To install the latest version, run pip3 install -U gita ``` -If development mode is preferred, -download the source code and run +If you prefer development mode, download the source code and run ``` pip3 install -e <gita-source-folder> @@ -123,8 +130,8 @@ then you can put the following line in the `.bashrc` file. alias gita="python3 -m gita" ``` -Windows users may need to enable the ANSI escape sequence in terminal, otherwise -the branch color won't work. +Windows users may need to enable the ANSI escape sequence in terminal for +the branch color to work. See [this stackoverflow post](https://stackoverflow.com/questions/51680709/colored-text-output-in-powershell-console-using-ansi-vt100-codes) for details. ## Auto-completion @@ -133,11 +140,11 @@ Download [.gita-completion.bash](https://github.com/nosarthur/gita/blob/master/.gita-completion.bash) or [.gita-completion.zsh](https://github.com/nosarthur/gita/blob/master/.gita-completion.zsh) -and source it in the .rc file. +and source it in the corresponding rc file. ## <a name='superman'></a> Superman mode -The superman mode delegates any git command/alias. +The superman mode delegates any `git` command or alias. Usage: ``` @@ -151,8 +158,25 @@ 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` +## <a name='shell'></a> Shell mode + +The shell mode delegates any shell command. +Usage: + +``` +gita shell [repo-name(s) or group-name(s)] <any-shell-command> +``` + +Here `repo-name(s)` or `group-name(s)` are optional, and their absence means all repos. +For example, + +- `gita shell ll` lists contents for all repos +- `gita shell repo1 mkdir docs` create a new directory `docs` in repo1 + ## <a name='custom'></a> Customization +### user-defined sub-command using yaml file + Custom delegating sub-commands can be defined in `$XDG_CONFIG_HOME/gita/cmds.yml` (most likely `~/.config/gita/cmds.yml`). And they shadow the default ones if name collisions exist. @@ -167,15 +191,15 @@ stat: help: show edit statistics ``` -which executes `git diff --stat`. +which executes `git diff --stat` for the specified repo(s). -If the delegated git command is a single word, the `cmd` tag can be omitted. +If the delegated `git` command is a single word, the `cmd` tag can be omitted. See `push` for an example. To disable asynchronous execution, set the `disable_async` tag to be `true`. See `difftool` for an example. -If you want a custom command to behave like `gita fetch`, i.e., to apply -command to all repos if nothing is specified, +If you want a custom command to behave like `gita fetch`, i.e., to apply the +command to all repos when no repo is specified, set the `allow_all` option to be `true`. For example, the following snippet creates a new command `gita comaster [repo-name(s)]` with optional repo name input. @@ -187,11 +211,17 @@ comaster: help: checkout the master branch ``` -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. -(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.) +### customize the local/remote relationship coloring displayed by the `gita ll` command + +You can see the default color scheme and the available colors via `gita color`. +To change the color coding, use `gita color set <situation> <color>`. +The configuration is saved in `$XDG_CONFIG_HOME/gita/color.yml`. + +### customize information displayed by the `gita ll` command + +You can customize the information displayed by `gita ll`. +The used and unused information items are shown with `gita info`, and the +configuration is saved in `$XDG_CONFIG_HOME/gita/info.yml`. For example, the default information items setting corresponds to @@ -200,28 +230,13 @@ For example, the default information items setting corresponds to - commit_msg ``` -To create your own information items, define a dictionary called `extra_info_items` -in `$XDG_CONFIG_HOME/gita/extra_repo_info.py`. It should map strings to functions, -where the strings are the information item names and the functions take repo path -as input. A trivial example is shown below. - -```python -def get_delim(path: str) -> str: - return '|' - -extra_info_items = {'delim': get_delim} -``` - -If it works, you will see these extra items in the 'Unused' section of the -`gita info` output. To use them, edit `$XDG_CONFIG_HOME/gita/extra_repo_info.py`. - ## Requirements Gita requires Python 3.6 or higher, due to the use of [f-string](https://www.python.org/dev/peps/pep-0498/) and [asyncio module](https://docs.python.org/3.6/library/asyncio.html). -Under the hood, gita uses subprocess to run git commands/aliases. +Under the hood, gita uses `subprocess` to run git commands/aliases. Thus the installed git version may matter. I have git `1.8.3.1`, `2.17.2`, and `2.20.1` on my machines, and their results agree. @@ -243,18 +258,6 @@ A step-by-step guide to reproduce this project is [here](https://nosarthur.githu You can also sponsor me on [GitHub](https://github.com/sponsors/nosarthur). Any amount is appreciated! -## Contributors - -[![nosarthur](https://github.com/nosarthur.png?size=40 "nosarthur")](https://github.com/nosarthur) -[![mc0239](https://github.com/mc0239.png?size=40 "mc0239")](https://github.com/mc0239) -[![dgrant](https://github.com/dgrant.png?size=40 "dgrant")](https://github.com/dgrant) -[![samibh](https://github.com/github.png?size=40 "samibh")](https://github.com/samibh) -[![wbrn](https://github.com/wbrn.png?size=40 "wbrn")](https://github.com/wbrn) -[![TpOut](https://github.com/TpOut.png?size=40 "TpOut")](https://github.com/TpOut) -[![PabloCastellano](https://github.com/PabloCastellano.png?size=40 "PabloCastellano")](https://github.com/PabloCastellano) -[![cd3](https://github.com/cd3.png?size=40 "cd3")](https://github.com/cd3) -[![Steve-Xyh](https://github.com/Steve-Xyh.png?size=40 "Steve-Xyh")](https://github.com/Steve-Xyh) - ## Other multi-repo tools I haven't tried them but I heard good things about them. diff --git a/doc/README_CN.md b/doc/README_CN.md index 051bbe8..80d1ccd 100644 --- a/doc/README_CN.md +++ b/doc/README_CN.md @@ -14,7 +14,7 @@ | | ____ | | | | | ___ | | | \_ ) | | | | | ( ) | | (___) |__) (___ | | | ) ( | -(_______)_______/ )_( |/ \| v0.11 +(_______)_______/ )_( |/ \| v0.12 ``` # Gita:一个管理多个 git 库的命令行工具 diff --git a/gita/__main__.py b/gita/__main__.py index 9a24bb9..beecab2 100644 --- a/gita/__main__.py +++ b/gita/__main__.py @@ -44,7 +44,11 @@ def f_color(args: argparse.Namespace): if cmd == 'll': # pragma: no cover info.show_colors() elif cmd == 'set': - print('not implemented') + colors = info.get_color_encoding() + colors[args.situation] = info.Color[args.color].value + yml_config = common.get_config_fname('color.yml') + with open(yml_config, 'w') as f: + yaml.dump(colors, f, default_flow_style=None) def f_info(args: argparse.Namespace): @@ -68,6 +72,23 @@ def f_info(args: argparse.Namespace): yaml.dump(to_display, f, default_flow_style=None) +def f_clone(args: argparse.Namespace): + path = Path.cwd() + errors = utils.exec_async_tasks( + utils.run_async(repo_name, path, ['git', 'clone', url]) + for url, repo_name, _ in utils.parse_clone_config(args.fname)) + + +def f_freeze(_): + repos = utils.get_repos() + for name, path in repos.items(): + url = '' + cp = subprocess.run(['git', 'remote', '-v'], cwd=path, capture_output=True) + if cp.returncode == 0: + url = cp.stdout.decode('utf-8').split('\n')[0].split()[1] + print(f'{url},{name},{path}') + + def f_ll(args: argparse.Namespace): """ Display details of all repos @@ -108,8 +129,11 @@ def f_group(args: argparse.Namespace): del groups[gname] utils.write_to_groups_file(groups, 'w') 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.write_to_groups_file(groups, 'w') elif cmd == 'add': gname = args.gname @@ -120,6 +144,15 @@ 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+') + 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 + utils.write_to_groups_file(groups, 'w') def f_context(args: argparse.Namespace): @@ -189,6 +222,42 @@ def f_git_cmd(args: argparse.Namespace): subprocess.run(cmds, cwd=path) +def f_shell(args): + """ + Delegate shell command defined in `args.man`, which may or may not + contain repo names. + """ + names = [] + repos = utils.get_repos() + groups = utils.get_groups() + ctx = utils.get_context() + for i, word in enumerate(args.man): + if word in repos or word in groups: + names.append(word) + else: + break + args.repo = names + # TODO: redundant with f_git_cmd + 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: + if k in repos: + chosen[k] = repos[k] + if k in groups: + for r in groups[k]: + chosen[r] = repos[r] + repos = chosen + cmds = args.man[i:] + for name, path in repos.items(): + # TODO: pull this out as a function + got = subprocess.run(cmds, cwd=path, check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + print(utils.format_output(got.stdout.decode(), name)) + + def f_super(args): """ Delegate git command/alias defined in `args.man`, which may or may not @@ -221,32 +290,40 @@ def main(argv=None): version=f'%(prog)s {version}') # 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 = subparsers.add_parser('add', description='add repo(s)', + help='add repo(s)') + p_add.add_argument('paths', nargs='+', help="repo(s) to add") 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)') + p_rm = subparsers.add_parser('rm', description='remove repo(s)', + help='remove repo(s)') p_rm.add_argument('repo', nargs='+', choices=utils.get_repos(), help="remove the chosen repo(s)") p_rm.set_defaults(func=f_rm) - p_rename = subparsers.add_parser('rename', help='rename a repo') + p_freeze = subparsers.add_parser('freeze', description='print all repo information') + p_freeze.set_defaults(func=f_freeze) + + p_clone = subparsers.add_parser('clone', description='clone repos from config file') + p_clone.add_argument('fname', + help='config file. Its content should be the output of `gita freeze`.') + p_clone.set_defaults(func=f_clone) + + p_rename = subparsers.add_parser('rename', description='rename a repo') p_rename.add_argument( 'repo', nargs=1, choices=utils.get_repos(), help="rename the chosen repo") - p_rename.add_argument( - 'new_name', - help="new name") + p_rename.add_argument('new_name', help="new name") p_rename.set_defaults(func=f_rename) p_color = subparsers.add_parser('color', - help='display and modify branch coloring of the ll sub-command.') + description='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') @@ -262,7 +339,7 @@ def main(argv=None): help="available colors") p_info = subparsers.add_parser('info', - help='list, add, or remove information items of the ll sub-command.') + description='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') @@ -297,12 +374,12 @@ 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', + p_ll.add_argument('-C', '--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.' + description='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='?', @@ -311,7 +388,7 @@ def main(argv=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') + 'ls', description='display names of all repos, or path of a chosen repo') p_ls.add_argument('repo', nargs='?', choices=utils.get_repos(), @@ -319,7 +396,7 @@ def main(argv=None): p_ls.set_defaults(func=f_ls) p_group = subparsers.add_parser( - 'group', help='list, add, or remove repo group(s)') + 'group', description='list, add, or remove repo group(s)') p_group.set_defaults(func=f_group) group_cmds = p_group.add_subparsers(dest='group_cmd', help='additional help with sub-command -h') @@ -336,6 +413,17 @@ def main(argv=None): metavar='group-name', required=True, help="group name") + pg_rmrepo = group_cmds.add_parser('rmrepo', description='remove repo(s) from a group.') + pg_rmrepo.add_argument('from_group', + nargs='+', + metavar='repo', + choices=utils.get_repos(), + help="repo(s) to be removed from the group") + pg_rmrepo.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(), @@ -351,7 +439,7 @@ def main(argv=None): # superman mode p_super = subparsers.add_parser( 'super', - help='superman mode: delegate any git command/alias in specified or ' + description='Superman mode: delegate any git command/alias in specified or ' 'all repo(s).\n' 'Examples:\n \t gita super myrepo1 commit -am "fix a bug"\n' '\t gita super repo1 repo2 repo3 checkout new-feature') @@ -363,6 +451,21 @@ def main(argv=None): "Another: gita super checkout master ") p_super.set_defaults(func=f_super) + # shell mode + p_shell = subparsers.add_parser( + 'shell', + description='shell mode: delegate any shell command in specified or ' + 'all repo(s).\n' + 'Examples:\n \t gita shell pwd\n' + '\t gita shell repo1 repo2 repo3 touch xx') + p_shell.add_argument( + 'man', + nargs=argparse.REMAINDER, + help="execute arbitrary shell command for specified or all repos " + "Example: gita shell myrepo1 ls" + "Another: gita shell git checkout master ") + p_shell.set_defaults(func=f_shell) + # sub-commands that fit boilerplate cmds = utils.get_cmds_from_files() for name, data in cmds.items(): @@ -376,7 +479,7 @@ def main(argv=None): choices = utils.get_repos().keys() | utils.get_groups().keys() nargs = '+' help += ' for the chosen repo(s) or group(s)' - sp = subparsers.add_parser(name, help=help) + sp = subparsers.add_parser(name, description=help) sp.add_argument('repo', nargs=nargs, choices=choices, help=help) sp.set_defaults(func=f_git_cmd, cmd=cmd.split()) diff --git a/gita/info.py b/gita/info.py index 473127a..a8044e9 100644 --- a/gita/info.py +++ b/gita/info.py @@ -37,29 +37,36 @@ def show_colors(): # pragma: no cover """ """ + names = {c.value: c.name for c in Color} 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} ') + for situation, c in sorted(get_color_encoding().items()): + print(f'{situation:<12}: {c}{names[c]:<8}{Color.end} ') @lru_cache() -def get_color_encoding(): +def get_color_encoding() -> Dict[str, str]: """ - + Return color scheme for different local/remote situations. """ - # TODO: add config file - return { - 'no-remote': Color.white, - 'in-sync': Color.green, - 'diverged': Color.red, - 'local-ahead': Color.purple, - 'remote-ahead': Color.yellow, + # custom settings + yml_config = Path(common.get_config_fname('color.yml')) + if yml_config.is_file(): + with open(yml_config, 'r') as stream: + colors = yaml.load(stream, Loader=yaml.FullLoader) + else: + colors = { + 'no-remote': Color.white.value, + 'in-sync': Color.green.value, + 'diverged': Color.red.value, + 'local-ahead': Color.purple.value, + 'remote-ahead': Color.yellow.value, } + return colors def get_info_funcs() -> List[Callable[[str], str]]: @@ -82,20 +89,20 @@ def get_info_items() -> List[str]: """ Return the information items to be displayed in the `gita ll` command. """ - # default settings - display_items = ['branch', 'commit_msg'] - # custom settings 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 ALL_INFO_ITEMS] + else: + # default settings + display_items = ['branch', 'commit_msg'] return display_items def get_path(path): - return Color.cyan + path + Color.end + return f'{Color.cyan}{path}{Color.end}' def get_head(path: str) -> str: diff --git a/gita/utils.py b/gita/utils.py index d30a82e..9572f02 100644 --- a/gita/utils.py +++ b/gita/utils.py @@ -4,7 +4,7 @@ import asyncio import platform from functools import lru_cache, partial from pathlib import Path -from typing import List, Dict, Coroutine, Union +from typing import List, Dict, Coroutine, Union, Iterator from . import info from . import common @@ -60,7 +60,6 @@ def get_groups() -> Dict[str, List[str]]: return groups - def get_choices() -> List[Union[str, None]]: """ Return all repo names, group names, and an additional empty list. The empty @@ -128,6 +127,8 @@ def write_to_groups_file(groups: Dict[str, List[str]], mode: str): def add_repos(repos: Dict[str, str], new_paths: List[str]): """ Write new repo paths to file + + @param repos: name -> path """ existing_paths = set(repos.values()) new_paths = set(os.path.abspath(p) for p in new_paths if is_git(p)) @@ -142,6 +143,15 @@ def add_repos(repos: Dict[str, str], new_paths: List[str]): print('No new repos found!') +def parse_clone_config(fname: str) -> Iterator[List[str]]: + """ + Return the url, name, and path of all repos in `fname`. + """ + with open(fname) as f: + for line in f: + yield line.strip().split(',') + + async def run_async(repo_name: str, path: str, cmds: List[str]) -> Union[None, str]: """ Run `cmds` asynchronously in `path` directory. Return the `path` if @@ -157,7 +167,7 @@ async def run_async(repo_name: str, path: str, cmds: List[str]) -> Union[None, s stdout, stderr = await process.communicate() for pipe in (stdout, stderr): if pipe: - print(format_output(pipe.decode(), f'{repo_name}: ')) + print(format_output(pipe.decode(), repo_name)) # The existence of stderr is not good indicator since git sometimes write # to stderr even if the execution is successful, e.g. git fetch if process.returncode != 0: @@ -168,7 +178,7 @@ def format_output(s: str, prefix: str): """ Prepends every line in given string with the given prefix. """ - return ''.join([f'{prefix}{line}' for line in s.splitlines(keepends=True)]) + return ''.join([f'{prefix}: {line}' for line in s.splitlines(keepends=True)]) def exec_async_tasks(tasks: List[Coroutine]) -> List[Union[None, str]]: @@ -7,7 +7,7 @@ with open('README.md', encoding='utf-8') as f: setup( name='gita', packages=['gita'], - version='0.11.9', + version='0.12.7', license='MIT', description='Manage multiple git repos with sanity', long_description=long_description, @@ -33,6 +33,7 @@ setup( "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", ], include_package_data=True, ) diff --git a/tests/test_main.py b/tests/test_main.py index 1c30eec..ad501f7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -43,7 +43,7 @@ class TestLsLl: assert info.Color.end in out # no color on branch name - __main__.main(['ll', '-n']) + __main__.main(['ll', '-C']) out, err = capfd.readouterr() assert err == '' assert 'gita' in out @@ -95,6 +95,16 @@ class TestLsLl: assert out == expected +@patch('subprocess.run') +@patch('gita.utils.get_repos', return_value={'repo1': '/a/', 'repo2': '/b/'}) +def test_freeze(_, mock_run, capfd): + __main__.main(['freeze']) + assert mock_run.call_count == 2 + out, err = capfd.readouterr() + assert err == '' + assert out == ',repo1,/a/\n,repo2,/b/\n' + + @patch('os.path.isfile', return_value=True) @patch('gita.common.get_config_fname', return_value='some path') @patch('gita.utils.get_repos', return_value={'repo1': '/a/', 'repo2': '/b/'}) @@ -149,6 +159,20 @@ def test_superman(mock_run, _, input): mock_run.assert_called_once_with(expected_cmds, cwd='path7') +@pytest.mark.parametrize('input', [ + 'diff --name-only --staged', + "commit -am 'lala kaka'", +]) +@patch('gita.utils.get_repos', return_value={'repo7': 'path7'}) +@patch('subprocess.run') +def test_shell(mock_run, _, input): + mock_run.reset_mock() + args = ['shell', 'repo7'] + shlex.split(input) + __main__.main(args) + expected_cmds = shlex.split(input) + mock_run.assert_called_once_with(expected_cmds, cwd='path7', check=True, stderr=-2, stdout=-1) + + class TestContext: @patch('gita.utils.get_context', return_value=None) @@ -273,6 +297,19 @@ class TestGroupCmd: mock_write.assert_called_once_with( {'xx': ['a', 'b', 'c'], 'yy': ['a', 'c', 'd']}, '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 testRmRepo(self, mock_write, *_): + args = argparse.Namespace() + args.from_group = ['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') + @patch('gita.utils.is_git', return_value=True) @patch('gita.common.get_config_fname', return_value=PATH_FNAME) |