diff options
Diffstat (limited to '')
-rw-r--r-- | .github/workflows/nos.yml | 9 | ||||
-rw-r--r-- | Makefile | 9 | ||||
-rw-r--r-- | README.md | 55 | ||||
-rw-r--r-- | auto-completion/bash/.gita-completion.bash (renamed from .gita-completion.bash) | 0 | ||||
-rw-r--r-- | auto-completion/fish/gita.fish | 17 | ||||
-rw-r--r-- | auto-completion/zsh/.gita-completion.zsh (renamed from .gita-completion.zsh) | 0 | ||||
-rw-r--r-- | auto-completion/zsh/_gita | 473 | ||||
-rw-r--r-- | gita/__init__.py | 19 | ||||
-rw-r--r-- | gita/__main__.py | 77 | ||||
-rw-r--r-- | gita/cmds.json | 11 | ||||
-rw-r--r-- | gita/info.py | 78 | ||||
-rw-r--r-- | gita/io.py | 33 | ||||
-rw-r--r-- | gita/utils.py | 14 | ||||
-rw-r--r-- | setup.py | 7 | ||||
-rw-r--r-- | tests/test_main.py | 16 |
15 files changed, 737 insertions, 81 deletions
diff --git a/.github/workflows/nos.yml b/.github/workflows/nos.yml index 52cf572..c82d49d 100644 --- a/.github/workflows/nos.yml +++ b/.github/workflows/nos.yml @@ -4,13 +4,14 @@ on: [push, pull_request] jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest + timeout-minutes: 10 strategy: matrix: - os: [ubuntu-20.04, macos-latest, windows-latest] - python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11"] + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -1,4 +1,4 @@ -.PHONY: dist test install clean twine +.PHONY: dist test install clean twine auto-completion install: pip3 install -e . @@ -10,3 +10,10 @@ twine: twine upload dist/* clean: git clean -fdx +auto-completion: + @ mkdir -p auto-completion/bash + @ mkdir -p auto-completion/zsh + @ mkdir -p auto-completion/fish + register-python-argcomplete gita -s bash > auto-completion/bash/.gita-completion.bash + register-python-argcomplete gita -s zsh > auto-completion/zsh/_gita + register-python-argcomplete gita -s fish > auto-completion/fish/gita.fish @@ -41,13 +41,13 @@ I also made a youtube video to demonstrate the common usages The branch color distinguishes 5 situations between local and remote branches: -color | meaning ----|--- - white| local has no remote - green| local is the same as remote - red| local has diverged from remote - purple| local is ahead of remote (good for push) - yellow| local is behind remote (good for merge) +| color | meaning | +| ------ | ---------------------------------------- | +| white | local has no remote | +| green | local is the same as remote | +| red | local has diverged from remote | +| purple | local is ahead of remote (good for push) | +| yellow | local is behind remote (good for merge) | 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), @@ -57,11 +57,12 @@ See the [customization section](#custom). The additional status symbols denote -symbol | meaning ----|--- - `+`| staged changes - `*`| unstaged changes - `?`| untracked files/folders +| symbol | meaning | +| ------ | ----------------------- | +| `+` | staged changes | +| `*` | unstaged changes | +| `?` | untracked files/folders | +| `$` | stashed contents | The bookkeeping sub-commands are @@ -158,11 +159,21 @@ See [this stackoverflow post](https://stackoverflow.com/questions/51680709/color ## Auto-completion -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 shell. +You can download the generated auto-completion file in the following locations for your specific shell. Alternatively, if you have installed `argcomplete` on your system, you can also directly run `eval "$(register-python-argcomplete gita -s SHELL)"` (e.g. `SHELL` as `bash`/`zsh`) in your dotfile. + +### Bash +Download [.gita-completion.bash](https://github.com/nosarthur/gita/blob/master/.gita-completion.bash) and source it in shell. + +### Zsh +There are 2 options : +- [.gita-completion.zsh](https://github.com/nosarthur/gita/blob/master/contrib.completion/zsh/.gita-completion.zsh). Use the help of gita command to display options. It uses the bash completion system for zsh. +Add `autoload -U +X bashcompinit && bashcompinit` in .zshrc and source the zsh file +- [_gita](https://github.com/nosarthur/gita/blob/master/contrib.completion/zsh/_gita_). +Completion more Zsh style. Copy it in a folder and add this folder path in `FPATH` variable. This completion file doesn't take account to command from cmds.json + +### Fish +Download [gita.fish](https://github.com/nosarthur/gita/tree/master/auto-completion/fish/gita.fish) and place it in `~/.config/fish/completions/` + ## <a name='superman'></a> Superman mode @@ -380,10 +391,10 @@ their results agree. ## Tips -effect | shell command ----|--- -enter `<repo>` directory|`` cd `gita ls <repo>` `` -delete repos in `<group>` | `gita group ll <group> \| xargs gita rm` +| effect | shell command | +| ------------------------- | ---------------------------------------- | +| enter `<repo>` directory | `` cd `gita ls <repo>` `` | +| delete repos in `<group>` | `gita group ll <group> \| xargs gita rm` | ## Contributing @@ -411,4 +422,4 @@ I haven't tried them but I heard good things about them. - [myrepos](https://myrepos.branchable.com/) - [repo](https://source.android.com/setup/develop/repo) - +- [mu-repo](https://github.com/fabioz/mu-repo) diff --git a/.gita-completion.bash b/auto-completion/bash/.gita-completion.bash index cad120c..cad120c 100644 --- a/.gita-completion.bash +++ b/auto-completion/bash/.gita-completion.bash diff --git a/auto-completion/fish/gita.fish b/auto-completion/fish/gita.fish new file mode 100644 index 0000000..91580db --- /dev/null +++ b/auto-completion/fish/gita.fish @@ -0,0 +1,17 @@ + +function __fish_gita_complete + set -x _ARGCOMPLETE 1 + set -x _ARGCOMPLETE_DFS \t + set -x _ARGCOMPLETE_IFS \n + set -x _ARGCOMPLETE_SUPPRESS_SPACE 1 + set -x _ARGCOMPLETE_SHELL fish + set -x COMP_LINE (commandline -p) + set -x COMP_POINT (string length (commandline -cp)) + set -x COMP_TYPE + if set -q _ARC_DEBUG + gita 8>&1 9>&2 1>&9 2>&1 + else + gita 8>&1 9>&2 1>/dev/null 2>&1 + end +end +complete --command gita -f -a '(__fish_gita_complete)' diff --git a/.gita-completion.zsh b/auto-completion/zsh/.gita-completion.zsh index d1fe952..d1fe952 100644 --- a/.gita-completion.zsh +++ b/auto-completion/zsh/.gita-completion.zsh diff --git a/auto-completion/zsh/_gita b/auto-completion/zsh/_gita new file mode 100644 index 0000000..b133972 --- /dev/null +++ b/auto-completion/zsh/_gita @@ -0,0 +1,473 @@ +#compdef gita + +__gita_get_repos() { + local -a repositories + repositories=($(_call_program commands gita ls)) + _describe -t repositories 'gita repositories' repositories +} + +__gita_get_context() { + local -a context + context=( + "auto" + "none" + ) + _describe -t context 'gita context' context + __gita_get_groups +} + +__gita_get_infos() { + local -a all_infos infos_in_used infos_unused + all_infos=($(_call_program commands gita info ll | cut -d ":" -f2)) + infos_in_used=($(echo ${all_infos[1]} | tr ',' ' ')) + infos_unused=($(echo ${all_infos[2]} | tr ',' ' ')) + _describe -t infos_used 'gita infos in used' infos_in_used + _describe -t infos_unused 'gita infos unused' infos_unused +} + +__gita_get_groups() { + local -a groups + + groups=($(_call_program commands gita group ls)) + _describe -t groups 'gita groups' groups +} + +__gita_commands() { + local -a commands + commands=( + 'add:Add repo(s)' + 'rm:remove repo(s)' + 'freeze:Print all repo information' + 'clone:Clone repos' + 'rename:Rename a repo' + 'flags:Git flags configuration' + 'color:Color configuration' + 'info:Information setting' + 'll:Display summary of all repos' + 'context:Set context' + 'ls:Show repo(s) or repo path' + 'group:Group repos' + 'super:Run any git command/alias' + 'shell:Run any shell command' + 'clear:Removes all groups and repositories' + ) + _describe -t commands 'gita sub-commands' commands +} + +# FUNCTION: _gita_add [[[ +_gita_add() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + '(-n --dry-run)'{-n,--dry-run}'[dry run]' \ + '(-g --group)'{-g=,--group=}'[add repo(s) to the specified group]:Gita groups:__gita_get_groups' \ + '(-s --skip-modules)'{-s,--skip-modules}'[skip submodule repo(s)]' \ + '(-r --recursive)'{-r,--recursive}'[recursively add repo(s) in the given path(s)]' \ + '(-a --auto-group)'{-a,--auto-group}'[recursively add repo(s) in the given path(s) and create hierarchical groups based on folder structure]' \ + '(-b --bare)'{-b,--bare}'[add bare repo(s)]' \ + "(-h --help -)*:Directories:_directories" + ret=0 +} +#]]] + +# FUNCTION: _gita_rm [[[ +_gita_rm() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + "(-h --help -)*:gita repositories:__gita_get_repos" && + ret=0 +} +#]]] + +# FUNCTION: _gita_freeze [[[ +_gita_freeze() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + '(-g --group)'{-g=,--group=}'[freeze repos in the specified group]:Gita groups:__gita_get_groups' && + ret=0 +} +#]]] + +# FUNCTION: _gita_clone [[[ +_gita_clone() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + '(-C --directory)'{-C=,--directory=}'[ Change to DIRECTORY before doing anything]:Directories:_directories' \ + '(-p --preserve-path)'{-p,--preserve-path}'[clone repo(s) in their original paths]' \ + '(-n --dry-run)'{-n,--dry-run}'[dry run]' \ + '(-g --group)'{-g=,--group=}'[If set, add repo to the specified group after cloning, otherwise add to gita without group]:Gita groups:__gita_get_groups' \ + '(-f --from-file)'{-f=,--from-file=}'[ If set, clone repos in a config file rendered from `gita freeze`]:File:_path_files' && + ret=0 +} +#]]] + +# FUNCTION: _gita_rename [[[ +_gita_rename() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + "(-h --help -):Gita repositories:__gita_get_repos" && + ret=0 +} +#]]] + +# FUNCTION: _gita_flags_commands[[[ +_gita_flags_commands() { + + local -a subcommands + subcommands=( + 'll:Display repos with custom flags' + 'set:Set flags for repo' + ) + _describe -t subcommands 'gita flag sub-commands' subcommands +} +#]]] + +# FUNCTION: _gita_flags_ll [[[ +_gita_flags_ll() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' && + ret=0 +} +#]]] + +# FUNCTION: _gita_flags_set [[[ +_gita_flags_set() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + "(-h --help -):Gita repositories:__gita_get_repos" && + ret=0 +} +#]]] + +# FUNCTION: _gita_flags[[[ +_gita_flags() { + local curcontext="$curcontext" state state_descr line expl + local tmp ret=1 + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' + + _arguments -C \ + "1: :->cmds" \ + "*::arg:->args" + case "$state" in + cmds) + _gita_flags_commands && return 0 + ;; + args) + local cmd="${line[1]}" + curcontext="${curcontext%:*}-${cmd}:${curcontext##*:}" + local completion_func="_gita_flags_${cmd//-/_}" + _call_function ret "${completion_func}" && return ret + _message "a completion function is not defined for command or alias: ${cmd}" + return 1 + ;; + esac +} +#]]] + +# FUNCTION: _gita_color_commands[[[ +_gita_color_commands() { + + local -a subcommands + subcommands=( + 'll:Display available colors and the current branch coloring in the ll sub-command' + 'set:Set color for local/remote situation' + 'reset:Reset color scheme' + ) + _describe -t subcommands 'gita color sub-commands' subcommands +} +#]]] + +# FUNCTION: _gita_color_ll [[[ +_gita_color_ll() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' && + ret=0 +} +#]]] + +# FUNCTION: _gita_color_set [[[ +_gita_color_set() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' && + ret=0 +} +#]]] + +# FUNCTION: _gita_color_reset [[[ +_gita_color_reset() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' && + ret=0 +} +#]]] + +# FUNCTION: _gita_color[[[ +_gita_color() { + local curcontext="$curcontext" state state_descr line expl + local tmp ret=1 + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' + + _arguments -C \ + "1: :->cmds" \ + "*::arg:->args" + case "$state" in + cmds) + _gita_color_commands && return 0 + ;; + args) + local cmd="${line[1]}" + curcontext="${curcontext%:*}-${cmd}:${curcontext##*:}" + local completion_func="_gita_color_${cmd//-/_}" + _call_function ret "${completion_func}" && return ret + _message "a completion function is not defined for command or alias: ${cmd}" + return 1 + ;; + esac +} +#]]] + +# FUNCTION: _gita_info_commands[[[ +_gita_info_commands() { + + local -a subcommands + subcommands=( + 'll:Show used and unused information items of the ll sub-command' + 'add:Enable information item' + 'rm:Disable information item' + ) + _describe -t subcommands 'gita info sub-commands' subcommands +} +#]]] + +# FUNCTION: _gita_info_ll [[[ +_gita_info_ll() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' && + ret=0 +} +#]]] + +# FUNCTION: _gita_info_add [[[ +_gita_info_add() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + "(-h --help -):Gita infos:__gita_get_infos" && + ret=0 +} +#]]] + +# FUNCTION: _gita_info_rm [[[ +_gita_info_rm() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + "(-h --help -):Gita infos:__gita_get_infos" && + ret=0 +} +#]]] + +# FUNCTION: _gita_info[[[ +_gita_info() { + local curcontext="$curcontext" state state_descr line expl + local tmp ret=1 + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' + + _arguments -C \ + "1: :->cmds" \ + "*::arg:->args" + case "$state" in + cmds) + _gita_info_commands && return 0 + ;; + args) + local cmd="${line[1]}" + curcontext="${curcontext%:*}-${cmd}:${curcontext##*:}" + local completion_func="_gita_info_${cmd//-/_}" + _call_function ret "${completion_func}" && return ret + _message "a completion function is not defined for command or alias: ${cmd}" + return 1 + ;; + esac +} +#]]] + +# FUNCTION: _gita_ll [[[ +_gita_ll() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + '(-C --no-colors)'{-C,--no-colors}'[Disable coloring on the branch names]' \ + '(-g)'-g'[Show repo summaries by group]' \ + "(-h --help -):Groups name:__gita_get_groups" && + ret=0 +} +#]]] + +# FUNCTION: _gita_context [[[ +_gita_context() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + "(-h --help -):Gita context:__gita_get_context" && + ret=0 +} +#]]] + +# FUNCTION: _gita_ls [[[ +_gita_ls() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + "(-h --help -):Gita repositories:__gita_get_repos" && + ret=0 +} +#]]] + +# FUNCTION: _gita_group_commands[[[ +_gita_group_commands() { + + local -a subcommands + subcommands=( + 'll:List all groups with repos.' + 'ls:List all group names' + 'add:Add repo(s) to a group' + 'rmrepo:remove repo(s) from a group' + 'rename:Change group name' + 'rm:Remove group(s)' + ) + _describe -t subcommands 'gita group sub-commands' subcommands +} +#]]] + +# FUNCTION: _gita_group_ll [[[ +_gita_group_ll() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + "(-h --help -):Groups name:__gita_get_groups" && + ret=0 +} +#]]] + +# FUNCTION: _gita_group_ls [[[ +_gita_group_ls() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' && + ret=0 +} +#]]] + +# FUNCTION: _gita_group_add [[[ +_gita_group_add() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + '(-n --name)'{-n=,--name=}'[group-name,]:Groups name:__gita_get_groups' \ + '(-p --path)'{-p=,--path=}'[group-path]:Group path:_directories' \ + "(-h --help -)*:Gita repositories:__gita_get_repos" && + ret=0 +} +#]]] + +# FUNCTION: _gita_group_rmrepo [[[ +_gita_group_rmrepo() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + '(-n --name)'{-n=,--name=}'[group-name,]:Groups name:__gita_get_groups' \ + "(-h --help -)*:Gita repositories:__gita_get_repos" && + ret=0 +} +#]]] + +# FUNCTION: _gita_group_rename [[[ +_gita_group_rename() { + _arguments -A \ + '(-h --help -)'{-h,--help}'[show this help message and exit]' && + ret=0 +} +#]]] + +# FUNCTION: _gita_group_rm [[[ +_gita_group_rm() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + "(-h --help -)*:Groups name:__gita_get_groups" && + ret=0 +} +#]]] + +# FUNCTION: _gita_group[[[ +_gita_group() { + local curcontext="$curcontext" state state_descr line expl + local tmp ret=1 + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' + + _arguments -C \ + "1: :->cmds" \ + "*::arg:->args" + case "$state" in + cmds) + _gita_group_commands && return 0 + ;; + args) + local cmd="${line[1]}" + curcontext="${curcontext%:*}-${cmd}:${curcontext##*:}" + local completion_func="_gita_group_${cmd//-/_}" + _call_function ret "${completion_func}" && return ret + _message "a completion function is not defined for command or alias: ${cmd}" + return 1 + ;; + esac +} +#]]] + +# FUNCTION: _gita_super [[[ +_gita_super() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + '(-q --quote-mode)'{-q,--quote-mode}'[use quote mode]' && + ret=0 +} +#]]] + +# FUNCTION: _gita_shell [[[ +_gita_shell() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + '(-q --quote-mode)'{-q,--quote-mode}'[use quote mode]' && + ret=0 +} +#]]] + +# FUNCTION: _gita_clear [[[ +_gita_clear() { + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' && + ret=0 +} +#]]] + +# FUNCTION: _gita [[[ +_gita() { + local curcontext="$curcontext" state state_descr line expl + local tmp ret=1 + _arguments -A \ + '(-h --help)'{-h,--help}'[show this help message and exit]' \ + '(-v --version)'{-v,--version}'[show program'\''s version number and exit]' + + _arguments -C \ + "1: :->cmds" \ + "*::arg:->args" + case "$state" in + cmds) + __gita_commands && return 0 + ;; + args) + local cmd="${line[1]}" + curcontext="${curcontext%:*}-${cmd}:${curcontext##*:}" + local completion_func="_gita_${cmd//-/_}" + _call_function ret "${completion_func}" && return ret + _message "a completion function is not defined for command or alias: ${cmd}" + return 1 + ;; + esac +} # ]]] + +_gita "$@" diff --git a/gita/__init__.py b/gita/__init__.py index 33c0f41..de4873d 100644 --- a/gita/__init__.py +++ b/gita/__init__.py @@ -1,3 +1,18 @@ -import pkg_resources +import sys -__version__ = pkg_resources.get_distribution("gita").version + +def get_version() -> str: + try: + import pkg_resources + except ImportError: + try: + from importlib.metadata import version + except ImportError: + print("cannot determine version", sys.version_info) + else: + return version("gita") + else: + return pkg_resources.get_distribution("gita").version + + +__version__ = get_version() diff --git a/gita/__main__.py b/gita/__main__.py index b2bc32b..6809f15 100644 --- a/gita/__main__.py +++ b/gita/__main__.py @@ -18,14 +18,15 @@ import os import sys import csv import argparse +import argcomplete import subprocess from functools import partial -import pkg_resources from itertools import chain from pathlib import Path import glob +from typing import Dict, Optional -from . import utils, info, common +from . import utils, info, common, io, get_version def _group_name(name: str, exclude_old_names=True) -> str: @@ -146,13 +147,28 @@ def f_info(args: argparse.Namespace): with open(csv_config, "w", newline="") as f: writer = csv.writer(f) writer.writerow(to_display) + elif cmd == "set-length": + csv_config = common.get_config_fname("layout.csv") + print(f"Settings are in {csv_config}") + defaults = { + "branch": 19, + "symbols": 5, + "branch_name": 27, + "commit_msg": 0, + "commit_time": 0, # 0 means no limit + "path": 30, + } + with open(csv_config, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=defaults) + writer.writeheader() + writer.writerow(defaults) def f_clone(args: argparse.Namespace): if args.dry_run: if args.from_file: - for url, repo_name, abs_path in utils.parse_clone_config(args.clonee): + for url, repo_name, abs_path in io.parse_clone_config(args.clonee): print(f"git clone {url} {abs_path}") else: print(f"git clone {args.clonee}") @@ -172,28 +188,35 @@ def f_clone(args: argparse.Namespace): f_add(args) return + # TODO: add repos to group too + repos, groups = io.parse_clone_config(args.clonee) if args.preserve_path: utils.exec_async_tasks( - utils.run_async(repo_name, path, ["git", "clone", url, abs_path]) - for url, repo_name, abs_path in utils.parse_clone_config(args.clonee) + utils.run_async(repo_name, path, ["git", "clone", r["url"], r["path"]]) + for repo_name, r in repos.items() ) else: utils.exec_async_tasks( - utils.run_async(repo_name, path, ["git", "clone", url]) - for url, repo_name, _ in utils.parse_clone_config(args.clonee) + utils.run_async(repo_name, path, ["git", "clone", r["url"]]) + for repo_name, r in repos.items() ) def f_freeze(args): - repos = utils.get_repos() + """ + print repo and group information for future cloning + """ ctx = utils.get_context() if args.group is None and ctx: args.group = ctx.stem + repos = utils.get_repos() + group_name = args.group group_repos = None - if args.group: # only display repos in this group - group_repos = utils.get_groups()[args.group]["repos"] + if group_name: # only display repos in this group + group_repos = utils.get_groups()[group_name]["repos"] repos = {k: repos[k] for k in group_repos if k in repos} seen = {""} + # print(repos) for name, prop in repos.items(): path = prop["path"] url = "" @@ -211,7 +234,16 @@ def f_freeze(args): url = parts[1] if url not in seen: seen.add(url) - print(f"{url},{name},{path}") + # TODO: add another field to distinguish regular repo or worktree or submodule + print(f"{url},{name},{path},") + # group information: these lines don't have URL + if group_name: + group_path = utils.get_groups()[group_name]["path"] + print(f",{group_name},{group_path},{'|'.join(group_repos)}") + else: # show all groups + for gname, g in utils.get_groups().items(): + group_repos = "|".join(g["repos"]) + print(f",{gname},{g['path']},{group_repos}") def f_ll(args: argparse.Namespace): @@ -435,8 +467,9 @@ def main(argv=None): title="sub-commands", help="additional help with sub-command -h" ) - version = pkg_resources.require("gita")[0].version - p.add_argument("-v", "--version", action="version", version=f"%(prog)s {version}") + p.add_argument( + "-v", "--version", action="version", version=f"%(prog)s {get_version()}" + ) # bookkeeping sub-commands p_add = subparsers.add_parser("add", description="add repo(s)", help="add repo(s)") @@ -598,11 +631,17 @@ def main(argv=None): info_cmds.add_parser("rm", description="Disable information item.").add_argument( "info_item", choices=info.ALL_INFO_ITEMS, help="information item to delete" ) + info_cmds.add_parser( + "set-length", + description="Set default column widths for information items. " + "The settings are in layout.csv", + ) ll_doc = f""" status symbols: +: staged changes *: unstaged changes - _: untracked files/folders + ?: untracked files/folders + $: stashed changes branch colors: {info.Color.white}white{info.Color.end}: local has no remote @@ -780,17 +819,18 @@ def main(argv=None): cmds = utils.get_cmds_from_files() for name, data in cmds.items(): help = data.get("help") + repo_help = help cmd = data["cmd"] if data.get("allow_all"): choices = utils.get_choices() nargs = "*" - help += " for all repos or" + repo_help += " for all repos or" else: choices = utils.get_repos().keys() | utils.get_groups().keys() nargs = "+" - help += " for the chosen repo(s) or group(s)" - sp = subparsers.add_parser(name, description=help) - sp.add_argument("repo", nargs=nargs, choices=choices, help=help) + repo_help += " for the chosen repo(s) or group(s)" + sp = subparsers.add_parser(name, description=help, help=help) + sp.add_argument("repo", nargs=nargs, choices=choices, help=repo_help) is_shell = bool(data.get("shell")) sp.add_argument( "-s", @@ -805,6 +845,7 @@ def main(argv=None): cmd = cmd.split() sp.set_defaults(func=f_git_cmd, cmd=cmd) + argcomplete.autocomplete(p) args = p.parse_args(argv) args.async_blacklist = { diff --git a/gita/cmds.json b/gita/cmds.json index eadda81..c1890c5 100644 --- a/gita/cmds.json +++ b/gita/cmds.json @@ -22,8 +22,13 @@ "cmd": "git log -1 HEAD", "help": "show log information of HEAD" }, -"log": - {"cmd": "git log", +"lo":{ + "cmd": "git log --oneline -7", + "allow_all": true, + "help": "show one-line log for the latest 7 commits" + }, +"log":{ + "cmd": "git log", "disable_async": true, "help": "show logs" }, @@ -77,10 +82,12 @@ }, "stat":{ "cmd": "git diff --stat", + "allow_all": true, "help": "show edit statistics" }, "st":{ "cmd": "git status", + "allow_all": true, "help": "show status" }, "tag":{ diff --git a/gita/info.py b/gita/info.py index 10d8bea..0a253a0 100644 --- a/gita/info.py +++ b/gita/info.py @@ -9,6 +9,40 @@ from typing import Tuple, List, Callable, Dict from . import common +class Truncate: + """ + Reads in user layout.csv file and uses the values there + to truncate the string passed in. If the file doesn't + exist or the requested field doesn't exist then don't + truncate + """ + + widths = {} + + def __init__(self): + csv_config = Path(common.get_config_fname("layout.csv")) + if csv_config.is_file(): + with open(csv_config, "r") as f: + reader = csv.DictReader(f) + self.widths = next(reader) + + # Ensure the Dict type is Dict[str, int] to reduce casting elsewhere + for e, width in self.widths.items(): + self.widths[e] = int(width) + + def truncate(self, field: str, message: str): + # 0 means no width limit applied + if not self.widths.get(field): + return message + + length = 3 if self.widths[field] < 3 else self.widths[field] + return ( + message[: length - 3] + "..." + if len(message) > length + else message.ljust(length) + ) + + class Color(Enum): """ Terminal color @@ -113,8 +147,8 @@ def get_info_items() -> List[str]: return display_items -def get_path(prop: Dict[str, str]) -> str: - return f'{Color.cyan}{prop["path"]}{Color.end}' +def get_path(prop: Dict[str, str], truncator: Truncate) -> str: + return f'{Color.cyan}{truncator.truncate("path", prop["path"])}{Color.end}' # TODO: do we need to add the flags here too? @@ -164,7 +198,21 @@ def has_untracked(flags: List[str], path) -> bool: return bool(result.stdout) -def get_commit_msg(prop: Dict[str, str]) -> str: +def has_stashed(flags: List[str], path) -> bool: + """ + Return True if stashed content exists + """ + # FIXME: this doesn't work for repos like worktrees, bare, etc + p = Path(path) / ".git" / "logs" / "refs" / "stash" + got = False + try: + got = p.is_file() + except Exception: + pass + return got + + +def get_commit_msg(prop: Dict[str, str], truncator: Truncate) -> str: """ Return the last commit message. """ @@ -177,10 +225,10 @@ def get_commit_msg(prop: Dict[str, str]) -> str: universal_newlines=True, cwd=prop["path"], ) - return result.stdout.strip() + return truncator.truncate("commit_msg", result.stdout.strip()) -def get_commit_time(prop: Dict[str, str]) -> str: +def get_commit_time(prop: Dict[str, str], truncator: Truncate) -> str: """ Return the last commit time in parenthesis. """ @@ -192,13 +240,14 @@ def get_commit_time(prop: Dict[str, str]) -> str: universal_newlines=True, cwd=prop["path"], ) - return f"({result.stdout.strip()})" + return truncator.truncate("commit_time", f"({result.stdout.strip()})") default_symbols = { "dirty": "*", "staged": "+", "untracked": "?", + "stashed": "$", "local_ahead": "↑", "remote_ahead": "↓", "diverged": "⇕", @@ -223,11 +272,11 @@ def get_symbols() -> Dict[str, str]: return default_symbols -def get_repo_status(prop: Dict[str, str], no_colors=False) -> str: - branch = get_head(prop["path"]) - dirty, staged, untracked, situ = _get_repo_status(prop) +def get_repo_status(prop: Dict[str, str], truncator: Truncate, no_colors=False) -> str: + branch = truncator.truncate("branch", get_head(prop["path"])) + dirty, staged, untracked, stashed, situ = _get_repo_status(prop) symbols = get_symbols() - info = f"{branch:<10} [{symbols[dirty]+symbols[staged]+symbols[untracked]+symbols[situ]}]" + info = f"{branch:<10} {truncator.truncate('symbols', f'[{symbols[dirty]}{symbols[staged]}{symbols[stashed]}{symbols[untracked]}{symbols[situ]}]')}" if no_colors: return f"{info:<18}" @@ -236,11 +285,11 @@ def get_repo_status(prop: Dict[str, str], no_colors=False) -> str: return f"{color}{info:<18}{Color.end}" -def get_repo_branch(prop: Dict[str, str]) -> str: - return get_head(prop["path"]) +def get_repo_branch(prop: Dict[str, str], truncator: Truncate) -> str: + return truncator.truncate("branch_name", get_head(prop["path"])) -def _get_repo_status(prop: Dict[str, str]) -> Tuple[str, str, str, str]: +def _get_repo_status(prop: Dict[str, str]) -> Tuple[str, str, str, str, str]: """ Return the status of one repo """ @@ -249,6 +298,7 @@ def _get_repo_status(prop: Dict[str, str]) -> Tuple[str, str, str, str]: dirty = "dirty" if run_quiet_diff(flags, [], path) else "" staged = "staged" if run_quiet_diff(flags, ["--cached"], path) else "" untracked = "untracked" if has_untracked(flags, path) else "" + stashed = "stashed" if has_stashed(flags, path) else "" diff_returncode = run_quiet_diff(flags, ["@{u}", "@{0}"], path) if diff_returncode == 128: @@ -263,7 +313,7 @@ def _get_repo_status(prop: Dict[str, str]) -> Tuple[str, str, str, str]: situ = "diverged" if diverged else "remote_ahead" else: # local is ahead of remote situ = "local_ahead" - return dirty, staged, untracked, situ + return dirty, staged, untracked, stashed, situ ALL_INFO_ITEMS = { diff --git a/gita/io.py b/gita/io.py new file mode 100644 index 0000000..78665b7 --- /dev/null +++ b/gita/io.py @@ -0,0 +1,33 @@ +import os +import csv +from typing import Tuple + + +def parse_clone_config(fname: str) -> Tuple: + """ + Return the repo information (url, name, path, type) and group information + (, name, path, repos) saved in `fname`. + """ + repos = {} + groups = {} + if os.path.isfile(fname) and os.stat(fname).st_size > 0: + with open(fname) as f: + rows = csv.DictReader( + f, ["url", "name", "path", "type", "flags"], restval="" + ) # it's actually a reader + for r in rows: + if r["url"]: + repos[r["name"]] = { + "path": r["path"], + "type": r["type"], + "flags": r["flags"].split(), + "url": r["url"], + } + else: + groups[r["name"]] = { + "path": r["path"], + "repos": [ + repo for repo in r["type"].split("|") if repo in repos + ], + } + return repos, groups diff --git a/gita/utils.py b/gita/utils.py index 6746d7f..f7717e0 100644 --- a/gita/utils.py +++ b/gita/utils.py @@ -7,7 +7,7 @@ import platform import subprocess from functools import lru_cache from pathlib import Path -from typing import List, Dict, Coroutine, Union, Iterator, Tuple +from typing import List, Dict, Coroutine, Union, Tuple from collections import Counter, defaultdict from concurrent.futures import ThreadPoolExecutor import multiprocessing @@ -367,15 +367,6 @@ def auto_group(repos: Dict[str, Dict[str, str]], paths: List[str]) -> Dict[str, return new_groups -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 @@ -430,13 +421,14 @@ def describe(repos: Dict[str, Dict[str, str]], no_colors: bool = False) -> str: Return the status of all repos """ if repos: + truncator = info.Truncate() name_width = len(max(repos, key=len)) + 1 funcs = info.get_info_funcs(no_colors=no_colors) num_threads = min(multiprocessing.cpu_count(), len(repos)) with ThreadPoolExecutor(max_workers=num_threads) as executor: for line in executor.map( - lambda name: f'{name:<{name_width}}{" ".join(f(repos[name]) for f in funcs)}', + lambda name: f'{name:<{name_width}}{" ".join(f(repos[name], truncator) for f in funcs)}', sorted(repos), ): yield line @@ -7,7 +7,7 @@ with open("README.md", encoding="utf-8") as f: setup( name="gita", packages=["gita"], - version="0.16.6", + version="0.16.7.2", license="MIT", description="Manage multiple git repos with sanity", long_description=long_description, @@ -19,6 +19,7 @@ setup( author_email="zhou.dong@gmail.com", entry_points={"console_scripts": ["gita = gita.__main__:main"]}, python_requires="~=3.6", + install_requires=["argcomplete"], classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -29,10 +30,12 @@ setup( "Topic :: Software Development :: Version Control :: Git", "Topic :: Terminals", "Topic :: Utilities", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ], include_package_data=True, ) diff --git a/tests/test_main.py b/tests/test_main.py index a877160..03b15d4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -143,7 +143,7 @@ class TestLsLl: @patch("gita.info.get_head", return_value="master") @patch( "gita.info._get_repo_status", - return_value=("dirty", "staged", "untracked", "diverged"), + return_value=("dirty", "staged", "untracked", "", "diverged"), ) @patch("gita.info.get_commit_msg", return_value="msg") @patch("gita.info.get_commit_time", return_value="") @@ -196,8 +196,11 @@ def test_clone_with_url(mock_run): @patch( - "gita.utils.parse_clone_config", - return_value=[["git@github.com:user/repo.git", "repo", "/a/repo"]], + "gita.io.parse_clone_config", + return_value=( + {"repo": {"url": "git@github.com:user/repo.git", "path": "/a/repo"}}, + {}, + ), ) @patch("gita.utils.run_async", new=async_mock()) @patch("subprocess.run") @@ -217,8 +220,11 @@ def test_clone_with_config_file(*_): @patch( - "gita.utils.parse_clone_config", - return_value=[["git@github.com:user/repo.git", "repo", "/a/repo"]], + "gita.io.parse_clone_config", + return_value=( + {"repo": {"url": "git@github.com:user/repo.git", "path": "/a/repo"}}, + {}, + ), ) @patch("gita.utils.run_async", new=async_mock()) @patch("subprocess.run") |