summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gita-completion.bash18
-rw-r--r--.github/dependabot.yml7
-rw-r--r--.github/workflows/nos.yml20
-rw-r--r--MANIFEST.in2
-rw-r--r--Makefile6
-rw-r--r--README.md356
-rw-r--r--debian/changelog136
-rw-r--r--debian/control6
-rw-r--r--debian/copyright4
-rwxr-xr-xdebian/rules12
-rw-r--r--debian/watch2
-rw-r--r--doc/README_CN.md78
-rw-r--r--doc/screenshot.pngbin225737 -> 262997 bytes
-rw-r--r--doc/video-outline.pngbin0 -> 178810 bytes
-rw-r--r--gita/__init__.py2
-rw-r--r--gita/__main__.py825
-rw-r--r--gita/cmds.json90
-rw-r--r--gita/cmds.yml65
-rw-r--r--gita/common.py17
-rw-r--r--gita/info.py293
-rw-r--r--gita/utils.py440
-rw-r--r--requirements.txt5
-rw-r--r--setup.py30
-rw-r--r--tests/clash_path_file4
-rw-r--r--tests/conftest.py9
-rw-r--r--tests/mock_group_file4
-rw-r--r--tests/mock_path_file2
-rw-r--r--tests/test_info.py7
-rw-r--r--tests/test_main.py663
-rw-r--r--tests/test_utils.py306
-rw-r--r--tests/xx.context0
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
diff --git a/Makefile b/Makefile
index c67d4a7..1c0698f 100644
--- a/Makefile
+++ b/Makefile
@@ -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/*
diff --git a/README.md b/README.md
index d6efa14..79c6009 100644
--- a/README.md
+++ b/README.md
@@ -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
index d5941d7..df967e9 100644
--- a/doc/screenshot.png
+++ b/doc/screenshot.png
Binary files differ
diff --git a/doc/video-outline.png b/doc/video-outline.png
new file mode 100644
index 0000000..a54ea23
--- /dev/null
+++ b/doc/video-outline.png
Binary files differ
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
diff --git a/setup.py b/setup.py
index 196d69b..4950b49 100644
--- a/setup.py
+++ b/setup.py
@@ -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