diff options
-rw-r--r-- | .gita-completion.bash | 18 | ||||
-rw-r--r-- | .github/dependabot.yml | 7 | ||||
-rw-r--r-- | .github/workflows/nos.yml | 20 | ||||
-rw-r--r-- | MANIFEST.in | 2 | ||||
-rw-r--r-- | Makefile | 6 | ||||
-rw-r--r-- | README.md | 356 | ||||
-rw-r--r-- | debian/changelog | 136 | ||||
-rw-r--r-- | debian/control | 6 | ||||
-rw-r--r-- | debian/copyright | 4 | ||||
-rwxr-xr-x | debian/rules | 12 | ||||
-rw-r--r-- | debian/watch | 2 | ||||
-rw-r--r-- | doc/README_CN.md | 78 | ||||
-rw-r--r-- | doc/screenshot.png | bin | 225737 -> 262997 bytes | |||
-rw-r--r-- | doc/video-outline.png | bin | 0 -> 178810 bytes | |||
-rw-r--r-- | gita/__init__.py | 2 | ||||
-rw-r--r-- | gita/__main__.py | 825 | ||||
-rw-r--r-- | gita/cmds.json | 90 | ||||
-rw-r--r-- | gita/cmds.yml | 65 | ||||
-rw-r--r-- | gita/common.py | 17 | ||||
-rw-r--r-- | gita/info.py | 293 | ||||
-rw-r--r-- | gita/utils.py | 440 | ||||
-rw-r--r-- | requirements.txt | 5 | ||||
-rw-r--r-- | setup.py | 30 | ||||
-rw-r--r-- | tests/clash_path_file | 4 | ||||
-rw-r--r-- | tests/conftest.py | 9 | ||||
-rw-r--r-- | tests/mock_group_file | 4 | ||||
-rw-r--r-- | tests/mock_path_file | 2 | ||||
-rw-r--r-- | tests/test_info.py | 7 | ||||
-rw-r--r-- | tests/test_main.py | 663 | ||||
-rw-r--r-- | tests/test_utils.py | 306 | ||||
-rw-r--r-- | tests/xx.context | 0 |
31 files changed, 2682 insertions, 727 deletions
diff --git a/.gita-completion.bash b/.gita-completion.bash index 5090bb7..cad120c 100644 --- a/.gita-completion.bash +++ b/.gita-completion.bash @@ -3,34 +3,42 @@ _gita_completions() { local cur commands repos cmd + local IFS=$'\n\t ' cur=${COMP_WORDS[COMP_CWORD]} cmd=${COMP_WORDS[1]} - # FIXME: this is somewhat slow - commands=`gita -h | sed '2q;d' |sed 's/[{}.,]/ /g'` - repos=`gita ls` # this doesn't work for two repos with the same basename #gita_path=${XDG_CONFIG_HOME:-$HOME/.config}/gita/repo_path #repos=`awk '{split($0, paths, ":")} END {for (i in paths) {n=split(paths[i],b, /\//); print b[n]}}' ${gita_path}` if [ $COMP_CWORD -eq 1 ]; then + # FIXME: this is somewhat slow + commands=`gita -h | sed '2q;d' |sed 's/[{}.,]/ /g'` COMPREPLY=($(compgen -W "${commands}" ${cur})) elif [ $COMP_CWORD -gt 1 ]; then case $cmd in add) COMPREPLY=($(compgen -d ${cur})) ;; - ll) + clone) + COMPREPLY=($(compgen -f ${cur})) + ;; + color | flags) + COMPREPLY=($(compgen -W "ll set" ${cur})) + ;; + ll | context) + groups=`gita group ls` + COMPREPLY=($(compgen -W "${groups}" ${cur})) return ;; *) + repos=`gita ls` COMPREPLY=($(compgen -W "${repos}" ${cur})) ;; esac fi - } complete -F _gita_completions gita diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..491deae --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/nos.yml b/.github/workflows/nos.yml index d75defa..52cf572 100644 --- a/.github/workflows/nos.yml +++ b/.github/workflows/nos.yml @@ -4,22 +4,26 @@ on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.6, 3.7, 3.8] + os: [ubuntu-20.04, macos-latest, windows-latest] + python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependences run: | - python -m pip install --upgrade pip + python -m pip install --upgrade pip wheel pip install -r requirements.txt - pip install . + pip install -e . - name: Pytest run: | - pytest tests --cov=./gita + pytest tests --cov=./gita --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/MANIFEST.in b/MANIFEST.in index e50bea9..18fb062 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -include gita/cmds.yml +include gita/cmds.json @@ -2,9 +2,9 @@ install: pip3 install -e . -test: clean - pytest tests --cov=./gita $(TEST_ARGS) -n=auto -dist: clean +test: + pytest tests --cov=./gita $(TEST_ARGS) -n=auto -vv +dist: python3 setup.py sdist twine: twine upload dist/* @@ -14,67 +14,122 @@ | | ____ | | | | | ___ | | | \_ ) | | | | | ( ) | | (___) |__) (___ | | | ) ( | -(_______)_______/ )_( |/ \| v0.10 +(_______)_______/ )_( |/ \| v0.16 ``` # Gita: a command-line tool to manage multiple git repos -This tool does two things +This tool has two main features - 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. -I also hate to change directories to execute git commands. +- (batch) delegate git commands/aliases and shell commands on repos from any working directory ![gita screenshot](https://github.com/nosarthur/gita/raw/master/doc/screenshot.png) -Here the branch color distinguishes 5 situations between local and remote branches: - -- 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) +In this screenshot, the `gita ll` command displays the status of all repos. +The `gita remote dotfiles` command translates to `git remote -v` +for the `dotfiles` repo, even though we are not in the repo. +The `gita fetch` command fetches from all repos and two of them have updates. +To see the pre-defined commands, run `gita -h` or take a look at +[cmds.json](https://github.com/nosarthur/gita/blob/master/gita/cmds.json). +To add your own 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). + +I also made a youtube video to demonstrate the common usages +[![Img alt text](https://github.com/nosarthur/gita/raw/master/doc/video-outline.png)](https://www.youtube.com/watch?v=ySWbwQcbhqI) + +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) 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` command. +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 -- `gita add <repo-path(s)>`: add repo(s) to `gita` -- `gita rm <repo-name(s)>`: remove repo(s) from `gita` (won't remove files from disk) -- `gita group`: show grouping of the repos -- `gita group <repo-name(s)>`: group repos -- `gita ungroup <repo-name(s)>`: remove grouping for repos +- `gita add <repo-path(s)> [-g <groupname>]`: add repo(s) to `gita`, optionally into an existing group +- `gita add -a <repo-parent-path(s)>`: add repo(s) in <repo-parent-path(s)> recursively + and automatically generate hierarchical groups. See the [customization section](#custom) for more details. +- `gita add -b <bare-repo-path(s)>`: add bare repo(s) to `gita`. See the [customization section](#custom) for more details on setting custom worktree. +- `gita add -r <repo-parent-path(s)>`: add repo(s) in <repo-parent-path(s)> recursively +- `gita clear`: remove all groups and repos +- `gita clone <URL>`: clone repo from `URL` at current working directory +- `gita clone <URL> -C <directory>`: change to `directory` and then clone repo +- `gita clone -f <config-file>`: clone repos in `config-file` (generated by `gita freeze`) to current directory. +- `gita clone -p -f <config-file>`: clone repos in `config-file` to prescribed paths. +- `gita context`: context sub-command + - `gita context`: show current context + - `gita context <group-name>`: 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 reset`: Reset to the default coloring scheme + - `gita color set <situation> <color>`: Use the specified color for the local-remote situation +- `gita flags`: flags sub-command + - `gita flags set <repo-name> <flags>`: 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. Use with + `gita clone`. +- `gita group`: group sub-command + - `gita group add <repo-name(s)> -n <group-name>`: add repo(s) to a new 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 + - `gita info rm <info-item>`: disable information item - `gita ll`: display the status of all repos - `gita ll <group-name>`: display the status of repos in a group +- `gita ll -g`: display the repo summaries by groups - `gita ls`: display the names of all repos - `gita ls <repo-name>`: display the absolute path of one repo - `gita rename <repo-name> <new-name>`: rename a repo -- `gita info`: display the used and unused information items +- `gita rm <repo-name(s)>`: remove repo(s) from `gita` (won't remove files on disk) - `gita -v`: display gita version -Repo paths are saved in `$XDG_CONFIG_HOME/gita/repo_path` (most likely `~/.config/gita/repo_path`). - -The delegating sub-commands are of two formats +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. + 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 -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.json](https://github.com/nosarthur/gita/blob/master/gita/cmds.json). +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 runs 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 will run asynchronously, -with the exception of `log`, `difftool` and `mergetool`, which require non-trivial user input. +Repo configuration global is saved in `$XDG_CONFIG_HOME/gita/repos.csv` +(most likely `~/.config/gita/repos.csv`) or if you prefered at project configuration add environment variable `GITA_PROJECT_HOME`. ## Installation @@ -84,22 +139,21 @@ 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> ``` In either case, calling `gita` in terminal may not work, -then you can put the following line in the `.bashrc` file. +then 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 @@ -108,11 +162,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 shell. -## Superman mode +## <a name='superman'></a> Superman mode -The superman mode delegates any git command/alias. +The superman mode delegates any `git` command or alias. Usage: ``` @@ -126,66 +180,192 @@ For example, - `gita super frontend-repo backend-repo commit -am 'implement a new feature'` executes `git commit -am 'implement a new feature'` for `frontend-repo` and `backend-repo` -## Customization +## <a name='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 repo2 mkdir docs` create a new directory `docs` in `repo1` and `repo2` +- `gita shell "git describe --abbrev=0 --tags | xargs git checkout"`: check out the latest tag for all repos + +## <a name='custom'></a> Customization + +### define repo group and context + +When the project contains several independent but related repos, +we can define a group and execute `gita` command on this group. +For example, + +``` +gita group add repo1 repo2 -n my-group +gita ll my-group +gita pull my-group +``` + +To save more typing, one can set a group as context, then any `gita` command +is scoped to the group + +``` +gita context my-group +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 + +``` +gita add -a src +``` +on the following folder structure +``` +src +├── project1 +│ ├── repo1 +│ └── repo2 +├── repo3 +├── project2 +│ ├── repo4 +│ └── repo5 +└── repo6 +``` +gives rise to 3 groups: +``` +src:repo1,repo2,repo3,repo4,repo5,repo6 +src-project1:repo1,repo2 +src-project2:repo4,repo5 +``` + +### add user-defined sub-command using json file -Custom delegating sub-commands can be defined in `$XDG_CONFIG_HOME/gita/cmds.yml` -(most likely `~/.config/gita/cmds.yml`). +Custom delegating sub-commands can be defined in `$XDG_CONFIG_HOME/gita/cmds.json` +(most likely `~/.config/gita/cmds.json`) And they shadow the default ones if name collisions exist. Default delegating sub-commands are defined in -[cmds.yml](https://github.com/nosarthur/gita/blob/master/gita/cmds.yml). +[cmds.json](https://github.com/nosarthur/gita/blob/master/gita/cmds.json). For example, `gita stat <repo-name(s)>` is registered as -```yaml -stat: - cmd: diff --stat - help: show edit statistics +```json +"stat":{ + "cmd": "git diff --stat", + "help": "show edit statistics" +} ``` -which executes `git diff --stat`. +which executes `git diff --stat` for the specified repo(s). + +To disable asynchronous execution, set `disable_async` to be `true`. +See the `difftool` example: -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. +```json +"difftool":{ + "cmd": "git difftool", + "disable_async": true, + "help": "show differences using a tool" +} +``` -If you want a custom command to behave like `gita fetch`, i.e., to apply -command to all repos if nothing is specified, -set the `allow_all` option to be `true`. +If you want a custom command to behave like `gita fetch`, i.e., to apply to all +repos when no repo is specified, set `allow_all` to be `true`. For example, the following snippet creates a new command `gita comaster [repo-name(s)]` with optional repo name input. -```yaml -comaster: - cmd: checkout master - allow_all: true - help: checkout the master branch +```json +"comaster":{ + "cmd": "checkout master", + "allow_all": true, + "help": "checkout the master branch" +} +``` + +Any command that runs in the [superman mode](#superman) mode or the +[shell mode](#shell) can be defined in this json format. +For example, the following command runs in shell mode and fetches only the +current branch from upstream. + +```json +"fetchcrt":{ + "cmd": "git rev-parse --abbrev-ref HEAD | xargs git fetch --prune upstream", + "allow_all": true, + "shell": true, + "help": "fetch current branch only" +} ``` -Another customization is the information items displayed by `gita ll`. -The used and unused information items are shown with `gita info` and one can -create `$XDG_CONFIG_HOME/gita/info.yml` to customize it. For example, the -default information items setting corresponds to +### 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.csv`. + +### 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.csv`. -```yaml -- branch -- commit_msg +For example, the default setting corresponds to + +```csv +branch,commit_msg,commit_time +``` + +Here `branch` includes both branch name and status. +The status symbols are similar to the ones used in [spaceship-prompt](https://spaceship-prompt.sh/sections/git/#Git-status-git_status). + +To customize these symbols, add a file in `$XDG_CONFIG_HOME/gita/symbols.csv`. +The default settings corresponds to + +```csv +dirty,staged,untracked,local_ahead,remote_ahead,diverged,in_sync,no_remote +*,+,?,↑,↓,⇕,,∅ ``` +Only the symbols to be overridden need to be defined. +You can search unicode symbols [here](https://www.compart.com/en/unicode/). -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. +### customize git command flags -```python -def get_delim(path: str) -> str: - return '|' +One can set custom flags to run `git` commands. For example, with -extra_info_items = {'delim': get_delim} +``` +gita flags set my-repo --git-dir=`gita ls dotfiles` --work-tree=$HOME ``` -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`. +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 dotfiles` translates to + +``` +git --git-dir=$HOME/somefolder --work-tree=$HOME status +``` + +running from the `dotfiles` directory. + +This feature was originally added to deal with +[bare repo dotfiles](https://www.atlassian.com/git/tutorials/dotfiles). ## Requirements @@ -193,11 +373,18 @@ 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. +## Tips + +effect | shell command +---|--- +enter `<repo>` directory|`` cd `gita ls <repo>` `` +delete repos in `<group>` | `gita group ll <group> \| xargs gita rm` + ## Contributing To contribute, you can @@ -206,27 +393,18 @@ To contribute, you can - request/implement features - star/recommend this project +Read [this article](https://www.dataschool.io/how-to-contribute-on-github/) if you have never contribute code to open source project before. + Chat room is available on [![Join the chat at https://gitter.im/nosarthur/gita](https://badges.gitter.im/nosarthur/gita.svg)](https://gitter.im/nosarthur/gita?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -To run tests locally, simply `pytest`. +To run tests locally, simply `pytest` in the source code folder. +Note that context should be set as `none`. More implementation details are in [design.md](https://github.com/nosarthur/gita/blob/master/doc/design.md). A step-by-step guide to reproduce this project is [here](https://nosarthur.github.io/side%20project/2019/05/27/gita-breakdown.html). 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/debian/changelog b/debian/changelog index ae2a5d4..777598d 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,139 @@ +gita (0.16.6.1-2) sid; urgency=medium + + * Uploading to sid. + * Manually removing some files that pybuild doesn't clean up during + build (Closes: #1044761). + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 14 Aug 2023 11:18:16 +0200 + +gita (0.16.6.1-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 0.16.6.1. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Tue, 18 Jul 2023 12:49:30 +0200 + +gita (0.16.5-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 0.16.5. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Sun, 16 Jul 2023 07:43:10 +0200 + +gita (0.16.4-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 0.16.4. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Thu, 13 Jul 2023 07:38:04 +0200 + +gita (0.16.3-2) sid; urgency=medium + + * Uploading to sid. + * Uploading without changes after bookworm release. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Sun, 11 Jun 2023 14:12:37 +0200 + +gita (0.16.3-1) experimental; urgency=medium + + * Uploading to experimental. + * Merging upstream version 0.16.3. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Wed, 03 May 2023 11:16:43 +0200 + +gita (0.16.2-2) sid; urgency=medium + + * Uploading to sid. + * Updating to standards version 4.6.2. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 30 Jan 2023 17:12:01 +0100 + +gita (0.16.2-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 0.16.2. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Sun, 30 Jan 2022 11:51:16 +0100 + +gita (0.16.1-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 0.16.1. + * Updating copyright for 2022. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Sun, 09 Jan 2022 08:44:32 +0100 + +gita (0.15.9-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 0.15.9. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Tue, 02 Nov 2021 18:50:28 +0100 + +gita (0.15.8-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 0.15.8. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Sat, 18 Sep 2021 19:26:59 +0200 + +gita (0.15.7-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 0.15.7. + * Updating to standards version 4.6.0. + * Removing generated help2man manpge, the output is not good enough + (Closes: #992197). + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 06 Sep 2021 06:06:39 +0200 + +gita (0.15.2-2) sid; urgency=medium + + * Uploading to sid. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Sun, 15 Aug 2021 17:35:15 +0200 + +gita (0.15.2-1) experimental; urgency=medium + + * Uploading to experimental. + * Merging upstream version 0.15.2. + * Updating watch file. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 02 Aug 2021 06:36:39 +0200 + +gita (0.15.1-1) experimental; urgency=medium + + * Uploading to experimental. + * Merging upstream version 0.15.1. + * Updating python debhelper sequence. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Sat, 17 Jul 2021 09:26:46 +0200 + +gita (0.12.9-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 0.12.9. + * Updating to standards version 4.5.1. + * Updating years in copyright file for 2021. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 25 Jan 2021 13:58:15 +0100 + +gita (0.11.9-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 0.11.9-1. + * Adding missing build-depends to python3-yaml, + thanks to Chris Lamb <lamby@debian.org> (Closes: #972493). + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 02 Nov 2020 16:59:19 +0100 + +gita (0.10.10-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 0.10.10. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Wed, 02 Sep 2020 20:11:17 +0200 + gita (0.10.9-1) sid; urgency=medium * Initial upload to sid (Closes: #968651). diff --git a/debian/control b/debian/control index 152f1fa..bc208c4 100644 --- a/debian/control +++ b/debian/control @@ -4,12 +4,12 @@ Priority: optional Maintainer: Daniel Baumann <daniel.baumann@progress-linux.org> Build-Depends: debhelper-compat (= 13), - dh-python, - help2man, + dh-sequence-python3, python3-all, python3-setuptools, + python3-yaml, Rules-Requires-Root: no -Standards-Version: 4.5.0 +Standards-Version: 4.7.0 Homepage: https://github.com/nosarthur/gita Vcs-Browser: https://git.progress-linux.org/users/daniel.baumann/debian/packages/gita Vcs-Git: https://git.progress-linux.org/users/daniel.baumann/debian/packages/gita diff --git a/debian/copyright b/debian/copyright index 38b39a8..062db5c 100644 --- a/debian/copyright +++ b/debian/copyright @@ -4,11 +4,11 @@ Upstream-Contact: Dong Zhou <zhou.dong@gmail.com> Source: https://github.com/nosarthur/gita/releases Files: * -Copyright: 2018-2020 Dong Zhou <zhou.dong@gmail.com> +Copyright: 2018-2022 Dong Zhou <zhou.dong@gmail.com> License: MIT Files: debian/* -Copyright: 2020 Daniel Baumann <daniel.baumann@progress-linux.org> +Copyright: 2020-2024 Daniel Baumann <daniel.baumann@progress-linux.org> License: MIT License: MIT diff --git a/debian/rules b/debian/rules index f959ab3..0d8f6ca 100755 --- a/debian/rules +++ b/debian/rules @@ -1,18 +1,16 @@ #!/usr/bin/make -f %: - dh ${@} --buildsystem=pybuild --with python3 + dh ${@} --buildsystem=pybuild + +execute_after_dh_auto_clean: + # help pybuild + rm -rf *.egg-info execute_after_dh_auto_install: # bash-completion mkdir -p debian/gita/usr/share/bash-completion/completions cp .gita-completion.bash debian/gita/usr/share/bash-completion/completions/gita - # manpage - mkdir -p debian/gita/usr/share/man/man1 - PYTHONPATH=debian/gita/usr/lib/$$(py3versions -d)/dist-packages \ - help2man --no-discard-stderr --name 'Manage many git repos' \ - debian/gita/usr/bin/gita > debian/gita/usr/share/man/man1/gita.1 - override_dh_auto_test: # disabled diff --git a/debian/watch b/debian/watch index 61010be..34ba308 100644 --- a/debian/watch +++ b/debian/watch @@ -1,3 +1,3 @@ version=4 opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/gita-$1\.tar\.gz/ \ -https://github.com/nosarthur/gita/releases .*/v?(\d\S+)\.tar\.gz +https://github.com/nosarthur/gita/tags .*/v?(\d\S+)\.tar\.gz diff --git a/doc/README_CN.md b/doc/README_CN.md index 433aaec..2088e78 100644 --- a/doc/README_CN.md +++ b/doc/README_CN.md @@ -14,50 +14,85 @@ | | ____ | | | | | ___ | | | \_ ) | | | | | ( ) | | (___) |__) (___ | | | ) ( | -(_______)_______/ )_( |/ \| v0.10 +(_______)_______/ )_( |/ \| v0.16 ``` # Gita:一个管理多个 git 库的命令行工具 -这个工具有两个作用: +这个工具有两个功能: - 并排显示多个库的状态信息,比如分支名,编辑状态,提交信息等 - 在任何目录下(批处理)代理执行 git 指令 ![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 <repo-path(s)>`: 添加库 -- `gita rm <repo-name(s)>`: 移除库(不会删除文件) -- `gita group`: 显示库的组群 -- `gita group` <repo-name(s)>: 将库分组 +- `gita add -a <repo-parent-path(s)>`: 递归添加路径下的所有库并自动产生层级分组,见 + [customization section](#custom) +- `gita add -b <bare-repo-path(s)>`: 添加bare库 + [customization section](#custom) +- `gita add -r <repo-parent-path(s)>`: 递归添加路径下的所有库 +- `gita clear`: +- `gita clone <URL>`: +- `gita clone <URL> -C <directory>`: +- `gita clone -f <config-file>`: 克隆`<config-file>` (由`gita freeze`生成)里的库 +- `gita clone -p -f <config-file>`: 克隆`<config-file>`里的库并放到指定路径 +- `gita context`: 情境命令 + - `gita context`: 显示当前的情境 + - `gita context none`: 去除情境 + - `gita context <group-name>`: 把情境设置成`group-name`, 之后所有的操作只作用到这个组里的库 +- `gita color`: + - `gita color [ll]`:显示颜色设置 + - `gita color set <situation> <color>`:给本地/远程关系设置颜色 +- `gita flags`: + - `gita flags set <repo-name> <flags>`:给库设置flags + - `gita flags [ll]`:显示已有的flags +- `gita freeze`:显示URL, 路径之类的库信息(配合`gita clone`使用) +- `gita group`: 组群命令 + - `gita group add <repo-name(s)>`: 把库加入新的或者已经存在的组 + - `gita group [ll]`: 显示已有的组和它们的库 + - `gita group ls`: 显示已有的组名 + - `gita group rename <group-name> <new-name>`: 改组名 + - `gita group rm group(s): 删除组 + - `gita group rmrepo -n <group-name>:删除组里的库 +- `gita info`: 显示已用的和未用的信息项 + - `gita info [ll]` + - `gita info add <info-item>` + - `gita info rm <info-item>` - `gita ll`: 显示所有库的状态信息 - `gita ll <group-name>`: 显示一个组群中库的状态信息 - `gita ls`: 显示所有库的名字 - `gita ls <repo-name>`: 显示一个库的绝对路径 - `gita rename <repo-name> <new-name>`: 重命名一个库 -- `gita info`: 显示已用的和未用的信息项 +- `gita rm <repo-name(s)>`: 移除库(不会删除文件) - `gita -v`: 显示版本号 -库的路径存在`$XDG_CONFIG_HOME/gita/repo_path` (多半是`~/.config/gita/repo_path`)。 +库的路径存在`$XDG_CONFIG_HOME/gita/repos.csv` (多半是`~/.config/gita/repos.csv`)。 代理执行的子命令有两种格式: @@ -72,7 +107,7 @@ ## 安装指南 -正常人类按装: +正常人类安装: ``` pip3 install -U gita @@ -142,11 +177,10 @@ comaster: help: checkout the master branch ``` 另一个自定义功能是针对`gita ll`展示的信息项。 -`gita info`可以展示所有用到的和没用到的信息项,并且可以通过修改`$XDG_CONFIG_HOME/gita/info.yml`支持自定义。举个栗子,默认的信息项显示配置相当于是: +`gita info`可以展示所有用到的和没用到的信息项,并且可以通过修改`$XDG_CONFIG_HOME/gita/info.csv`支持自定义。举个栗子,默认的信息项显示配置相当于是: -```yaml -- branch -- commit_msg +```csv +branch,commit_msg,commit_time ``` 为了创建自己的信息项,命名一个目录为`extra_info_items`。 在`$XDG_CONFIG_HOME/gita/extra_repo_info.py`中,要把信息项的名字作为字符串映射到方法中,该方法将库的路径作为输入参数。举个栗子: diff --git a/doc/screenshot.png b/doc/screenshot.png Binary files differindex d5941d7..df967e9 100644 --- a/doc/screenshot.png +++ b/doc/screenshot.png diff --git a/doc/video-outline.png b/doc/video-outline.png Binary files differnew file mode 100644 index 0000000..a54ea23 --- /dev/null +++ b/doc/video-outline.png diff --git a/gita/__init__.py b/gita/__init__.py index eeb79a3..33c0f41 100644 --- a/gita/__init__.py +++ b/gita/__init__.py @@ -1,3 +1,3 @@ import pkg_resources -__version__ = pkg_resources.get_distribution('gita').version +__version__ = pkg_resources.get_distribution("gita").version diff --git a/gita/__main__.py b/gita/__main__.py index 6e47f8e..b2bc32b 100644 --- a/gita/__main__.py +++ b/gita/__main__.py @@ -1,4 +1,4 @@ -''' +""" Gita manages multiple git repos. It has two functionalities 1. display the status of multiple repos side by side @@ -12,19 +12,82 @@ Examples: For bash auto completion, download and source https://github.com/nosarthur/gita/blob/master/.gita-completion.bash -''' +""" import os +import sys +import csv import argparse import subprocess +from functools import partial import pkg_resources +from itertools import chain +from pathlib import Path +import glob -from . import utils, info +from . import utils, info, common + + +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 + """ + if name: + return os.path.abspath(name) + return "" def f_add(args: argparse.Namespace): repos = utils.get_repos() - utils.add_repos(repos, args.paths) + paths = args.paths + dry_run = args.dry_run + groups = utils.get_groups() + if args.recursive or args.auto_group: + 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 + ) + ) + new_repos = utils.add_repos( + repos, + paths, + include_bare=args.bare, + exclude_submodule=args.skip_submodule, + dry_run=dry_run, + ) + if dry_run: + return + 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).") + utils.write_to_groups_file(new_groups, "a+") + if new_repos and args.group: + gname = args.group + gname_repos = set(groups[gname]["repos"]) + gname_repos.update(new_repos) + groups[gname]["repos"] = sorted(gname_repos) + print(f"Added {len(new_repos)} repos to the {gname} group") + utils.write_to_groups_file(groups, "w") def f_rename(args: argparse.Namespace): @@ -32,12 +95,123 @@ def f_rename(args: argparse.Namespace): utils.rename_repo(repos, args.repo[0], args.new_name) -def f_info(_): - all_items, to_display = info.get_info_items() - print('In use:', ','.join(to_display)) - unused = set(all_items) - set(to_display) - if unused: - print('Unused:', ' '.join(unused)) +def f_flags(args: argparse.Namespace): + cmd = args.flags_cmd or "ll" + repos = utils.get_repos() + if cmd == "ll": + for r, prop in repos.items(): + if prop["flags"]: + print(f"{r}: {prop['flags']}") + elif cmd == "set": + # when in memory, flags are List[str], when on disk, they are space + # delimited str + repos[args.repo]["flags"] = args.flags + utils.write_to_repo_file(repos, "w") + + +def f_color(args: argparse.Namespace): + cmd = args.color_cmd or "ll" + if cmd == "ll": # pragma: no cover + info.show_colors() + elif cmd == "set": + colors = info.get_color_encoding() + colors[args.situation] = args.color + csv_config = common.get_config_fname("color.csv") + with open(csv_config, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=colors) + writer.writeheader() + writer.writerow(colors) + elif cmd == "reset": + Path(common.get_config_fname("color.csv")).unlink(missing_ok=True) + + +def f_info(args: argparse.Namespace): + to_display = info.get_info_items() + cmd = args.info_cmd or "ll" + if cmd == "ll": + print("In use:", ",".join(to_display)) + unused = sorted(list(set(info.ALL_INFO_ITEMS) - set(to_display))) + if unused: + print("Unused:", ",".join(unused)) + return + if cmd == "add" and args.info_item not in to_display: + to_display.append(args.info_item) + csv_config = common.get_config_fname("info.csv") + with open(csv_config, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(to_display) + elif cmd == "rm" and args.info_item in to_display: + to_display.remove(args.info_item) + csv_config = common.get_config_fname("info.csv") + with open(csv_config, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(to_display) + + +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): + print(f"git clone {url} {abs_path}") + else: + print(f"git clone {args.clonee}") + return + + if args.directory: + path = args.directory + else: + path = Path.cwd() + + if not args.from_file: + subprocess.run(["git", "clone", args.clonee], cwd=path) + # add the cloned repo to gita; group is also supported + cloned_path = os.path.join(path, args.clonee.split("/")[-1].split(".")[0]) + args.paths = [cloned_path] + args.recursive = args.auto_group = args.bare = args.skip_submodule = False + f_add(args) + return + + 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) + ) + 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) + ) + + +def f_freeze(args): + repos = utils.get_repos() + 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]["repos"] + repos = {k: repos[k] for k in group_repos if k in repos} + seen = {""} + for name, prop in repos.items(): + path = prop["path"] + url = "" + # FIXME: capture_output is new in 3.7. Maybe drop support for 3.6 + cp = subprocess.run( + ["git", "remote", "-v"], + cwd=path, + universal_newlines=True, + capture_output=True, + ) + lines = cp.stdout.split("\n") + if cp.returncode == 0 and len(lines) > 0: + parts = lines[0].split() + if len(parts) > 1: + url = parts[1] + if url not in seen: + seen.add(url) + print(f"{url},{name},{path}") def f_ll(args: argparse.Namespace): @@ -45,62 +219,128 @@ def f_ll(args: argparse.Namespace): Display details of all repos """ repos = utils.get_repos() + ctx = utils.get_context() + if args.group is None and ctx: + args.group = ctx.stem + 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): - 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, prop in utils.get_groups().items(): + print(f"{g}:") + g_repos = {k: repos[k] for k in prop["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): repos = utils.get_repos() if args.repo: # one repo, show its path - print(repos[args.repo]) + print(repos[args.repo]["path"]) else: # show names of all repos - print(' '.join(repos)) + print(" ".join(repos)) def f_group(args: argparse.Namespace): groups = utils.get_groups() - if args.to_group: - gname = input('group name? ') + cmd = args.group_cmd or "ll" + if cmd == "ll": + if "to_show" in args and args.to_show: + gname = args.to_show + print(" ".join(groups[gname]["repos"])) + else: + 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 + 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 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: + 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) - utils.write_to_groups_file(groups, 'w') + 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+') - else: - for group, repos in groups.items(): - print(f"{group}: {' '.join(repos)}") - - -def f_ungroup(args: argparse.Namespace): - groups = utils.get_groups() - to_ungroup = set(args.to_ungroup) - to_del = [] - for name, repos in groups.items(): - remaining = set(repos) - to_ungroup - if remaining: - groups[name] = list(sorted(remaining)) + 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: + 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: # display current context + if ctx: + group = ctx.stem + print(f"{group}: {' '.join(utils.get_groups()[group]['repos'])}") + elif (Path(common.get_config_dir()) / "auto.context").exists(): + print("auto: none detected!") else: - to_del.append(name) - for name in to_del: - del groups[name] - utils.write_to_groups_file(groups, 'w') + print("Context is not set") + else: # set context + utils.replace_context(ctx, choice) def f_rm(args: argparse.Namespace): """ Unregister repo(s) from gita """ - path_file = utils.get_config_fname('repo_path') + path_file = common.get_config_fname("repos.csv") if os.path.isfile(path_file): repos = utils.get_repos() + group_updated = False + groups = utils.get_groups() for repo in args.repo: del repos[repo] - utils.write_to_repo_file(repos, 'w') + up = utils.delete_repo_from_groups(repo, groups) + group_updated = group_updated or up + if group_updated: + utils.write_to_groups_file(groups, "w") + + utils.write_to_repo_file(repos, "w") def f_git_cmd(args: argparse.Namespace): @@ -108,32 +348,62 @@ def f_git_cmd(args: argparse.Namespace): Delegate git command/alias defined in `args.cmd`. Asynchronous execution is disabled for commands in the `args.async_blacklist`. """ - repos = utils.get_repos() - groups = utils.get_groups() - 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 = ['git'] + args.cmd - if len(repos) == 1 or cmds[1] in args.async_blacklist: - for path in repos.values(): + if "_parsed_repos" in args: + repos = args._parsed_repos + else: + repos, _ = utils.parse_repos_and_rest(args.repo) + + per_repo_cmds = [] + for prop in repos.values(): + cmds = args.cmd.copy() + if cmds[0] == "git" and prop["flags"]: + cmds[1:1] = prop["flags"] + per_repo_cmds.append(cmds) + + # This async blacklist mechanism is broken if the git command name does + # not match with the gita command name. + if len(repos) == 1 or args.cmd[1] in args.async_blacklist: + for prop, cmds in zip(repos.values(), per_repo_cmds): + path = prop["path"] print(path) - subprocess.run(cmds, cwd=path) + subprocess.run(cmds, cwd=path, shell=args.shell) else: # run concurrent subprocesses # Async execution cannot deal with multiple repos' user name/password. # Here we shut off any user input in the async execution, and re-run # the failed ones synchronously. errors = utils.exec_async_tasks( - utils.run_async(repo_name, path, cmds) for repo_name, path in repos.items()) + utils.run_async(repo_name, prop["path"], cmds) + for cmds, (repo_name, prop) in zip(per_repo_cmds, repos.items()) + ) for path in errors: if path: print(path) - subprocess.run(cmds, cwd=path) + # FIXME: This is broken, flags are missing. But probably few + # people will use `gita flags` + subprocess.run(args.cmd, cwd=path) + + +def f_shell(args): + """ + Delegate shell command defined in `args.man`, which may or may not + contain repo names. + """ + repos, cmds = utils.parse_repos_and_rest(args.man, args.quote_mode) + if not cmds: + print("Missing commands") + sys.exit(2) + + cmds = " ".join(cmds) # join the shell command into a single string + for name, prop in repos.items(): + # TODO: pull this out as a function + got = subprocess.run( + cmds, + cwd=prop["path"], + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + print(utils.format_output(got.stdout.decode(), name)) def f_super(args): @@ -141,59 +411,195 @@ def f_super(args): Delegate git command/alias defined in `args.man`, which may or may not contain repo names. """ - names = [] - repos = utils.get_repos() - groups = utils.get_groups() - for i, word in enumerate(args.man): - if word in repos or word in groups: - names.append(word) - else: - break - args.cmd = args.man[i:] - args.repo = names + repos, cmds = utils.parse_repos_and_rest(args.man, args.quote_mode) + if not cmds: + print("Missing commands") + sys.exit(2) + + args.cmd = ["git"] + cmds + args._parsed_repos = repos + args.shell = False f_git_cmd(args) +def f_clear(_): + utils.write_to_groups_file({}, "w") + utils.write_to_repo_file({}, "w") + + def main(argv=None): - p = argparse.ArgumentParser(prog='gita', - formatter_class=argparse.RawTextHelpFormatter, - description=__doc__) - subparsers = p.add_subparsers(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 = argparse.ArgumentParser( + prog="gita", formatter_class=argparse.RawTextHelpFormatter, description=__doc__ + ) + subparsers = p.add_subparsers( + 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}") # 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="+", type=_path_name, help="repo(s) to add") + p_add.add_argument("-n", "--dry-run", action="store_true", help="dry run") + p_add.add_argument( + "-g", + "--group", + choices=utils.get_groups(), + help="add repo(s) to the specified group", + ) + p_add.add_argument( + "-s", "--skip-submodule", action="store_true", help="skip submodule repo(s)" + ) + 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( + "-a", + "--auto-group", + action="store_true", + help="recursively add repo(s) in the given path(s) " + "and create hierarchical groups based on folder structure.", + ) + xgroup.add_argument("-b", "--bare", action="store_true", help="add bare repo(s)") p_add.set_defaults(func=f_add) - p_rm = subparsers.add_parser('rm', help='remove repo(s)') - p_rm.add_argument('repo', - nargs='+', - choices=utils.get_repos(), - help="remove the chosen 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_rename.add_argument( - 'repo', - nargs=1, - choices=utils.get_repos(), - help="rename the chosen repo") + p_freeze = subparsers.add_parser( + "freeze", + description="print all repo information", + help="print all repo information", + ) + p_freeze.add_argument( + "-g", + "--group", + choices=utils.get_groups(), + help="freeze repos in the specified group", + ) + p_freeze.set_defaults(func=f_freeze) + + p_clone = subparsers.add_parser( + "clone", description="clone repos", help="clone repos" + ) + p_clone.add_argument( + "clonee", + help="A URL or a config file.", + ) + p_clone.add_argument( + "-C", + "--directory", + help="Change to DIRECTORY before doing anything.", + ) + p_clone.add_argument( + "-p", + "--preserve-path", + dest="preserve_path", + action="store_true", + help="clone repo(s) in their original paths", + ) + p_clone.add_argument( + "-n", + "--dry-run", + action="store_true", + help="If set, show command without execution", + ) + xgroup = p_clone.add_mutually_exclusive_group() + xgroup.add_argument( + "-g", + "--group", + choices=utils.get_groups(), + help="If set, add repo to the specified group after cloning, otherwise add to gita without group.", + ) + xgroup.add_argument( + "-f", + "--from-file", + action="store_true", + help="If set, clone repos in a config file rendered from `gita freeze`", + ) + p_clone.set_defaults(func=f_clone) + + p_rename = subparsers.add_parser( + "rename", description="rename a repo", help="rename a repo" + ) p_rename.add_argument( - 'new_name', - help="new name") + "repo", nargs=1, choices=utils.get_repos(), help="rename the chosen repo" + ) + p_rename.add_argument("new_name", help="new name") p_rename.set_defaults(func=f_rename) - p_info = subparsers.add_parser('info', help='show information items of the ll sub-command') + p_flags = subparsers.add_parser( + "flags", + description="Set custom git flags for repo.", + help="git flags configuration", + ) + p_flags.set_defaults(func=f_flags) + flags_cmds = p_flags.add_subparsers( + dest="flags_cmd", help="additional help with sub-command -h" + ) + flags_cmds.add_parser("ll", description="display repos with custom flags") + pf_set = flags_cmds.add_parser("set", description="Set flags for repo.") + pf_set.add_argument("repo", choices=utils.get_repos(), help="repo name") + pf_set.add_argument( + "flags", nargs=argparse.REMAINDER, help="custom flags, use quotes" + ) + + p_color = subparsers.add_parser( + "color", + description="display and modify branch coloring of the ll sub-command.", + help="color configuration", + ) + p_color.set_defaults(func=f_color) + color_cmds = p_color.add_subparsers( + dest="color_cmd", help="additional help with sub-command -h" + ) + color_cmds.add_parser( + "ll", + description="display available colors and the current branch coloring in the ll sub-command", + ) + color_cmds.add_parser("reset", description="reset color scheme.") + pc_set = color_cmds.add_parser( + "set", description="Set color for local/remote situation." + ) + pc_set.add_argument( + "situation", + choices=info.get_color_encoding(), + help="5 possible local/remote situations", + ) + pc_set.add_argument( + "color", choices=[c.name for c in info.Color], help="available colors" + ) + + p_info = subparsers.add_parser( + "info", + description="list, add, or remove information items of the ll sub-command.", + help="information setting", + ) p_info.set_defaults(func=f_info) - - ll_doc = f''' status symbols: + info_cmds = p_info.add_subparsers( + dest="info_cmd", help="additional help with sub-command -h" + ) + info_cmds.add_parser( + "ll", description="show used and unused information items of the ll sub-command" + ) + info_cmds.add_parser("add", description="Enable information item.").add_argument( + "info_item", choices=info.ALL_INFO_ITEMS, help="information item to add" + ) + info_cmds.add_parser("rm", description="Disable information item.").add_argument( + "info_item", choices=info.ALL_INFO_ITEMS, help="information item to delete" + ) + + ll_doc = f""" status symbols: +: staged changes *: unstaged changes _: untracked files/folders @@ -203,86 +609,213 @@ def main(argv=None): {info.Color.green}green{info.Color.end}: local is the same as remote {info.Color.red}red{info.Color.end}: local has diverged from remote {info.Color.purple}purple{info.Color.end}: local is ahead of remote (good for push) - {info.Color.yellow}yellow{info.Color.end}: local is behind remote (good for merge)''' - p_ll = subparsers.add_parser('ll', - help='display summary of all repos', - formatter_class=argparse.RawTextHelpFormatter, - description=ll_doc) - p_ll.add_argument('group', - nargs='?', - choices=utils.get_groups(), - help="show repos in the chosen group") + {info.Color.yellow}yellow{info.Color.end}: local is behind remote (good for merge)""" + p_ll = subparsers.add_parser( + "ll", + help="display summary of all repos", + formatter_class=argparse.RawTextHelpFormatter, + description=ll_doc, + ) + p_ll.add_argument( + "group", + nargs="?", + choices=utils.get_groups(), + 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", + help="set context", + 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="?", + 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( - 'ls', help='display names of all repos, or path of a chosen repo') - p_ls.add_argument('repo', - nargs='?', - choices=utils.get_repos(), - help="show path of the chosen repo") + "ls", + help="show repo(s) or repo path", + description="display names of all repos, or path of a chosen repo", + ) + p_ls.add_argument( + "repo", + nargs="?", + choices=utils.get_repos(), + help="show path of the chosen repo", + ) p_ls.set_defaults(func=f_ls) p_group = subparsers.add_parser( - 'group', help='group repos or display names of all groups if no repo is provided') - p_group.add_argument('to_group', - nargs='*', - choices=utils.get_choices(), - help="repo(s) to be grouped") + "group", description="list, add, or remove repo group(s)", help="group repos" + ) p_group.set_defaults(func=f_group) - - p_ungroup = subparsers.add_parser( - 'ungroup', help='remove group information for repos', - description="Remove group information on repos") - p_ungroup.add_argument('to_ungroup', - nargs='+', - choices=utils.get_repos(), - help="repo(s) to be ungrouped") - p_ungroup.set_defaults(func=f_ungroup) + group_cmds = p_group.add_subparsers( + dest="group_cmd", help="additional help with sub-command -h" + ) + pg_ll = group_cmds.add_parser("ll", description="List all groups with repos.") + pg_ll.add_argument( + "to_show", nargs="?", choices=utils.get_groups(), help="group to show" + ) + group_cmds.add_parser("ls", description="List all group names.") + pg_add = group_cmds.add_parser("add", description="Add repo(s) to a group.") + pg_add.add_argument( + "to_group", + nargs="+", + metavar="repo", + choices=utils.get_repos(), + help="repo(s) to be grouped", + ) + pg_add.add_argument( + "-n", + "--name", + dest="gname", + type=partial(_group_name, exclude_old_names=False), + metavar="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( + "to_rm", + 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(), + help="existing group to rename", + ) + pg_rename.add_argument( + "new_name", metavar="new-name", type=_group_name, help="new group name" + ) + group_cmds.add_parser("rm", description="Remove group(s).").add_argument( + "to_ungroup", nargs="+", choices=utils.get_groups(), help="group(s) to delete" + ) # superman mode p_super = subparsers.add_parser( - 'super', - help='superman mode: delegate any git command/alias in specified or ' - 'all repo(s).\n' + "super", + help="run any git command/alias", + description="Superman mode: delegate any git command/alias in specified repo(s), group(s), 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') + "\t gita super repo1 repo2 repo3 checkout new-feature", + ) p_super.add_argument( - 'man', + "man", nargs=argparse.REMAINDER, - help="execute arbitrary git command/alias for specified or all repos " - "Example: gita super myrepo1 diff --name-only --staged " - "Another: gita super checkout master ") + help="execute arbitrary git command/alias for specified repo(s), group(s), or all repos.\n" + "Example: gita super repo1 diff --name-only --staged;\n" + "gita super checkout master ", + ) + p_super.add_argument( + "-q", "--quote-mode", action="store_true", help="use quote mode" + ) p_super.set_defaults(func=f_super) + # shell mode + p_shell = subparsers.add_parser( + "shell", + help="run any shell command", + description="shell mode: delegate any shell command in specified repo(s), group(s), 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 repo(s), group(s), or all repos.\n" + "Example: gita shell myrepo1 ls\n" + "Another: gita shell git checkout master ", + ) + p_shell.add_argument( + "-q", "--quote-mode", action="store_true", help="use quote mode" + ) + p_shell.set_defaults(func=f_shell) + + # clear + p_clear = subparsers.add_parser( + "clear", + description="removes all groups and repositories", + help="removes all groups and repositories", + ) + p_clear.set_defaults(func=f_clear) + # sub-commands that fit boilerplate cmds = utils.get_cmds_from_files() for name, data in cmds.items(): - help = data.get('help') - cmd = data.get('cmd') or name - if data.get('allow_all'): + help = data.get("help") + cmd = data["cmd"] + if data.get("allow_all"): choices = utils.get_choices() - nargs = '*' - help += ' for all repos or' + nargs = "*" + 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, help=help) - sp.add_argument('repo', nargs=nargs, choices=choices, help=help) - sp.set_defaults(func=f_git_cmd, cmd=cmd.split()) + 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) + is_shell = bool(data.get("shell")) + sp.add_argument( + "-s", + "--shell", + default=is_shell, + type=bool, + help="If set, run in shell mode", + ) + if is_shell: + cmd = [cmd] + else: + cmd = cmd.split() + sp.set_defaults(func=f_git_cmd, cmd=cmd) args = p.parse_args(argv) args.async_blacklist = { - name - for name, data in cmds.items() if data.get('disable_async') + name for name, data in cmds.items() if data.get("disable_async") } - if 'func' in args: + if "func" in args: args.func(args) else: p.print_help() # pragma: no cover -if __name__ == '__main__': +if __name__ == "__main__": main() # pragma: no cover diff --git a/gita/cmds.json b/gita/cmds.json new file mode 100644 index 0000000..eadda81 --- /dev/null +++ b/gita/cmds.json @@ -0,0 +1,90 @@ +{ +"br":{ + "cmd": "git branch -vv", + "help":"show local branches"}, +"clean":{ + "cmd": "git clean -dfx", + "help": "remove all untracked files/folders"}, +"diff":{ + "cmd": "git diff", + "help": "git show differences"}, +"difftool":{ + "cmd": "git difftool", + "disable_async": true, + "help": "show differences using a tool" + }, +"fetch":{ + "cmd": "git fetch", + "allow_all": true, + "help": "fetch remote update" + }, +"last":{ + "cmd": "git log -1 HEAD", + "help": "show log information of HEAD" + }, +"log": + {"cmd": "git log", + "disable_async": true, + "help": "show logs" + }, +"merge":{ + "cmd": "git merge @{u}", + "help": "merge remote updates" + }, +"mergetool":{ + "cmd": "git mergetool", + "disable_async": true, + "help": "merge updates with a tool" + }, +"patch":{ + "cmd": "git format-patch HEAD~", + "help": "make a patch" + }, +"pull":{ + "cmd": "git pull", + "allow_all": true, + "help": "pull remote updates" + }, +"push":{ + "cmd": "git push", + "allow_all": true, + "help": "push the local updates" + }, +"rebase":{ + "cmd": "git rebase", + "help": "rebase from master" + }, +"reflog":{ + "cmd": "git reflog", + "help": "show ref logs" + }, +"remote":{ + "cmd": "git remote -v", + "help": "show remote settings" + }, +"reset":{ + "cmd": "git reset", + "help": "reset repo(s)" + }, +"show":{ + "cmd": "git show", + "disable_async": true, + "help": "show detailed commit information" + }, +"stash":{ + "cmd": "git stash", + "help": "store uncommited changes" + }, +"stat":{ + "cmd": "git diff --stat", + "help": "show edit statistics" + }, +"st":{ + "cmd": "git status", + "help": "show status" + }, +"tag":{ + "cmd": "git tag -n", + "help": "show tags" + } +} diff --git a/gita/cmds.yml b/gita/cmds.yml deleted file mode 100644 index 8db932e..0000000 --- a/gita/cmds.yml +++ /dev/null @@ -1,65 +0,0 @@ -br: - cmd: branch -vv - help: show local branches -clean: - cmd: clean -dfx - help: remove all untracked files/folders -diff: - help: show differences -difftool: - disable_async: true - help: show differences using a tool -fetch: - allow_all: true - help: fetch remote update -last: - cmd: log -1 HEAD - help: show log information of HEAD -log: - disable_async: true - help: show logs -merge: - cmd: merge @{u} - help: merge remote updates -mergetool: - disable_async: true - help: merge updates with a tool -patch: - cmd: format-patch HEAD~ - help: make a patch -pull: - allow_all: true - help: pull remote updates -push: - help: push the local updates -rebase: - help: rebase from master -reflog: - help: show ref logs -remote: - cmd: remote -v - help: show remote settings -reset: - help: reset repo(s) -shortlog: - disable_async: true - help: show short log -show: - disable_async: true - help: show detailed commit information -show-branch: - disable_async: true - help: show detailed branch information -stash: - help: store uncommited changes -stat: - cmd: diff --stat - help: show edit statistics -st: - help: show status -tag: - cmd: tag -n - help: show tags -whatchanged: - disable_async: true - help: show detailed log diff --git a/gita/common.py b/gita/common.py index 0a271fc..64116af 100644 --- a/gita/common.py +++ b/gita/common.py @@ -2,7 +2,16 @@ import os def get_config_dir() -> str: - parent = os.environ.get('XDG_CONFIG_HOME') or os.path.join( - os.path.expanduser('~'), '.config') - root = os.path.join(parent, "gita") - return root + root = ( + os.environ.get("GITA_PROJECT_HOME") + or os.environ.get("XDG_CONFIG_HOME") + or os.path.join(os.path.expanduser("~"), ".config") + ) + return os.path.join(root, "gita") + + +def get_config_fname(fname: str) -> str: + """ + Return the file name that stores the repo locations. + """ + return os.path.join(get_config_dir(), fname) diff --git a/gita/info.py b/gita/info.py index 18d20fd..10d8bea 100644 --- a/gita/info.py +++ b/gita/info.py @@ -1,146 +1,275 @@ -import os -import sys -import yaml +import csv import subprocess +from enum import Enum +from pathlib import Path +from collections import namedtuple +from functools import lru_cache, partial from typing import Tuple, List, Callable, Dict + from . import common -class Color: +class Color(Enum): """ Terminal color """ - red = '\x1b[31m' # local diverges from remote - green = '\x1b[32m' # local == remote - yellow = '\x1b[33m' # local is behind - blue = '\x1b[34m' - purple = '\x1b[35m' # local is ahead - cyan = '\x1b[36m' - white = '\x1b[37m' # no remote branch - end = '\x1b[0m' + black = "\x1b[30m" + red = "\x1b[31m" # local diverges from remote + green = "\x1b[32m" # local == remote + yellow = "\x1b[33m" # local is behind + blue = "\x1b[34m" + purple = "\x1b[35m" # local is ahead + cyan = "\x1b[36m" + white = "\x1b[37m" # no remote branch + end = "\x1b[0m" + b_black = "\x1b[30;1m" + b_red = "\x1b[31;1m" + b_green = "\x1b[32;1m" + b_yellow = "\x1b[33;1m" + b_blue = "\x1b[34;1m" + b_purple = "\x1b[35;1m" + b_cyan = "\x1b[36;1m" + b_white = "\x1b[37;1m" + underline = "\x1B[4m" + + # Make f"{Color.foo}" expand to Color.foo.value . + # + # See https://stackoverflow.com/a/24487545 + def __str__(self): + return f"{self.value}" + + +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 + """ """ + for i, c in enumerate(Color, start=1): + if c != Color.end and c != Color.underline: + print(f"{c.value}{c.name:<8} ", end="") + if i % 9 == 0: + print() + print(f"{Color.end}") + for situation, c in sorted(get_color_encoding().items()): + print(f"{situation:<12}: {Color[c].value}{c:<8}{Color.end} ") -def get_info_funcs() -> List[Callable[[str], str]]: + +@lru_cache() +def get_color_encoding() -> Dict[str, str]: + """ + Return color scheme for different local/remote situations. + In the format of {situation: color name} + """ + # custom settings + csv_config = Path(common.get_config_fname("color.csv")) + if csv_config.is_file(): + with open(csv_config, "r") as f: + reader = csv.DictReader(f) + colors = next(reader) + else: + colors = default_colors + return colors + + +def get_info_funcs(no_colors=False) -> List[Callable[[str], str]]: """ Return the functions to generate `gita ll` information. All these functions take the repo path as input and return the corresponding information as str. See `get_path`, `get_repo_status`, `get_common_commit` for examples. """ - info_items, to_display = get_info_items() - return [info_items[k] for k in to_display] + to_display = get_info_items() + # This re-definition is to make unit test mocking to work + all_info_items = { + "branch": partial(get_repo_status, no_colors=no_colors), + "branch_name": get_repo_branch, + "commit_msg": get_commit_msg, + "commit_time": get_commit_time, + "path": get_path, + } + return [all_info_items[k] for k in to_display] -def get_info_items() -> Tuple[Dict[str, Callable[[str], str]], List[str]]: +def get_info_items() -> List[str]: """ - Return the available information items for display in the `gita ll` - sub-command, and the ones to be displayed. - It loads custom information functions and configuration if they exist. + Return the information items to be displayed in the `gita ll` command. """ - # default settings - info_items = {'branch': get_repo_status, - 'commit_msg': get_commit_msg, - 'path': get_path, } - display_items = ['branch', 'commit_msg'] - # custom settings - root = common.get_config_dir() - src_fname = os.path.join(root, 'extra_repo_info.py') - yml_fname = os.path.join(root, 'info.yml') - if os.path.isfile(src_fname): - sys.path.append(root) - from extra_repo_info import extra_info_items - info_items.update(extra_info_items) - if os.path.isfile(yml_fname): - with open(yml_fname, 'r') as stream: - display_items = yaml.load(stream, Loader=yaml.FullLoader) - display_items = [x for x in display_items if x in info_items] - return info_items, display_items + csv_config = Path(common.get_config_fname("info.csv")) + if csv_config.is_file(): + with open(csv_config, "r") as f: + reader = csv.reader(f) + display_items = next(reader) + display_items = [x for x in display_items if x in ALL_INFO_ITEMS] + else: + # default settings + display_items = ["branch", "commit_msg", "commit_time"] + return display_items -def get_path(path): - return Color.cyan + path + Color.end +def get_path(prop: Dict[str, str]) -> str: + return f'{Color.cyan}{prop["path"]}{Color.end}' +# TODO: do we need to add the flags here too? def get_head(path: str) -> str: - result = subprocess.run('git rev-parse --abbrev-ref HEAD'.split(), - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - universal_newlines=True, - cwd=path) + result = subprocess.run( + "git symbolic-ref -q --short HEAD || git describe --tags --exact-match", + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + cwd=path, + ) return result.stdout.strip() -def run_quiet_diff(args: List[str]) -> bool: +def run_quiet_diff(flags: List[str], args: List[str], path) -> int: """ Return the return code of git diff `args` in quiet mode """ result = subprocess.run( - ['git', 'diff', '--quiet'] + args, + ["git"] + flags + ["diff", "--quiet"] + args, stderr=subprocess.DEVNULL, + cwd=path, ) return result.returncode -def get_common_commit() -> str: +def get_common_commit(path) -> str: """ Return the hash of the common commit of the local and upstream branches. """ - result = subprocess.run('git merge-base @{0} @{u}'.split(), - stdout=subprocess.PIPE, - universal_newlines=True) + result = subprocess.run( + "git merge-base @{0} @{u}".split(), + stdout=subprocess.PIPE, + universal_newlines=True, + cwd=path, + ) return result.stdout.strip() -def has_untracked() -> bool: +def has_untracked(flags: List[str], path) -> bool: """ Return True if untracked file/folder exists """ - result = subprocess.run('git ls-files -zo --exclude-standard'.split(), - stdout=subprocess.PIPE) + cmd = ["git"] + flags + "ls-files -zo --exclude-standard".split() + result = subprocess.run(cmd, stdout=subprocess.PIPE, cwd=path) return bool(result.stdout) -def get_commit_msg(path: str) -> str: +def get_commit_msg(prop: Dict[str, str]) -> str: """ Return the last commit message. """ # `git show-branch --no-name HEAD` is faster than `git show -s --format=%s` - result = subprocess.run('git show-branch --no-name HEAD'.split(), - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - universal_newlines=True, - cwd=path) + cmd = ["git"] + prop["flags"] + "show-branch --no-name HEAD".split() + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + cwd=prop["path"], + ) return result.stdout.strip() -def get_repo_status(path: str) -> str: - head = get_head(path) - dirty, staged, untracked, color = _get_repo_status(path) - return f'{color}{head+" "+dirty+staged+untracked:<10}{Color.end}' +def get_commit_time(prop: Dict[str, str]) -> str: + """ + Return the last commit time in parenthesis. + """ + cmd = ["git"] + prop["flags"] + "log -1 --format=%cd --date=relative".split() + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + universal_newlines=True, + cwd=prop["path"], + ) + return f"({result.stdout.strip()})" + + +default_symbols = { + "dirty": "*", + "staged": "+", + "untracked": "?", + "local_ahead": "↑", + "remote_ahead": "↓", + "diverged": "⇕", + "in_sync": "", + "no_remote": "∅", + "": "", +} -def _get_repo_status(path: str) -> Tuple[str]: +@lru_cache() +def get_symbols() -> Dict[str, str]: + """ + return status symbols with customization + """ + custom = {} + csv_config = Path(common.get_config_fname("symbols.csv")) + if csv_config.is_file(): + with open(csv_config, "r") as f: + reader = csv.DictReader(f) + custom = next(reader) + default_symbols.update(custom) + 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) + symbols = get_symbols() + info = f"{branch:<10} [{symbols[dirty]+symbols[staged]+symbols[untracked]+symbols[situ]}]" + + if no_colors: + return f"{info:<18}" + colors = {situ: Color[name].value for situ, name in get_color_encoding().items()} + color = colors[situ] + return f"{color}{info:<18}{Color.end}" + + +def get_repo_branch(prop: Dict[str, str]) -> str: + return get_head(prop["path"]) + + +def _get_repo_status(prop: Dict[str, str]) -> Tuple[str, str, str, str]: """ Return the status of one repo """ - os.chdir(path) - dirty = '*' if run_quiet_diff([]) else '' - staged = '+' if run_quiet_diff(['--cached']) else '' - untracked = '_' if has_untracked() else '' - - diff_returncode = run_quiet_diff(['@{u}', '@{0}']) - has_no_remote = diff_returncode == 128 - has_no_diff = diff_returncode == 0 - if has_no_remote: - color = Color.white - elif has_no_diff: - color = Color.green + path = prop["path"] + flags = prop["flags"] + 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 "" + + diff_returncode = run_quiet_diff(flags, ["@{u}", "@{0}"], path) + if diff_returncode == 128: + situ = "no_remote" + elif diff_returncode == 0: + situ = "in_sync" else: - common_commit = get_common_commit() - outdated = run_quiet_diff(['@{u}', common_commit]) + common_commit = get_common_commit(path) + outdated = run_quiet_diff(flags, ["@{u}", common_commit], path) if outdated: - diverged = run_quiet_diff(['@{0}', common_commit]) - color = Color.red if diverged else Color.yellow + diverged = run_quiet_diff(flags, ["@{0}", common_commit], path) + situ = "diverged" if diverged else "remote_ahead" else: # local is ahead of remote - color = Color.purple - return dirty, staged, untracked, color + situ = "local_ahead" + return dirty, staged, untracked, situ + + +ALL_INFO_ITEMS = { + "branch", + "branch_name", + "commit_msg", + "commit_time", + "path", +} diff --git a/gita/utils.py b/gita/utils.py index d14484a..6746d7f 100644 --- a/gita/utils.py +++ b/gita/utils.py @@ -1,61 +1,160 @@ +import sys import os -import yaml +import json +import csv import asyncio import platform +import subprocess from functools import lru_cache -from typing import List, Dict, Coroutine, Union +from pathlib import Path +from typing import List, Dict, Coroutine, Union, Iterator, Tuple +from collections import Counter, defaultdict +from concurrent.futures import ThreadPoolExecutor +import multiprocessing from . import info from . import common -def get_config_fname(fname: str) -> str: +MAX_INT = sys.maxsize + + +def get_relative_path(kid: os.PathLike, parent: str) -> Union[List[str], None]: """ - Return the file name that stores the repo locations. + Return the relative path depth if relative, otherwise None. + + Both the `kid` and `parent` should be absolute paths """ - root = common.get_config_dir() - return os.path.join(root, fname) + if parent == "": + return None + + p_kid = Path(kid) + # p_kid = Path(kid).resolve() + try: + p_rel = p_kid.relative_to(parent) + except ValueError: + return None + rel = str(p_rel).split(os.sep) + if rel == ["."]: + rel = [] + return rel @lru_cache() -def get_repos() -> Dict[str, str]: +def get_repos() -> Dict[str, Dict[str, str]]: """ - Return a `dict` of repo name to repo absolute path + Return a `dict` of repo name to repo absolute path and repo type + """ - path_file = get_config_fname('repo_path') + path_file = common.get_config_fname("repos.csv") repos = {} - # Each line is a repo path and repo name separated by , if os.path.isfile(path_file) and os.stat(path_file).st_size > 0: with open(path_file) as f: - for line in f: - line = line.rstrip() - if not line: # blank line - continue - path, name = line.split(',') - if not is_git(path): - continue - if name not in repos: - repos[name] = path - else: # repo name collision for different paths: include parent path name - par_name = os.path.basename(os.path.dirname(path)) - repos[os.path.join(par_name, name)] = path + rows = csv.DictReader( + f, ["path", "name", "type", "flags"], restval="" + ) # it's actually a reader + repos = { + r["name"]: { + "path": r["path"], + "type": r["type"], + "flags": r["flags"].split(), + } + for r in rows + if is_git(r["path"], include_bare=True) + } return repos @lru_cache() -def get_groups() -> Dict[str, List[str]]: +def get_context() -> Union[Path, 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")) + 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": + # 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(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, Dict[str, Union[str, List]]]: """ - 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 = get_config_fname('groups.yml') + fname = common.get_config_fname("groups.csv") groups = {} - # Each line is a repo path and repo name separated by , + repos = get_repos() + # 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: - groups = yaml.load(f, Loader=yaml.FullLoader) + with open(fname, "r") as f: + rows = csv.DictReader( + f, ["name", "repos", "path"], restval="", delimiter=":" + ) + # filter out invalid repos + groups = { + r["name"]: { + "repos": [repo for repo in r["repos"].split() if repo in repos], + "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: + Path(auto.with_name(f"{new}.context")).write_text("") + def get_choices() -> List[Union[str, None]]: """ @@ -72,67 +171,209 @@ def get_choices() -> List[Union[str, None]]: return choices -def is_git(path: str) -> bool: +def is_submodule_repo(p: Path) -> bool: + """ """ + if p.is_file() and ".git/modules" in p.read_text(): + return True + return False + + +def is_git(path: str, include_bare=False, exclude_submodule=False) -> bool: """ Return True if the path is a git repo. """ + if not os.path.exists(path): + return False # An alternative is to call `git rev-parse --is-inside-work-tree` # I don't see why that one is better yet. - # For a regular git repo, .git is a folder, for a worktree repo, .git is a file. - # However, git submodule repo also has .git as a file. + # For a regular git repo, .git is a folder. For a worktree repo and + # submodule repo, .git is a file. # A more reliable way to differentiable regular and worktree repos is to # compare the result of `git rev-parse --git-dir` and # `git rev-parse --git-common-dir` - loc = os.path.join(path, '.git') + loc = os.path.join(path, ".git") # TODO: we can display the worktree repos in a different font. - return os.path.exists(loc) - - -def rename_repo(repos: Dict[str, str], repo: str, new_name: str): + if os.path.exists(loc): + if exclude_submodule and is_submodule_repo(Path(loc)): + return False + return True + if not include_bare: + return False + # detect bare repo + got = subprocess.run( + "git rev-parse --is-bare-repository".split(), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + cwd=path, + ) + if got.returncode == 0 and got.stdout == b"true\n": + return True + return False + + +def rename_repo(repos: Dict[str, Dict[str, str]], repo: str, new_name: str): """ Write new repo name to file """ - path = repos[repo] + if new_name in repos: + print(f"{new_name} is already in use!") + return + prop = repos[repo] del repos[repo] - repos[new_name] = path - write_to_repo_file(repos, 'w') + repos[new_name] = prop + write_to_repo_file(repos, "w") + groups = get_groups() + for g, values in groups.items(): + members = values["repos"] + if repo in members: + members.remove(repo) + members.append(new_name) + groups[g]["repos"] = sorted(members) + write_to_groups_file(groups, "w") -def write_to_repo_file(repos: Dict[str, str], mode: str): + +def write_to_repo_file(repos: Dict[str, Dict[str, str]], mode: str): """ + @param repos: each repo is {name: {properties}} """ - data = ''.join(f'{path},{name}\n' for name, path in repos.items()) - fname = get_config_fname('repo_path') + # The 3rd column is repo type; unused field + data = [ + (prop["path"], name, "", " ".join(prop["flags"])) + for name, prop in repos.items() + ] + fname = common.get_config_fname("repos.csv") os.makedirs(os.path.dirname(fname), exist_ok=True) - with open(fname, mode) as f: - f.write(data) + with open(fname, mode, newline="") as f: + writer = csv.writer(f, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL) + 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): + """ """ + fname = common.get_config_fname("groups.csv") + os.makedirs(os.path.dirname(fname), exist_ok=True) + if not groups: # all groups are deleted + Path(fname).write_text("") + else: + # delete the group if there are no repos + for name in list(groups): + if not groups[name]["repos"]: + del groups[name] + with open(fname, mode, newline="") as f: + data = [ + (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) + + +def _make_name( + path: str, repos: Dict[str, Dict[str, str]], name_counts: Counter +) -> str: """ + Given a new repo `path`, create a repo name. By default, basename is used. + If name collision exists, further include parent path name. + @param path: It should not be in `repos` and is absolute """ - fname = get_config_fname('groups.yml') - os.makedirs(os.path.dirname(fname), exist_ok=True) - with open(fname, mode) as f: - yaml.dump(groups, f, default_flow_style=None) - - -def add_repos(repos: Dict[str, str], new_paths: List[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 + + +def add_repos( + repos: Dict[str, Dict[str, str]], + new_paths: List[str], + include_bare=False, + exclude_submodule=False, + dry_run=False, +) -> Dict[str, Dict[str, str]]: """ - Write new repo paths to file + Write new repo paths to file; return the added repos. + + @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)) + existing_paths = {prop["path"] for prop in repos.values()} + new_paths = {p for p in new_paths if is_git(p, include_bare, exclude_submodule)} new_paths = new_paths - existing_paths + new_repos = {} if new_paths: print(f"Found {len(new_paths)} new repo(s).") + if dry_run: + for p in new_paths: + print(p) + return {} + name_counts = Counter(os.path.basename(os.path.normpath(p)) for p in new_paths) new_repos = { - os.path.basename(os.path.normpath(path)): path - for path in new_paths} - write_to_repo_file(new_repos, 'a+') + _make_name(path, repos, name_counts): { + "path": path, + "flags": "", + } + for path in new_paths + } + write_to_repo_file(new_repos, "a+") + else: + print("No new repos found!") + return new_repos + + +def _generate_dir_hash(repo_path: str, paths: List[str]) -> Tuple[Tuple[str, ...], str]: + """ + 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: + rel = get_relative_path(repo_path, p)[:-1] + if rel is not None: + break else: - print('No new repos found!') + 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, 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(dict) + for repo_name, prop in repos.items(): + 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]) + 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 + + +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]: @@ -140,17 +381,19 @@ async def run_async(repo_name: str, path: str, cmds: List[str]) -> Union[None, s Run `cmds` asynchronously in `path` directory. Return the `path` if execution fails. """ + # TODO: deprecated since 3.8, will be removed in 3.10 process = await asyncio.create_subprocess_exec( *cmds, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, start_new_session=True, - cwd=path) + cwd=path, + ) 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: @@ -161,7 +404,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]]: @@ -169,7 +412,7 @@ def exec_async_tasks(tasks: List[Coroutine]) -> List[Union[None, str]]: Execute tasks asynchronously """ # TODO: asyncio API is nicer in python 3.7 - if platform.system() == 'Windows': + if platform.system() == "Windows": loop = asyncio.ProactorEventLoop() asyncio.set_event_loop(loop) else: @@ -182,17 +425,21 @@ def exec_async_tasks(tasks: List[Coroutine]) -> List[Union[None, str]]: return errors -def describe(repos: Dict[str, str]) -> str: +def describe(repos: Dict[str, Dict[str, str]], no_colors: bool = False) -> str: """ Return the status of all repos """ if repos: - name_width = max(len(n) for n in repos) + 1 - funcs = info.get_info_funcs() - for name in sorted(repos): - path = repos[name] - display_items = ' '.join(f(path) for f in funcs) - yield f'{name:<{name_width}}{display_items}' + 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)}', + sorted(repos), + ): + yield line def get_cmds_from_files() -> Dict[str, Dict[str, str]]: @@ -208,18 +455,59 @@ def get_cmds_from_files() -> Dict[str, Dict[str, str]]: } """ # default config file - fname = os.path.join(os.path.dirname(__file__), "cmds.yml") - with open(fname, 'r') as stream: - cmds = yaml.load(stream, Loader=yaml.FullLoader) + fname = os.path.join(os.path.dirname(__file__), "cmds.json") + with open(fname, "r") as f: + cmds = json.load(f) # custom config file root = common.get_config_dir() - fname = os.path.join(root, 'cmds.yml') + fname = os.path.join(root, "cmds.json") custom_cmds = {} if os.path.isfile(fname) and os.path.getsize(fname): - with open(fname, 'r') as stream: - custom_cmds = yaml.load(stream, Loader=yaml.FullLoader) + with open(fname, "r") as f: + custom_cmds = json.load(f) # custom commands shadow default ones cmds.update(custom_cmds) return cmds + + +def parse_repos_and_rest( + input: List[str], + quote_mode=False, +) -> Tuple[Dict[str, Dict[str, str]], List[str]]: + """ + Parse gita input arguments + + @return: repos and the rest (e.g., gita shell and super commands) + """ + i = 0 + names = [] + repos = get_repos() + groups = get_groups() + ctx = get_context() + for i, word in enumerate(input): + if word in repos or word in groups: + names.append(word) + else: + break + else: # all input is repos and groups, shift the index once more + if i is not None: + i += 1 + if not names and ctx: + names = [ctx.stem] + if quote_mode and i + 1 != len(input): + print(input[i], "is not a repo or group") + sys.exit(2) + + if names: + chosen = {} + for k in names: + if k in repos: + chosen[k] = repos[k] + if k in groups: + for r in groups[k]["repos"]: + chosen[r] = repos[r] + # if not set here, all repos are chosen + repos = chosen + return repos, input[i:] diff --git a/requirements.txt b/requirements.txt index 3e9e127..1ca2dbe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ -pytest>=4.4.0 +pytest>=6.1.2 pytest-cov>=2.6.1 -pytest-xdist>=1.26.0 +pytest-xdist>=2.1.0 setuptools>=40.6.3 twine>=1.12.1 -pyyaml>=5.1 @@ -1,25 +1,24 @@ from setuptools import setup long_description = None -with open('README.md', encoding='utf-8') as f: +with open("README.md", encoding="utf-8") as f: long_description = f.read() setup( - name='gita', - packages=['gita'], - version='0.10.10', - license='MIT', - description='Manage multiple git repos', + name="gita", + packages=["gita"], + version="0.16.6", + 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'], - author='Dong Zhou', - author_email='zhou.dong@gmail.com', - entry_points={'console_scripts': ['gita = gita.__main__:main']}, - install_requires=['pyyaml>=5.1'], - python_requires='~=3.6', + long_description_content_type="text/markdown", + url="https://github.com/nosarthur/gita", + platforms=["linux", "osx", "win32"], + 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"]}, + python_requires="~=3.6", classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -33,6 +32,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/clash_path_file b/tests/clash_path_file index 4abbfca..33eeae2 100644 --- a/tests/clash_path_file +++ b/tests/clash_path_file @@ -1,3 +1,3 @@ -/a/bcd/repo1,repo1 -/e/fgh/repo2,repo2 +/a/bcd/repo1,repo1, +/e/fgh/repo2,repo2,,--haha --pp /root/x/repo1,repo1 diff --git a/tests/conftest.py b/tests/conftest.py index b3e59ed..5236a90 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,10 +8,11 @@ def fullpath(fname: str): return str(TEST_DIR / fname) -PATH_FNAME = fullpath('mock_path_file') -PATH_FNAME_EMPTY = fullpath('empty_path_file') -PATH_FNAME_CLASH = fullpath('clash_path_file') -GROUP_FNAME = fullpath('mock_group_file') +PATH_FNAME = fullpath("mock_path_file") +PATH_FNAME_EMPTY = fullpath("empty_path_file") +PATH_FNAME_CLASH = fullpath("clash_path_file") +GROUP_FNAME = fullpath("mock_group_file") + def async_mock(): """ diff --git a/tests/mock_group_file b/tests/mock_group_file index 32f0a64..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/mock_path_file b/tests/mock_path_file index 2a5f9f9..81dc9ef 100644 --- a/tests/mock_path_file +++ b/tests/mock_path_file @@ -1,4 +1,4 @@ /a/bcd/repo1,repo1 -/a/b/c/repo3,xxx +/a/b/c/repo3,xxx,, /e/fgh/repo2,repo2 diff --git a/tests/test_info.py b/tests/test_info.py index 025aedc..0af8a47 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -4,13 +4,14 @@ from unittest.mock import patch, MagicMock from gita import info -@patch('subprocess.run') +@patch("subprocess.run") def test_run_quiet_diff(mock_run): mock_return = MagicMock() mock_run.return_value = mock_return - got = info.run_quiet_diff(['my', 'args']) + got = info.run_quiet_diff(["--flags"], ["my", "args"], "/a/b/c") mock_run.assert_called_once_with( - ['git', 'diff', '--quiet', 'my', 'args'], + ["git", "--flags", "diff", "--quiet", "my", "args"], stderr=subprocess.DEVNULL, + cwd="/a/b/c", ) assert got == mock_return.returncode diff --git a/tests/test_main.py b/tests/test_main.py index ff28111..a877160 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,178 +1,615 @@ import pytest from unittest.mock import patch +from pathlib import Path import argparse +import asyncio import shlex from gita import __main__ -from gita import utils +from gita import utils, info from conftest import ( - PATH_FNAME, PATH_FNAME_EMPTY, PATH_FNAME_CLASH, GROUP_FNAME, - async_mock + PATH_FNAME, + PATH_FNAME_EMPTY, + PATH_FNAME_CLASH, + GROUP_FNAME, + async_mock, + TEST_DIR, ) +@patch("gita.utils.get_repos", return_value={"aa"}) +def test_group_name(_): + got = __main__._group_name("xx") + assert got == "xx" + with pytest.raises(SystemExit): + __main__._group_name("aa") + + +class TestAdd: + @pytest.mark.parametrize( + "input, expected", + [ + (["add", "."], ""), + ], + ) + @patch("gita.common.get_config_fname") + def test_add(self, mock_path_fname, tmp_path, input, expected): + def side_effect(input, _=None): + return tmp_path / f"{input}.txt" + + mock_path_fname.side_effect = side_effect + utils.get_repos.cache_clear() + __main__.main(input) + utils.get_repos.cache_clear() + got = utils.get_repos() + assert len(got) == 1 + assert got["gita"]["type"] == expected + + +@pytest.mark.parametrize( + "path_fname, expected", + [ + (PATH_FNAME, ""), + (PATH_FNAME_CLASH, "repo2: ['--haha', '--pp']\n"), + ], +) +@patch("gita.utils.is_git", return_value=True) +@patch("gita.utils.get_groups", return_value={}) +@patch("gita.common.get_config_fname") +def test_flags(mock_path_fname, _, __, path_fname, expected, capfd): + mock_path_fname.return_value = path_fname + utils.get_repos.cache_clear() + __main__.main(["flags"]) + out, err = capfd.readouterr() + assert err == "" + assert out == expected + + class TestLsLl: - @patch('gita.utils.get_config_fname') - def testLl(self, mock_path_fname, capfd, tmp_path): - """ functional test """ + @patch("gita.common.get_config_fname") + def test_ll(self, mock_path_fname, capfd, tmp_path): + """ + functional test + """ + # avoid modifying the local configuration - mock_path_fname.return_value = tmp_path / 'path_config.txt' - __main__.main(['add', '.']) + def side_effect(input, _=None): + return tmp_path / f"{input}.txt" + + mock_path_fname.side_effect = side_effect + utils.get_repos.cache_clear() + __main__.main(["add", "."]) out, err = capfd.readouterr() - assert err == '' - assert 'Found 1 new repo(s).\n' == out + assert err == "" + assert "Found 1 new repo(s).\n" == out # in production this is not needed utils.get_repos.cache_clear() - __main__.main(['ls']) + __main__.main(["ls"]) + out, err = capfd.readouterr() + assert err == "" + assert "gita\n" == out + + __main__.main(["ll"]) out, err = capfd.readouterr() - assert err == '' - assert 'gita\n' == out + assert err == "" + assert "gita" in out + assert info.Color.end.value in out - __main__.main(['ll']) + # no color on branch name + __main__.main(["ll", "-C"]) out, err = capfd.readouterr() - assert err == '' - assert 'gita' in out + assert err == "" + assert "gita" in out + assert info.Color.end.value not in out - __main__.main(['ls', 'gita']) + __main__.main(["ls", "gita"]) out, err = capfd.readouterr() - assert err == '' - assert out.strip() == utils.get_repos()['gita'] - - def testLs(self, monkeypatch, capfd): - monkeypatch.setattr(utils, 'get_repos', - lambda: {'repo1': '/a/', 'repo2': '/b/'}) - monkeypatch.setattr(utils, 'describe', lambda x: x) - __main__.main(['ls']) + assert err == "" + assert out.strip() == utils.get_repos()["gita"]["path"] + + def test_ls(self, monkeypatch, capfd): + monkeypatch.setattr( + utils, + "get_repos", + lambda: {"repo1": {"path": "/a/"}, "repo2": {"path": "/b/"}}, + ) + monkeypatch.setattr(utils, "describe", lambda x: x) + __main__.main(["ls"]) out, err = capfd.readouterr() - assert err == '' + assert err == "" assert out == "repo1 repo2\n" - __main__.main(['ls', 'repo1']) + __main__.main(["ls", "repo1"]) out, err = capfd.readouterr() - assert err == '' - assert out == '/a/\n' - - @pytest.mark.parametrize('path_fname, expected', [ - (PATH_FNAME, - "repo1 cmaster dsu\x1b[0m msg\nrepo2 cmaster dsu\x1b[0m msg\nxxx cmaster dsu\x1b[0m msg\n"), - (PATH_FNAME_EMPTY, ""), - (PATH_FNAME_CLASH, - "repo1 cmaster dsu\x1b[0m msg\nrepo2 cmaster dsu\x1b[0m msg\nx/repo1 cmaster dsu\x1b[0m msg\n" - ), - ]) - @patch('gita.utils.is_git', return_value=True) - @patch('gita.info.get_head', return_value="master") - @patch('gita.info._get_repo_status', return_value=("d", "s", "u", "c")) - @patch('gita.info.get_commit_msg', return_value="msg") - @patch('gita.utils.get_config_fname') - def testWithPathFiles(self, mock_path_fname, _0, _1, _2, _3, path_fname, - expected, capfd): - mock_path_fname.return_value = path_fname + assert err == "" + assert out == "/a/\n" + + @pytest.mark.parametrize( + "path_fname, expected", + [ + ( + PATH_FNAME, + "repo1 \x1b[31mmaster [*+?⇕] \x1b[0m msg \nrepo2 \x1b[31mmaster [*+?⇕] \x1b[0m msg \nxxx \x1b[31mmaster [*+?⇕] \x1b[0m msg \n", + ), + (PATH_FNAME_EMPTY, ""), + ( + PATH_FNAME_CLASH, + "repo1 \x1b[31mmaster [*+?⇕] \x1b[0m msg \nrepo2 \x1b[31mmaster [*+?⇕] \x1b[0m msg \n", + ), + ], + ) + @patch("gita.utils.is_git", return_value=True) + @patch("gita.info.get_head", return_value="master") + @patch( + "gita.info._get_repo_status", + return_value=("dirty", "staged", "untracked", "diverged"), + ) + @patch("gita.info.get_commit_msg", return_value="msg") + @patch("gita.info.get_commit_time", return_value="") + @patch("gita.common.get_config_fname") + def test_with_path_files( + self, mock_path_fname, _0, _1, _2, _3, _4, path_fname, expected, capfd + ): + def side_effect(input, _=None): + if input == "repos.csv": + return path_fname + return f"/{input}" + + mock_path_fname.side_effect = side_effect utils.get_repos.cache_clear() - __main__.main(['ll']) + __main__.main(["ll"]) out, err = capfd.readouterr() print(out) - assert err == '' + assert err == "" assert out == expected -@patch('os.path.isfile', return_value=True) -@patch('gita.utils.get_config_fname', return_value='some path') -@patch('gita.utils.get_repos', return_value={'repo1': '/a/', 'repo2': '/b/'}) -@patch('gita.utils.write_to_repo_file') +@pytest.mark.parametrize( + "input, expected", + [ + ({"repo1": {"path": "/a/"}, "repo2": {"path": "/b/"}}, ""), + ], +) +@patch("subprocess.run") +@patch("gita.utils.get_repos") +def test_freeze(mock_repos, mock_run, input, expected, capfd): + mock_repos.return_value = input + __main__.main(["freeze"]) + assert mock_run.call_count == 2 + out, err = capfd.readouterr() + assert err == "" + assert out == expected + + +@patch("subprocess.run") +def test_clone_with_url(mock_run): + args = argparse.Namespace() + args.clonee = "http://abc.com/repo1" + args.preserve_path = None + args.directory = "/home/xxx" + args.from_file = False + args.dry_run = False + __main__.f_clone(args) + cmds = ["git", "clone", args.clonee] + mock_run.assert_called_once_with(cmds, cwd=args.directory) + + +@patch( + "gita.utils.parse_clone_config", + return_value=[["git@github.com:user/repo.git", "repo", "/a/repo"]], +) +@patch("gita.utils.run_async", new=async_mock()) +@patch("subprocess.run") +def test_clone_with_config_file(*_): + asyncio.set_event_loop(asyncio.new_event_loop()) + args = argparse.Namespace() + args.clonee = "freeze_filename" + args.preserve_path = False + args.directory = None + args.from_file = True + args.dry_run = False + __main__.f_clone(args) + mock_run = utils.run_async.mock + assert mock_run.call_count == 1 + cmds = ["git", "clone", "git@github.com:user/repo.git"] + mock_run.assert_called_once_with("repo", Path.cwd(), cmds) + + +@patch( + "gita.utils.parse_clone_config", + return_value=[["git@github.com:user/repo.git", "repo", "/a/repo"]], +) +@patch("gita.utils.run_async", new=async_mock()) +@patch("subprocess.run") +def test_clone_with_preserve_path(*_): + asyncio.set_event_loop(asyncio.new_event_loop()) + args = argparse.Namespace() + args.clonee = "freeze_filename" + args.directory = None + args.from_file = True + args.preserve_path = True + args.dry_run = False + __main__.f_clone(args) + mock_run = utils.run_async.mock + assert mock_run.call_count == 1 + cmds = ["git", "clone", "git@github.com:user/repo.git", "/a/repo"] + mock_run.assert_called_once_with("repo", Path.cwd(), cmds) + + +@patch("os.makedirs") +@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": {"path": "/a/", "type": ""}, + "repo2": {"path": "/b/", "type": ""}, + }, +) +@patch("gita.utils.write_to_repo_file") def test_rm(mock_write, *_): args = argparse.Namespace() - args.repo = ['repo1'] + args.repo = ["repo1"] __main__.f_rm(args) - mock_write.assert_called_once_with({'repo2': '/b/'}, 'w') + mock_write.assert_called_once_with({"repo2": {"path": "/b/", "type": ""}}, "w") def test_not_add(): # this won't write to disk because the repo is not valid - __main__.main(['add', '/home/some/repo/']) + __main__.main(["add", "/home/some/repo/"]) -@patch('gita.utils.get_repos', return_value={'repo2': '/d/efg'}) -@patch('subprocess.run') +@patch("gita.utils.get_repos", return_value={"repo2": {"path": "/d/efg", "flags": []}}) +@patch("subprocess.run") def test_fetch(mock_run, *_): - __main__.main(['fetch']) - mock_run.assert_called_once_with(['git', 'fetch'], cwd='/d/efg') + asyncio.set_event_loop(asyncio.new_event_loop()) + __main__.main(["fetch"]) + mock_run.assert_called_once_with(["git", "fetch"], cwd="/d/efg", shell=False) @patch( - 'gita.utils.get_repos', return_value={ - 'repo1': '/a/bc', - 'repo2': '/d/efg' - }) -@patch('gita.utils.run_async', new=async_mock()) -@patch('subprocess.run') + "gita.utils.get_repos", + return_value={ + "repo1": {"path": "/a/bc", "flags": []}, + "repo2": {"path": "/d/efg", "flags": []}, + }, +) +@patch("gita.utils.run_async", new=async_mock()) +@patch("subprocess.run") def test_async_fetch(*_): - __main__.main(['fetch']) + __main__.main(["fetch"]) mock_run = utils.run_async.mock assert mock_run.call_count == 2 - cmds = ['git', 'fetch'] + cmds = ["git", "fetch"] # print(mock_run.call_args_list) - mock_run.assert_any_call('repo1', '/a/bc', cmds) - mock_run.assert_any_call('repo2', '/d/efg', cmds) + mock_run.assert_any_call("repo1", "/a/bc", cmds) + mock_run.assert_any_call("repo2", "/d/efg", cmds) -@pytest.mark.parametrize('input', [ - 'diff --name-only --staged', - "commit -am 'lala kaka'", -]) -@patch('gita.utils.get_repos', return_value={'repo7': 'path7'}) -@patch('subprocess.run') +@pytest.mark.parametrize( + "input", + [ + "diff --name-only --staged", + "commit -am 'lala kaka'", + ], +) +@patch("gita.utils.get_repos", return_value={"repo7": {"path": "path7", "flags": []}}) +@patch("subprocess.run") def test_superman(mock_run, _, input): mock_run.reset_mock() - args = ['super', 'repo7'] + shlex.split(input) + args = ["super", "repo7"] + shlex.split(input) __main__.main(args) - expected_cmds = ['git'] + shlex.split(input) - mock_run.assert_called_once_with(expected_cmds, cwd='path7') - - -@pytest.mark.parametrize('input, expected', [ - ('a', {'xx': ['b'], 'yy': ['c', 'd']}), - ("c", {'xx': ['a', 'b'], 'yy': ['a', 'd']}), - ("a b", {'yy': ['c', 'd']}), -]) -@patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''}) -@patch('gita.utils.get_config_fname', return_value=GROUP_FNAME) -@patch('gita.utils.write_to_groups_file') -def test_ungroup(mock_write, _, __, input, expected): - utils.get_groups.cache_clear() - args = ['ungroup'] + shlex.split(input) + expected_cmds = ["git"] + shlex.split(input) + mock_run.assert_called_once_with(expected_cmds, cwd="path7", shell=False) + + +@pytest.mark.parametrize( + "input", + [ + "diff --name-only --staged", + "commit -am 'lala kaka'", + ], +) +@patch("gita.utils.get_repos", return_value={"repo7": {"path": "path7", "flags": []}}) +@patch("subprocess.run") +def test_shell(mock_run, _, input): + mock_run.reset_mock() + args = ["shell", "repo7", input] __main__.main(args) - mock_write.assert_called_once_with(expected, 'w') + expected_cmds = input + mock_run.assert_called_once_with( + expected_cmds, cwd="path7", shell=True, stderr=-2, stdout=-1 + ) -@patch('gita.utils.get_config_fname', return_value=GROUP_FNAME) -def test_group_display(_, capfd): - args = argparse.Namespace() - args.to_group = None - utils.get_groups.cache_clear() - __main__.f_group(args) - out, err = capfd.readouterr() - assert err == '' - assert 'xx: a b\nyy: a c d\n' == out +class TestContext: + @patch("gita.utils.get_context", return_value=None) + 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": {"repos": ["a", "b"]}}) + def test_display_context(self, _, __, capfd): + __main__.main(["context"]) + out, err = capfd.readouterr() + assert err == "" + assert "gname: a b\n" == out + + @patch("gita.utils.get_context") + def test_reset(self, mock_ctx): + __main__.main(["context", "none"]) + mock_ctx.return_value.unlink.assert_called() + + @patch("gita.utils.get_context", return_value=None) + @patch("gita.common.get_config_dir", return_value=TEST_DIR) + @patch("gita.utils.get_groups", return_value={"lala": ["b"], "kaka": []}) + def test_set_first_time(self, *_): + ctx = TEST_DIR / "lala.context" + assert not ctx.is_file() + __main__.main(["context", "lala"]) + assert ctx.is_file() + ctx.unlink() + + @patch("gita.common.get_config_dir", return_value=TEST_DIR) + @patch("gita.utils.get_groups", return_value={"lala": ["b"], "kaka": []}) + @patch("gita.utils.get_context") + def test_set_second_time(self, mock_ctx, *_): + __main__.main(["context", "kaka"]) + mock_ctx.return_value.rename.assert_called() + + +class TestGroupCmd: + @patch("gita.common.get_config_fname", return_value=GROUP_FNAME) + def test_ls(self, _, capfd): + args = argparse.Namespace() + args.to_group = None + args.group_cmd = "ls" + utils.get_groups.cache_clear() + __main__.f_group(args) + out, err = capfd.readouterr() + assert err == "" + assert "xx yy\n" == out + + @patch("gita.utils.get_repos", return_value={"a": "", "b": "", "c": "", "d": ""}) + @patch("gita.common.get_config_fname", return_value=GROUP_FNAME) + def test_ll(self, _, __, capfd): + args = argparse.Namespace() + args.to_group = None + args.group_cmd = None + args.to_show = None + utils.get_groups.cache_clear() + __main__.f_group(args) + out, err = capfd.readouterr() + assert err == "" + assert ( + out + == "\x1b[4mxx\x1b[0m: \n - a\n - b\n\x1b[4myy\x1b[0m: \n - a\n - c\n - d\n" + ) + + @patch("gita.utils.get_repos", return_value={"a": "", "b": "", "c": "", "d": ""}) + @patch("gita.common.get_config_fname", return_value=GROUP_FNAME) + def test_ll_with_group(self, _, __, capfd): + args = argparse.Namespace() + args.to_group = None + args.group_cmd = None + args.to_show = "yy" + utils.get_groups.cache_clear() + __main__.f_group(args) + out, err = capfd.readouterr() + assert err == "" + assert "a c d\n" == out + @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_rename(self, mock_write, *_): + args = argparse.Namespace() + args.gname = "xx" + args.new_name = "zz" + args.group_cmd = "rename" + utils.get_groups.cache_clear() + __main__.f_group(args) + expected = { + "yy": {"repos": ["a", "c", "d"], "path": ""}, + "zz": {"repos": ["a", "b"], "path": ""}, + } + mock_write.assert_called_once_with(expected, "w") -@patch('gita.utils.is_git', return_value=True) -@patch('gita.utils.get_config_fname', return_value=PATH_FNAME) -@patch('gita.utils.rename_repo') + @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, *_): + utils.get_groups.cache_clear() + with pytest.raises(SystemExit, match="1"): + __main__.main("group rename xx yy".split()) + + @pytest.mark.parametrize( + "input, expected", + [ + ("xx", {"yy": {"repos": ["a", "c", "d"], "path": ""}}), + ("xx yy", {}), + ], + ) + @patch("gita.utils.get_repos", return_value={"a": "", "b": "", "c": "", "d": ""}) + @patch("gita.common.get_config_fname", return_value=GROUP_FNAME) + @patch("gita.utils.write_to_groups_file") + def test_rm(self, mock_write, _, __, input, expected): + utils.get_groups.cache_clear() + args = ["group", "rm"] + shlex.split(input) + __main__.main(args) + mock_write.assert_called_once_with(expected, "w") + + @patch("gita.utils.get_repos", return_value={"a": "", "b": "", "c": "", "d": ""}) + @patch("gita.common.get_config_fname", return_value=GROUP_FNAME) + @patch("gita.utils.write_to_groups_file") + def test_add(self, mock_write, *_): + args = argparse.Namespace() + args.to_group = ["a", "c"] + args.group_cmd = "add" + args.gname = "zz" + utils.get_groups.cache_clear() + __main__.f_group(args) + mock_write.assert_called_once_with( + {"zz": {"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) + @patch("gita.utils.write_to_groups_file") + def test_add_to_existing(self, mock_write, *_): + args = argparse.Namespace() + args.to_group = ["a", "c"] + args.group_cmd = "add" + args.gname = "xx" + utils.get_groups.cache_clear() + __main__.f_group(args) + mock_write.assert_called_once_with( + { + "xx": {"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.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": {"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): + def side_effect(input, _=None): + return tmp_path / f"{input}.csv" + + mock_path_fname.side_effect = side_effect + + __main__.main("add .".split()) + utils.get_repos.cache_clear() + __main__.main("group add gita -n test".split()) + utils.get_groups.cache_clear() + __main__.main("ll test".split()) + out, err = capfd.readouterr() + assert err == "" + assert "gita" in out + + +@patch("gita.utils.is_git", return_value=True) +@patch("gita.common.get_config_fname", return_value=PATH_FNAME) +@patch("gita.utils.rename_repo") def test_rename(mock_rename, _, __): utils.get_repos.cache_clear() - args = ['rename', 'repo1', 'abc'] + args = ["rename", "repo1", "abc"] __main__.main(args) mock_rename.assert_called_once_with( - {'repo1': '/a/bcd/repo1', 'repo2': '/e/fgh/repo2', - 'xxx': '/a/b/c/repo3'}, - 'repo1', 'abc') + { + "repo1": {"path": "/a/bcd/repo1", "type": "", "flags": []}, + "xxx": {"path": "/a/b/c/repo3", "type": "", "flags": []}, + "repo2": {"path": "/e/fgh/repo2", "type": "", "flags": []}, + }, + "repo1", + "abc", + ) -@patch('os.path.isfile', return_value=False) -def test_info(mock_isfile, capfd): - __main__.f_info(None) +class TestInfo: + @patch("gita.common.get_config_fname", return_value="") + def test_ll(self, _, capfd): + args = argparse.Namespace() + args.info_cmd = None + __main__.f_info(args) + out, err = capfd.readouterr() + assert ( + "In use: branch,commit_msg,commit_time\nUnused: branch_name,path\n" == out + ) + assert err == "" + + @patch("gita.common.get_config_fname") + def test_add(self, mock_get_fname, tmpdir): + args = argparse.Namespace() + args.info_cmd = "add" + args.info_item = "path" + with tmpdir.as_cwd(): + csv_config = Path.cwd() / "info.csv" + mock_get_fname.return_value = csv_config + __main__.f_info(args) + items = info.get_info_items() + assert items == ["branch", "commit_msg", "commit_time", "path"] + + @patch("gita.common.get_config_fname") + def test_rm(self, mock_get_fname, tmpdir): + args = argparse.Namespace() + args.info_cmd = "rm" + args.info_item = "commit_msg" + with tmpdir.as_cwd(): + csv_config = Path.cwd() / "info.csv" + mock_get_fname.return_value = csv_config + __main__.f_info(args) + items = info.get_info_items() + assert items == ["branch", "commit_time"] + + +@patch("gita.common.get_config_fname") +def test_set_color(mock_get_fname, tmpdir): + args = argparse.Namespace() + args.color_cmd = "set" + 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": "b_white", + "in_sync": "green", + "diverged": "red", + "local_ahead": "purple", + "remote_ahead": "yellow", + } + + +@pytest.mark.parametrize( + "input, expected", + [ + ({"repo1": {"path": "/a/"}, "repo2": {"path": "/b/"}}, ""), + ], +) +@patch("gita.utils.write_to_groups_file") +@patch("gita.utils.write_to_repo_file") +@patch("gita.utils.get_repos") +def test_clear( + mock_repos, + mock_write_to_repo_file, + mock_write_to_groups_file, + input, + expected, + capfd, +): + mock_repos.return_value = input + __main__.main(["clear"]) + assert mock_write_to_repo_file.call_count == 1 + mock_write_to_repo_file.assert_called_once_with({}, "w") + assert mock_write_to_groups_file.call_count == 1 + mock_write_to_groups_file.assert_called_once_with({}, "w") out, err = capfd.readouterr() - assert 'In use: branch,commit_msg\nUnused: path\n' == out - assert err == '' + assert err == "" + assert out == expected diff --git a/tests/test_utils.py b/tests/test_utils.py index 3128041..2936f0e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,111 +1,269 @@ import pytest import asyncio +import subprocess +from pathlib import Path from unittest.mock import patch, mock_open from gita import utils, info from conftest import ( - PATH_FNAME, PATH_FNAME_EMPTY, PATH_FNAME_CLASH, GROUP_FNAME, + PATH_FNAME, + PATH_FNAME_EMPTY, + PATH_FNAME_CLASH, + GROUP_FNAME, + TEST_DIR, ) -@pytest.mark.parametrize('test_input, diff_return, expected', [ - ({ - 'abc': '/root/repo/' - }, True, 'abc \x1b[31mrepo *+_ \x1b[0m msg'), - ({ - 'repo': '/root/repo2/' - }, False, 'repo \x1b[32mrepo _ \x1b[0m msg'), -]) +@pytest.mark.parametrize( + "kid, parent, expected", + [ + ("/a/b/repo", "/a/b", ["repo"]), + ("/a/b/repo", "/a", ["b", "repo"]), + ("/a/b/repo", "/a/", ["b", "repo"]), + ("/a/b/repo", "", None), + ("/a/b/repo", "/a/b/repo", []), + ], +) +def test_get_relative_path(kid, parent, expected): + assert expected == utils.get_relative_path(kid, parent) + + +@pytest.mark.parametrize( + "input, expected", + [ + ( + [], + ( + { + "repo1": {"path": "/a/bcd/repo1", "type": "", "flags": []}, + "xxx": {"path": "/a/b/c/repo3", "type": "", "flags": []}, + "repo2": {"path": "/e/fgh/repo2", "type": "", "flags": []}, + }, + [], + ), + ), + ( + ["st"], + ( + { + "repo1": {"path": "/a/bcd/repo1", "type": "", "flags": []}, + "xxx": {"path": "/a/b/c/repo3", "type": "", "flags": []}, + "repo2": {"path": "/e/fgh/repo2", "type": "", "flags": []}, + }, + ["st"], + ), + ), + ( + ["repo1", "st"], + ({"repo1": {"flags": [], "path": "/a/bcd/repo1", "type": ""}}, ["st"]), + ), + (["repo1"], ({"repo1": {"flags": [], "path": "/a/bcd/repo1", "type": ""}}, [])), + ], +) +@patch("gita.utils.is_git", return_value=True) +@patch("gita.common.get_config_fname", return_value=PATH_FNAME) +def test_parse_repos_and_rest(mock_path_fname, _, input, expected): + got = utils.parse_repos_and_rest(input) + assert got == expected + + +@pytest.mark.parametrize( + "repo_path, paths, expected", + [ + ("/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) + assert got == expected + + +@pytest.mark.parametrize( + "repos, paths, expected", + [ + ( + {"r1": {"path": "/a/b//repo1"}, "r2": {"path": "/a/b/repo2"}}, + ["/a/b"], + {"b": {"repos": ["r1", "r2"], "path": "/a/b"}}, + ), + ( + {"r1": {"path": "/a/b//repo1"}, "r2": {"path": "/a/b/c/repo2"}}, + ["/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": {"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) + assert got == expected + + +@pytest.mark.parametrize( + "test_input, diff_return, expected", + [ + ( + [{"abc": {"path": "/root/repo/", "type": "", "flags": []}}, False], + True, + "abc \x1b[31mrepo [*+?⇕] \x1b[0m msg xx", + ), + ( + [{"abc": {"path": "/root/repo/", "type": "", "flags": []}}, True], + True, + "abc repo [*+?⇕] msg xx", + ), + ( + [{"repo": {"path": "/root/repo2/", "type": "", "flags": []}}, False], + False, + "repo \x1b[32mrepo [?] \x1b[0m msg xx", + ), + ], +) def test_describe(test_input, diff_return, expected, monkeypatch): - monkeypatch.setattr(info, 'get_head', lambda x: 'repo') - monkeypatch.setattr(info, 'run_quiet_diff', lambda _: diff_return) - monkeypatch.setattr(info, 'get_commit_msg', lambda _: "msg") - monkeypatch.setattr(info, 'has_untracked', lambda: True) - monkeypatch.setattr('os.chdir', lambda x: None) - print('expected: ', repr(expected)) - print('got: ', repr(next(utils.describe(test_input)))) - assert expected == next(utils.describe(test_input)) - - -@pytest.mark.parametrize('path_fname, expected', [ - (PATH_FNAME, { - 'repo1': '/a/bcd/repo1', - 'repo2': '/e/fgh/repo2', - 'xxx': '/a/b/c/repo3', - }), - (PATH_FNAME_EMPTY, {}), - (PATH_FNAME_CLASH, { - 'repo1': '/a/bcd/repo1', - 'repo2': '/e/fgh/repo2', - 'x/repo1': '/root/x/repo1' - }), -]) -@patch('gita.utils.is_git', return_value=True) -@patch('gita.utils.get_config_fname') + monkeypatch.setattr(info, "get_head", lambda x: "repo") + monkeypatch.setattr(info, "run_quiet_diff", lambda *_: diff_return) + monkeypatch.setattr(info, "get_commit_msg", lambda *_: "msg") + monkeypatch.setattr(info, "get_commit_time", lambda *_: "xx") + monkeypatch.setattr(info, "has_untracked", lambda *_: True) + monkeypatch.setattr(info, "get_common_commit", lambda x: "") + + info.get_color_encoding.cache_clear() # avoid side effect + assert expected == next(utils.describe(*test_input)) + + +@pytest.mark.parametrize( + "path_fname, expected", + [ + ( + PATH_FNAME, + { + "repo1": {"path": "/a/bcd/repo1", "type": "", "flags": []}, + "repo2": {"path": "/e/fgh/repo2", "type": "", "flags": []}, + "xxx": {"path": "/a/b/c/repo3", "type": "", "flags": []}, + }, + ), + (PATH_FNAME_EMPTY, {}), + ( + PATH_FNAME_CLASH, + { + "repo2": { + "path": "/e/fgh/repo2", + "type": "", + "flags": ["--haha", "--pp"], + }, + "repo1": {"path": "/root/x/repo1", "type": "", "flags": []}, + }, + ), + ], +) +@patch("gita.utils.is_git", return_value=True) +@patch("gita.common.get_config_fname") def test_get_repos(mock_path_fname, _, path_fname, expected): mock_path_fname.return_value = path_fname utils.get_repos.cache_clear() assert utils.get_repos() == expected -@pytest.mark.parametrize('group_fname, expected', [ - (GROUP_FNAME, {'xx': ['a', 'b'], 'yy': ['a', 'c', 'd']}), -]) -@patch('gita.utils.get_config_fname') -def test_get_groups(mock_group_fname, group_fname, expected): +@patch("gita.common.get_config_dir") +def test_get_context(mock_config_dir): + mock_config_dir.return_value = TEST_DIR + utils.get_context.cache_clear() + assert utils.get_context() == TEST_DIR / "xx.context" + + mock_config_dir.return_value = "/" + utils.get_context.cache_clear() + assert utils.get_context() == None + + +@pytest.mark.parametrize( + "group_fname, expected", + [ + ( + GROUP_FNAME, + { + "xx": {"repos": ["a", "b"], "path": ""}, + "yy": {"repos": ["a", "c", "d"], "path": ""}, + }, + ), + ], +) +@patch("gita.common.get_config_fname") +@patch("gita.utils.get_repos", return_value={"a": "", "b": "", "c": "", "d": ""}) +def test_get_groups(_, mock_group_fname, group_fname, expected): mock_group_fname.return_value = group_fname utils.get_groups.cache_clear() assert utils.get_groups() == expected -@patch('os.path.isfile', return_value=True) -@patch('os.path.getsize', return_value=True) +@patch("os.path.isfile", return_value=True) +@patch("os.path.getsize", return_value=True) def test_custom_push_cmd(*_): - with patch('builtins.open', - mock_open(read_data='push:\n cmd: hand\n help: me')): + with patch( + "builtins.open", + mock_open(read_data='{"push":{"cmd":"hand","help":"me","allow_all":true}}'), + ): cmds = utils.get_cmds_from_files() - assert cmds['push'] == {'cmd': 'hand', 'help': 'me'} + assert cmds["push"] == {"cmd": "hand", "help": "me", "allow_all": True} @pytest.mark.parametrize( - 'path_input, expected', + "path_input, expected", [ - (['/home/some/repo/'], '/home/some/repo,repo\n'), # add one new - (['/home/some/repo1', '/repo2'], - {'/repo2,repo2\n/home/some/repo1,repo1\n', # add two new - '/home/some/repo1,repo1\n/repo2,repo2\n'}), # add two new - (['/home/some/repo1', '/nos/repo'], - '/home/some/repo1,repo1\n'), # add one old one new - ]) -@patch('os.makedirs') -@patch('gita.utils.is_git', return_value=True) + (["/home/some/repo"], "/home/some/repo,some/repo,,\r\n"), # add one new + ( + ["/home/some/repo1", "/repo2"], + {"/repo2,repo2,,\r\n", "/home/some/repo1,repo1,,\r\n"}, # add two new + ), # add two new + ( + ["/home/some/repo1", "/nos/repo"], + "/home/some/repo1,repo1,,\r\n", + ), # add one old one new + ], +) +@patch("os.makedirs") +@patch("gita.utils.is_git", return_value=True) def test_add_repos(_0, _1, path_input, expected, monkeypatch): - monkeypatch.setenv('XDG_CONFIG_HOME', '/config') - with patch('builtins.open', mock_open()) as mock_file: - utils.add_repos({'repo': '/nos/repo'}, path_input) - mock_file.assert_called_with('/config/gita/repo_path', 'a+') + monkeypatch.setenv("XDG_CONFIG_HOME", "/config") + with patch("builtins.open", mock_open()) as mock_file: + utils.add_repos({"repo": {"path": "/nos/repo"}}, path_input) + mock_file.assert_called_with("/config/gita/repos.csv", "a+", newline="") handle = mock_file() if type(expected) == str: handle.write.assert_called_once_with(expected) else: - handle.write.assert_called_once() + # the write order is random + assert handle.write.call_count == 2 args, kwargs = handle.write.call_args assert args[0] in expected assert not kwargs -@patch('gita.utils.write_to_repo_file') -def test_rename_repo(mock_write): - utils.rename_repo({'r1': '/a/b', 'r2': '/c/c'}, 'r2', 'xxx') - mock_write.assert_called_once_with({'r1': '/a/b', 'xxx': '/c/c'}, 'w') +@patch("gita.utils.write_to_groups_file") +@patch("gita.utils.write_to_repo_file") +def test_rename_repo(mock_write, _): + repos = {"r1": {"path": "/a/b", "type": None}, "r2": {"path": "/c/c", "type": None}} + utils.rename_repo(repos, "r2", "xxx") + mock_write.assert_called_once_with(repos, "w") def test_async_output(capfd): tasks = [ - utils.run_async('myrepo', '.', [ - 'python3', '-c', - f"print({i});import time; time.sleep({i});print({i})" - ]) for i in range(4) + utils.run_async( + "myrepo", + ".", + ["python3", "-c", f"print({i});import time; time.sleep({i});print({i})"], + ) + for i in range(4) ] # I don't fully understand why a new loop is needed here. Without a new # loop, "pytest" fails but "pytest tests/test_utils.py" works. Maybe pytest @@ -114,5 +272,15 @@ def test_async_output(capfd): utils.exec_async_tasks(tasks) out, err = capfd.readouterr() - assert err == '' - assert out == 'myrepo: 0\nmyrepo: 0\n\nmyrepo: 1\nmyrepo: 1\n\nmyrepo: 2\nmyrepo: 2\n\nmyrepo: 3\nmyrepo: 3\n\n' + assert err == "" + assert ( + out + == "myrepo: 0\nmyrepo: 0\n\nmyrepo: 1\nmyrepo: 1\n\nmyrepo: 2\nmyrepo: 2\n\nmyrepo: 3\nmyrepo: 3\n\n" + ) + + +def test_is_git(tmpdir): + with tmpdir.as_cwd(): + subprocess.run("git init --bare .".split()) + assert utils.is_git(Path.cwd()) is False + assert utils.is_git(Path.cwd(), include_bare=True) is True diff --git a/tests/xx.context b/tests/xx.context new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/xx.context |