diff options
-rw-r--r-- | .gita-completion.bash | 37 | ||||
-rw-r--r-- | .github/workflows/nos.yml | 25 | ||||
-rw-r--r-- | .gitignore | 102 | ||||
-rw-r--r-- | .travis.yml | 11 | ||||
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | MANIFEST.in | 1 | ||||
-rw-r--r-- | Makefile | 12 | ||||
-rw-r--r-- | README.md | 230 | ||||
-rw-r--r-- | doc/README_CN.md | 187 | ||||
-rw-r--r-- | doc/design.md | 81 | ||||
-rw-r--r-- | doc/screenshot.png | bin | 0 -> 225737 bytes | |||
-rw-r--r-- | gita/__init__.py | 3 | ||||
-rw-r--r-- | gita/__main__.py | 289 | ||||
-rw-r--r-- | gita/cmds.yml | 65 | ||||
-rw-r--r-- | gita/common.py | 7 | ||||
-rw-r--r-- | gita/info.py | 146 | ||||
-rw-r--r-- | gita/utils.py | 225 | ||||
-rw-r--r-- | requirements.txt | 6 | ||||
-rw-r--r-- | setup.py | 38 | ||||
-rw-r--r-- | tests/clash_path_file | 3 | ||||
-rw-r--r-- | tests/conftest.py | 26 | ||||
-rw-r--r-- | tests/empty_path_file | 0 | ||||
-rw-r--r-- | tests/mock_group_file | 2 | ||||
-rw-r--r-- | tests/mock_path_file | 4 | ||||
-rw-r--r-- | tests/test_info.py | 16 | ||||
-rw-r--r-- | tests/test_main.py | 167 | ||||
-rw-r--r-- | tests/test_utils.py | 118 |
27 files changed, 1822 insertions, 0 deletions
diff --git a/.gita-completion.bash b/.gita-completion.bash new file mode 100644 index 0000000..5090bb7 --- /dev/null +++ b/.gita-completion.bash @@ -0,0 +1,37 @@ + +_gita_completions() +{ + + local cur commands repos cmd + + 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 + COMPREPLY=($(compgen -W "${commands}" ${cur})) + elif [ $COMP_CWORD -gt 1 ]; then + case $cmd in + add) + COMPREPLY=($(compgen -d ${cur})) + ;; + ll) + return + ;; + *) + COMPREPLY=($(compgen -W "${repos}" ${cur})) + ;; + esac + fi + +} + +complete -F _gita_completions gita + diff --git a/.github/workflows/nos.yml b/.github/workflows/nos.yml new file mode 100644 index 0000000..d75defa --- /dev/null +++ b/.github/workflows/nos.yml @@ -0,0 +1,25 @@ +name: gita-test + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: [3.6, 3.7, 3.8] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependences + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install . + - name: Pytest + run: | + pytest tests --cov=./gita diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4124c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,102 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# dotenv +.env + +# virtualenv +.venv +venv/ +ENV/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..dedcf1c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: python + +python: + - '3.6' +install: + - pip3 install . + - pip install -r requirements.txt +script: make test +after_success: + - bash <(curl -s https://codecov.io/bash) + @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Dong Zhou + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..e50bea9 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include gita/cmds.yml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c67d4a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: dist test install clean twine + +install: + pip3 install -e . +test: clean + pytest tests --cov=./gita $(TEST_ARGS) -n=auto +dist: clean + python3 setup.py sdist +twine: + twine upload dist/* +clean: + git clean -fdx diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6518a9 --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +[![PyPi version](https://img.shields.io/pypi/v/gita.svg?color=blue)](https://pypi.org/project/gita/) +[![Build Status](https://travis-ci.org/nosarthur/gita.svg?branch=master)](https://travis-ci.org/nosarthur/gita) +[![codecov](https://codecov.io/gh/nosarthur/gita/branch/master/graph/badge.svg)](https://codecov.io/gh/nosarthur/gita) +[![licence](https://img.shields.io/pypi/l/gita.svg)](https://github.com/nosarthur/gita/blob/master/LICENSE) +[![PyPI - Downloads](https://img.shields.io/pypi/dm/gita.svg)](https://pypistats.org/packages/gita) +[![Chinese](https://img.shields.io/badge/-中文-lightgrey.svg)](https://github.com/nosarthur/gita/blob/master/doc/README_CN.md) + +``` + _______________________________ +( ____ \__ __|__ __( ___ ) +| ( \/ ) ( ) ( | ( ) | +| | | | | | | (___) | +| | ____ | | | | | ___ | +| | \_ ) | | | | | ( ) | +| (___) |__) (___ | | | ) ( | +(_______)_______/ )_( |/ \| v0.10 +``` + +# Gita: a command-line tool to manage multiple git repos + +This tool does two things + +- display the status of multiple git repos such as branch, modification, commit message side by side +- delegate git commands/aliases from any working directory + +If several repos compile together, it helps to see their status together too. +I also hate to change directories to execute git commands. + +![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) + +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. + +The additional status symbols denote + +- `+`: 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 ll`: display the status of all repos +- `gita ll <group-name>`: display the status of repos in a group +- `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 -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 + +- `gita <sub-command> [repo-name(s) or group-name(s)]`: + optional repo or group input, and no input means all repos. +- `gita <sub-command> <repo-name(s) or groups-name(s)>`: + required repo name(s) or group name(s) input + +By default, only `fetch` and `pull` take optional 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. + +## Customization + +Custom delegating sub-commands can be defined in `$XDG_CONFIG_HOME/gita/cmds.yml` +(most likely `~/.config/gita/cmds.yml`). +And they shadow the default ones if name collisions exist. + +Default delegating sub-commands are defined in +[cmds.yml](https://github.com/nosarthur/gita/blob/master/gita/cmds.yml). +For example, `gita stat <repo-name(s)>` is registered as + +```yaml +stat: + cmd: diff --stat + help: show edit statistics +``` + +which executes `git diff --stat`. + +If the delegated git command is a single word, the `cmd` tag can be omitted. +See `push` for an example. +To disable asynchronous execution, set the `disable_async` tag to be `true`. +See `difftool` for an example. + +If you want a custom command to behave like `gita fetch`, i.e., to apply +command to all repos if nothing is specified, +set the `allow_all` option to be `true`. +For example, the following snippet creates a new command +`gita comaster [repo-name(s)]` with optional repo name input. + +```yaml +comaster: + cmd: checkout master + allow_all: true + help: checkout the master branch +``` + +Another customization is the information items displayed by `gita ll`. +The used and unused information items are shown with `gita info` and one can +create `$XDG_CONFIG_HOME/gita/info.yml` to customize it. For example, the +default information items setting corresponds to + +```yaml +- branch +- commit_msg +``` + +To create your own information items, define a dictionary called `extra_info_items` +in `$XDG_CONFIG_HOME/gita/extra_repo_info.py`. It should map strings to functions, +where the strings are the information item names and the functions take repo path +as input. A trivial example is shown below. + +```python +def get_delim(path: str) -> str: + return '|' + +extra_info_items = {'delim': get_delim} +``` + +If it works, you will see these extra items in the 'Unused' section of the +`gita info` output. To use them, edit `$XDG_CONFIG_HOME/gita/extra_repo_info.py`. + +## Superman mode + +The superman mode delegates any git command/alias. +Usage: + +``` +gita super [repo-name(s) or group-name(s)] <any-git-command-with-or-without-options> +``` + +Here `repo-name(s)` or `group-name(s)` are optional, and their absence means all repos. +For example, + +- `gita super checkout master` puts all repos on the master branch +- `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` + +## Requirements + +Gita requires Python 3.6 or higher, due to the use of +[f-string](https://www.python.org/dev/peps/pep-0498/) +and [asyncio module](https://docs.python.org/3.6/library/asyncio.html). + +Under the hood, gita uses subprocess to run git commands/aliases. +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. + +## Installation + +To install the latest version, run + +``` +pip3 install -U gita +``` + +If development mode is preferred, +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. + +``` +alias gita="python3 -m gita" +``` + +Windows users may need to enable the ANSI escape sequence in terminal, otherwise +the branch color won't work. +See [this stackoverflow post](https://stackoverflow.com/questions/51680709/colored-text-output-in-powershell-console-using-ansi-vt100-codes) for details. + +## Auto-completion + +Download +[.gita-completion.bash](https://github.com/nosarthur/gita/blob/master/.gita-completion.bash) +and source it in `.bashrc`. + +## Contributing + +To contribute, you can + +- report/fix bugs +- request/implement features +- star/recommend this project + +To run tests locally, simply `pytest`. +More implementation details are in +[design.md](https://github.com/nosarthur/gita/blob/master/doc/design.md). + +You can also make donation to me on [patreon](https://www.patreon.com/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) + +## Other multi-repo tools + +I haven't tried them but I heard good things about them. + +- [myrepos](https://myrepos.branchable.com/) +- [repo](https://source.android.com/setup/develop/repo) + diff --git a/doc/README_CN.md b/doc/README_CN.md new file mode 100644 index 0000000..ac688db --- /dev/null +++ b/doc/README_CN.md @@ -0,0 +1,187 @@ +[![PyPi version](https://img.shields.io/pypi/v/gita.svg?color=blue)](https://pypi.org/project/gita/) +[![Build Status](https://travis-ci.org/nosarthur/gita.svg?branch=master)](https://travis-ci.org/nosarthur/gita) +[![codecov](https://codecov.io/gh/nosarthur/gita/branch/master/graph/badge.svg)](https://codecov.io/gh/nosarthur/gita) +[![licence](https://img.shields.io/pypi/l/gita.svg)](https://github.com/nosarthur/gita/blob/master/LICENSE) +[![PyPI - Downloads](https://img.shields.io/pypi/dm/gita.svg)](https://pypistats.org/packages/gita) +[![English](https://img.shields.io/badge/-English-lightgrey.svg)](https://github.com/nosarthur/gita) + +``` + _______________________________ +( ____ \__ __|__ __( ___ ) +| ( \/ ) ( ) ( | ( ) | +| | | | | | | (___) | +| | ____ | | | | | ___ | +| | \_ ) | | | | | ( ) | +| (___) |__) (___ | | | ) ( | +(_______)_______/ )_( |/ \| v0.10 +``` + +# Gita:一个管理多个 git 库的命令行工具 + +这个工具有两个作用: + +- 并排显示多个库的状态信息,比如分支名,编辑状态,提交信息等 +- 在任何目录下代理执行 git 指令 + +![gita screenshot](https://github.com/nosarthur/gita/raw/master/doc/screenshot.png) + +本地和远程分支之间的关系有5种情况,在这里分别用5种颜色对应着: + +- 绿色:本地和远程保持一致 +- 红色:本地和远程产生了分叉 +- 黄色:本地落后于远程(适合合并merge) +- 白色:本地没有指定远程 +- 紫色:本地超前于远程(适合推送push) + +为什么选择了紫色作为超前以及黄色作为落后,绿色作为基准 的理由在这两篇文章中解释: +[blueshift](https://en.wikipedia.org/wiki/Blueshift)、[redshift](https://en.wikipedia.org/wiki/Redshift) + +额外的状态符号意义: + +- `+`: 暂存(staged) +- `*`: 未暂存(unstaged) +- `_`: 未追踪(untracked) + +基础指令: + +- `gita add <repo-path(s)>`: 添加库 +- `gita rm <repo-name(s)>`: 移除库(不会删除文件) +- `gita group`: 显示库的组群 +- `gita group` <repo-name(s)>: 将库分组 +- `gita ll`: 显示所有库的状态信息 +- `gita ll <group-name>`: 显示一个组群中库的状态信息 +- `gita ls`: 显示所有库的名字 +- `gita ls <repo-name>`: 显示一个库的绝对路径 +- `gita rename <repo-name> <new-name>`: 重命名一个库 +- `gita info`: 显示已用的和未用的信息项 +- `gita -v`: 显示版本号 + +库的路径存在`$XDG_CONFIG_HOME/gita/repo_path` (多半是`~/.config/gita/repo_path`)。 + +代理执行的子命令有两种格式: + +- `gita <sub-command> [repo-name(s) or group-name(s)]`: 库名或组群名是可选的,缺失表示所有库 +- `gita <sub-command> <repo-name(s) or group-name(s)>`: 必须有库名或组群名 + +默认只有`fetch`和`pull`是第一种格式。 + +如果输入了多个库名, +而且被代理的git指令不需要用户输入, +那么各个库的代理指令会被异步执行。 + +## 私人定制 + +定制的代理子命令要放在`$XDG_CONFIG_HOME/gita/cmds.yml` (多半是`~/.config/gita/cmds.yml`)。 +如果存在命名冲突,它们会覆盖掉默认的指令。 + +默认代理子指令的定义可见 +[cmds.yml](https://github.com/nosarthur/gita/blob/master/gita/cmds.yml)。 +举个栗子,`gita stat <repo-name(s)>`的定义是 + +```yaml +stat: + cmd: diff --stat + help: show edit statistics +``` + +它会执行`git diff --stat`。 + +如果被代理的指令是一个单词,`cmd`也可以省略。比如`push`。 +如果要取消异步执行,把`disable_async`设成`true`。比如`difftool`。 + +如果你想让定制的命令跟`gita fetch`等命令一样,可以作用于所有的库, +就把`allow_all`设成`true`。 +举个栗子,`gita comaster [repo-names(s)]`会生成一个新的定制命令,对于这个命令,库名是可选输入。comaster的解释如下: + +```yaml +comaster: + cmd: checkout master + allow_all: true + help: checkout the master branch +``` +另一个自定义功能是针对`gita ll`展示的信息项。 +`gita info`可以展示所有用到的和没用到的信息项,并且可以通过修改`$XDG_CONFIG_HOME/gita/info.yml`支持自定义。举个栗子,默认的信息项显示配置相当于是: + +```yaml +- branch +- commit_msg +``` +为了创建自己的信息项,命名一个目录为`extra_info_items`。 +在`$XDG_CONFIG_HOME/gita/extra_repo_info.py`中,要把信息项的名字作为字符串映射到方法中,该方法将库的路径作为输入参数。举个栗子: + +```python +def get_delim(path: str) -> str: + return '|' + +extra_info_items = {'delim': get_delim} +``` +如果没有遇到问题,你会在`gita info`的输出内容中的`unused`小节中看到这些额外信息项。 + +## 超人模式 + +超人模式可以代理执行任何git命令/别名。它的格式是 + +``` +gita super [repo-name(s) or group-name(s)] <any-git-command-with-or-without-options> +``` + +其中库名或组群名是可有可无的。举几个例子 + +- `gita super checkout master`会把所有库都弄到主库杈上 +- `gita super frontend-repo backend-repo commit -am 'implement a new feature'` + 会对`frontend-repo`和`backend-repo`运行`git commit -am 'implement a new feature'` + +## 先决条件 + +因为用了[f-string](https://www.python.org/dev/peps/pep-0498/) +和[asyncio module](https://docs.python.org/3.6/library/asyncio.html),系统必须要用Python 3.6或以上。 + +暗地里老夫用`subprocess`来代理执行git指令。所以git的版本有可能会影响结果。 +经测试,`1.8.3.1`, `2.17.2`, 和`2.20.1`的结果是一致的。 + +## 安装指南 + +正常人类按装: + +``` +pip3 install -U gita +``` + +神奇码农安装:先下载源码,然后 + +``` +pip3 install -e <gita-source-folder> +``` + +装完之后在命令行下执行`gita`可能还不行。那就把下面这个昵称放到`.bashrc`里。 +``` +alias gita="python3 -m gita" +``` + +Windows用户可能需要额外的设置来支持彩色的命令行, 见[这个帖子](https://stackoverflow.com/questions/51680709/colored-text-output-in-powershell-console-using-ansi-vt100-codes)。 + +## 自动补全 + +下载 +[.gita-completion.bash](https://github.com/nosarthur/gita/blob/master/.gita-completion.bash) +并在`.bashrc`里点它。 + +## 有所作为 + +要想有所作为,你可以 + +- 报告/治理虫子 +- 建议/实现功能 +- 加星/推荐本作 + +在本地跑单元测试可以直接用`pytest`。更多的细节可见 +[design.md](https://github.com/nosarthur/gita/blob/master/doc/design.md)。 + +如果你愿意资助我,请访问[patreon](https://www.patreon.com/nosarthur). + +## 他山之石 + +没用过,听说不错 + +- [myrepos](https://myrepos.branchable.com/) +- [repo](https://source.android.com/setup/develop/repo) diff --git a/doc/design.md b/doc/design.md new file mode 100644 index 0000000..6edf84e --- /dev/null +++ b/doc/design.md @@ -0,0 +1,81 @@ +# design + +This document explains the inner workings of this 200 LOC (excluding tests) project. + +The main idea of `gita` is to run git command/alias in subprocess or +asynchronous subprocess, which enables the following features + +- execute git commands/aliases from any working directory +- execute the same command for multiple repos in batch mode + +In addition, the `gita ll` command runs various `git` commands to collect +information for each repo, and displays the result of all repos side by side. + +## user interface + +There are three types of `gita` sub-commands + +- **bookkeeping**: add/remove repos from `gita`, display repo information +- **delegating**: delegate pre-configured `git` commands or aliases +- **`super`**: delegate arbitrary `git` commands or aliases + +And there are only two `gita` options, i.e., the `-h` for help and `-v` for version. + +The bookkeeping and delegating sub-commands all share the formats + +```shell +gita <sub-command> <repo-name(s)> +gita <sub-command> [repo-name(s)] +``` + +The exceptions are `add`, `ll`, and `super` + +```shell +gita ll +gita add <repo-path(s)> +gita super [repo-name(s)] <any-git-command-with-options> +``` + +The argument choices are determined by two utility functions + +- `<repo-name(s)>`: `utils.get_repos() -> Dict[str, str]` +- `[repo-name(s)]`: `utils.get_choices() -> List[Union[str, None]]` which allows null input + +## sub-command actions + +The actions of the `gita` sub-commands are defined +in [`__main__.py`](https://github.com/nosarthur/gita/gita/__main__.py). + +All delegating sub-commands call + +```python +f_git_cmd(args: argparse.Namespace) +``` + +to run either `subprocess` or `asyncio` APIs. +`subprocess` is used if there is only one repo input or the sub-command is +not allowed to run asynchronously. Otherwise `asyncio` is used for efficiency. + +The bookkeeping and `super` sub-commands have their own action functions + +```python +f_<sub-command>(args: argparse.Namespace) +``` + +Not surprisingly, the `f_super` function calls `f_git_cmd` in the end. + +## repo status information + +Utility functions to extract repo status information are defined in [utils.py](https://github.com/nosarthur/gita/gita/utils.py). +For example, + +| information | API | note | +| ------------------------------------------------------------------------------ | ------------------------------------------- | --------------------------------------- | +| repo name and path | `get_repos() -> Dict[str, str]` | parse `$XDG_CONFIG_HOME/gita/repo_path` | +| branch name | `get_head(path: str) -> str` | parse `.git/HEAD` | +| commit message | `get_commit_msg() -> str` | run `subprocess` | +| loca/remote relation | `_get_repo_status(path: str) -> Tuple[str]` | run `subprocess` | +| edit status, i.e., unstaged change `*`, staged change `+`, untracked files `_` | `_get_repo_status(path: str) -> Tuple[str]` | run `subprocess` | + +I notice that parsing file is faster than running `subprocess`. +One future improvement could be replacing the `subprocess` calls. diff --git a/doc/screenshot.png b/doc/screenshot.png Binary files differnew file mode 100644 index 0000000..d5941d7 --- /dev/null +++ b/doc/screenshot.png diff --git a/gita/__init__.py b/gita/__init__.py new file mode 100644 index 0000000..eeb79a3 --- /dev/null +++ b/gita/__init__.py @@ -0,0 +1,3 @@ +import pkg_resources + +__version__ = pkg_resources.get_distribution('gita').version diff --git a/gita/__main__.py b/gita/__main__.py new file mode 100644 index 0000000..ba0d270 --- /dev/null +++ b/gita/__main__.py @@ -0,0 +1,289 @@ +''' +Gita manages multiple git repos. It has two functionalities + + 1. display the status of multiple repos side by side + 2. delegate git commands/aliases from any working directory + +Examples: + gita ls + gita fetch + gita stat myrepo2 + gita super myrepo1 commit -am 'add some cool feature' + +For bash auto completion, download and source +https://github.com/nosarthur/gita/blob/master/.gita-completion.bash +''' + +import os +import argparse +import subprocess +import pkg_resources + +from . import utils, info + + +def f_add(args: argparse.Namespace): + repos = utils.get_repos() + utils.add_repos(repos, args.paths) + + +def f_rename(args: argparse.Namespace): + repos = utils.get_repos() + 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_ll(args: argparse.Namespace): + """ + Display details of all repos + """ + repos = utils.get_repos() + if args.group: # only display repos in this group + group_repos = utils.get_groups()[args.group] + repos = {k: repos[k] for k in group_repos if k in repos} + for line in utils.describe(repos): + print(line) + + +def f_ls(args: argparse.Namespace): + repos = utils.get_repos() + if args.repo: # one repo, show its path + print(repos[args.repo]) + else: # show names of all repos + print(' '.join(repos)) + + +def f_group(args: argparse.Namespace): + repos = utils.get_repos() + groups = utils.get_groups() + if args.to_group: + gname = input('group name? ') + if gname in groups: + gname_repos = set(groups[gname]) + gname_repos.update(args.to_group) + groups[gname] = sorted(gname_repos) + 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)) + else: + to_del.append(name) + for name in to_del: + del groups[name] + utils.write_to_groups_file(groups, 'w') + + +def f_rm(args: argparse.Namespace): + """ + Unregister repo(s) from gita + """ + path_file = utils.get_config_fname('repo_path') + if os.path.isfile(path_file): + repos = utils.get_repos() + for repo in args.repo: + del repos[repo] + utils.write_to_repo_file(repos, 'w') + + +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(): + print(path) + subprocess.run(cmds, cwd=path) + 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()) + for path in errors: + if path: + print(path) + subprocess.run(cmds, cwd=path) + + +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 + f_git_cmd(args) + + +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}') + + # 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.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.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_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_info.set_defaults(func=f_info) + + ll_doc = f''' status symbols: + +: staged changes + *: unstaged changes + _: untracked files/folders + + branch colors: + {info.Color.white}white{info.Color.end}: local has no remote + {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") + p_ll.set_defaults(func=f_ll) + + 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") + 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") + 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) + + # superman mode + p_super = subparsers.add_parser( + 'super', + help='superman mode: delegate any git command/alias in specified or ' + 'all repo(s).\n' + 'Examples:\n \t gita super myrepo1 commit -am "fix a bug"\n' + '\t gita super repo1 repo2 repo3 checkout new-feature') + p_super.add_argument( + '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 ") + p_super.set_defaults(func=f_super) + + # 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'): + choices = utils.get_choices() + 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()) + + args = p.parse_args(argv) + + args.async_blacklist = { + name + for name, data in cmds.items() if data.get('disable_async') + } + + if 'func' in args: + args.func(args) + else: + p.print_help() # pragma: no cover + + +if __name__ == '__main__': + main() # pragma: no cover diff --git a/gita/cmds.yml b/gita/cmds.yml new file mode 100644 index 0000000..8db932e --- /dev/null +++ b/gita/cmds.yml @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000..61df049 --- /dev/null +++ b/gita/common.py @@ -0,0 +1,7 @@ +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 diff --git a/gita/info.py b/gita/info.py new file mode 100644 index 0000000..18d20fd --- /dev/null +++ b/gita/info.py @@ -0,0 +1,146 @@ +import os +import sys +import yaml +import subprocess +from typing import Tuple, List, Callable, Dict +from . import common + + +class Color: + """ + 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' + + +def get_info_funcs() -> 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] + + +def get_info_items() -> Tuple[Dict[str, Callable[[str], str]], 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. + """ + # 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 + + +def get_path(path): + return Color.cyan + path + Color.end + + +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) + return result.stdout.strip() + + +def run_quiet_diff(args: List[str]) -> bool: + """ + Return the return code of git diff `args` in quiet mode + """ + result = subprocess.run( + ['git', 'diff', '--quiet'] + args, + stderr=subprocess.DEVNULL, + ) + return result.returncode + + +def get_common_commit() -> 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) + return result.stdout.strip() + + +def has_untracked() -> bool: + """ + Return True if untracked file/folder exists + """ + result = subprocess.run('git ls-files -zo --exclude-standard'.split(), + stdout=subprocess.PIPE) + return bool(result.stdout) + + +def get_commit_msg(path: 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) + 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_repo_status(path: str) -> Tuple[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 + else: + common_commit = get_common_commit() + outdated = run_quiet_diff(['@{u}', common_commit]) + if outdated: + diverged = run_quiet_diff(['@{0}', common_commit]) + color = Color.red if diverged else Color.yellow + else: # local is ahead of remote + color = Color.purple + return dirty, staged, untracked, color diff --git a/gita/utils.py b/gita/utils.py new file mode 100644 index 0000000..d14484a --- /dev/null +++ b/gita/utils.py @@ -0,0 +1,225 @@ +import os +import yaml +import asyncio +import platform +from functools import lru_cache +from typing import List, Dict, Coroutine, Union + +from . import info +from . import common + + +def get_config_fname(fname: str) -> str: + """ + Return the file name that stores the repo locations. + """ + root = common.get_config_dir() + return os.path.join(root, fname) + + +@lru_cache() +def get_repos() -> Dict[str, str]: + """ + Return a `dict` of repo name to repo absolute path + """ + path_file = get_config_fname('repo_path') + 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 + return repos + + +@lru_cache() +def get_groups() -> Dict[str, List[str]]: + """ + Return a `dict` of group name to repo names. + """ + fname = get_config_fname('groups.yml') + groups = {} + # Each line is a repo path and repo name separated by , + 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) + return groups + + + +def get_choices() -> List[Union[str, None]]: + """ + Return all repo names, group names, and an additional empty list. The empty + list is added as a workaround of + argparse's problem with coexisting nargs='*' and choices. + See https://utcc.utoronto.ca/~cks/space/blog/python/ArgparseNargsChoicesLimitation + and + https://bugs.python.org/issue27227 + """ + choices = list(get_repos()) + choices.extend(get_groups()) + choices.append([]) + return choices + + +def is_git(path: str) -> bool: + """ + Return True if the path is a git repo. + """ + # 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. + # 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') + # 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): + """ + Write new repo name to file + """ + path = repos[repo] + del repos[repo] + repos[new_name] = path + write_to_repo_file(repos, 'w') + + +def write_to_repo_file(repos: Dict[str, str], mode: str): + """ + """ + data = ''.join(f'{path},{name}\n' for name, path in repos.items()) + fname = get_config_fname('repo_path') + os.makedirs(os.path.dirname(fname), exist_ok=True) + with open(fname, mode) as f: + f.write(data) + + +def write_to_groups_file(groups: Dict[str, List[str]], mode: str): + """ + + """ + 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]): + """ + Write new repo paths to file + """ + existing_paths = set(repos.values()) + new_paths = set(os.path.abspath(p) for p in new_paths if is_git(p)) + new_paths = new_paths - existing_paths + if new_paths: + print(f"Found {len(new_paths)} new repo(s).") + new_repos = { + os.path.basename(os.path.normpath(path)): path + for path in new_paths} + write_to_repo_file(new_repos, 'a+') + else: + print('No new repos found!') + + +async def run_async(repo_name: str, path: str, cmds: List[str]) -> Union[None, str]: + """ + Run `cmds` asynchronously in `path` directory. Return the `path` if + execution fails. + """ + 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) + stdout, stderr = await process.communicate() + for pipe in (stdout, stderr): + if pipe: + print(format_output(pipe.decode(), f'{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: + return path + + +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)]) + + +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': + loop = asyncio.ProactorEventLoop() + asyncio.set_event_loop(loop) + else: + loop = asyncio.get_event_loop() + + try: + errors = loop.run_until_complete(asyncio.gather(*tasks)) + finally: + loop.close() + return errors + + +def describe(repos: Dict[str, str]) -> 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}' + + +def get_cmds_from_files() -> Dict[str, Dict[str, str]]: + """ + Parse delegated git commands from default config file + and custom config file. + + Example return + { + 'branch': {'help': 'show local branches'}, + 'clean': {'cmd': 'clean -dfx', + 'help': 'remove all untracked files/folders'}, + } + """ + # 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) + + # custom config file + root = common.get_config_dir() + fname = os.path.join(root, 'cmds.yml') + 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) + + # custom commands shadow default ones + cmds.update(custom_cmds) + return cmds diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3e9e127 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +pytest>=4.4.0 +pytest-cov>=2.6.1 +pytest-xdist>=1.26.0 +setuptools>=40.6.3 +twine>=1.12.1 +pyyaml>=5.1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e6e3984 --- /dev/null +++ b/setup.py @@ -0,0 +1,38 @@ +from setuptools import setup + +long_description = None +with open('README.md', encoding='utf-8') as f: + long_description = f.read() + +setup( + name='gita', + packages=['gita'], + version='0.10.9', + license='MIT', + description='Manage multiple git repos', + 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', + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Topic :: Software Development :: Version Control :: Git", + "Topic :: Terminals", + "Topic :: Utilities", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + ], + include_package_data=True, +) diff --git a/tests/clash_path_file b/tests/clash_path_file new file mode 100644 index 0000000..4abbfca --- /dev/null +++ b/tests/clash_path_file @@ -0,0 +1,3 @@ +/a/bcd/repo1,repo1 +/e/fgh/repo2,repo2 +/root/x/repo1,repo1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b3e59ed --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,26 @@ +from pathlib import Path +from unittest.mock import MagicMock + +TEST_DIR = Path(__file__).parents[0] + + +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') + +def async_mock(): + """ + Mock an async function. The calling arguments are saved in a MagicMock. + """ + m = MagicMock() + + async def coro(*args, **kwargs): + return m(*args, **kwargs) + + coro.mock = m + return coro diff --git a/tests/empty_path_file b/tests/empty_path_file new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/empty_path_file diff --git a/tests/mock_group_file b/tests/mock_group_file new file mode 100644 index 0000000..32f0a64 --- /dev/null +++ b/tests/mock_group_file @@ -0,0 +1,2 @@ +xx: [a, b] +yy: [a, c, d] diff --git a/tests/mock_path_file b/tests/mock_path_file new file mode 100644 index 0000000..2a5f9f9 --- /dev/null +++ b/tests/mock_path_file @@ -0,0 +1,4 @@ +/a/bcd/repo1,repo1 +/a/b/c/repo3,xxx +/e/fgh/repo2,repo2 + diff --git a/tests/test_info.py b/tests/test_info.py new file mode 100644 index 0000000..025aedc --- /dev/null +++ b/tests/test_info.py @@ -0,0 +1,16 @@ +import subprocess +from unittest.mock import patch, MagicMock + +from gita import info + + +@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']) + mock_run.assert_called_once_with( + ['git', 'diff', '--quiet', 'my', 'args'], + stderr=subprocess.DEVNULL, + ) + assert got == mock_return.returncode diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..1946352 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,167 @@ +import pytest +from unittest.mock import patch +import argparse +import shlex + +from gita import __main__ +from gita import utils +from conftest import ( + PATH_FNAME, PATH_FNAME_EMPTY, PATH_FNAME_CLASH, GROUP_FNAME, + async_mock +) + + +class TestLsLl: + @patch('gita.utils.get_config_fname') + def testLl(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', '.']) + out, err = capfd.readouterr() + assert err == '' + assert 'Found 1 new repo(s).\n' == out + + # in production this is not needed + utils.get_repos.cache_clear() + + __main__.main(['ls']) + out, err = capfd.readouterr() + assert err == '' + assert 'gita\n' == out + + __main__.main(['ll']) + out, err = capfd.readouterr() + assert err == '' + assert 'gita' in out + + __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']) + out, err = capfd.readouterr() + assert err == '' + assert out == "repo1 repo2\n" + __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 + utils.get_repos.cache_clear() + __main__.main(['ll']) + out, err = capfd.readouterr() + print(out) + 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') +def test_rm(mock_write, *_): + args = argparse.Namespace() + args.repo = ['repo1'] + __main__.f_rm(args) + mock_write.assert_called_once_with({'repo2': '/b/'}, 'w') + + +def test_not_add(): + # this won't write to disk because the repo is not valid + __main__.main(['add', '/home/some/repo/']) + + +@patch('gita.utils.get_repos', return_value={'repo2': '/d/efg'}) +@patch('subprocess.run') +def test_fetch(mock_run, *_): + __main__.main(['fetch']) + mock_run.assert_called_once_with(['git', 'fetch'], cwd='/d/efg') + + +@patch( + 'gita.utils.get_repos', return_value={ + 'repo1': '/a/bc', + 'repo2': '/d/efg' + }) +@patch('gita.utils.run_async', new=async_mock()) +@patch('subprocess.run') +def test_async_fetch(*_): + __main__.main(['fetch']) + mock_run = utils.run_async.mock + assert mock_run.call_count == 2 + 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) + + +@pytest.mark.parametrize('input', [ + 'diff --name-only --staged', + "commit -am 'lala kaka'", +]) +@patch('gita.utils.get_repos', return_value={'repo7': 'path7'}) +@patch('subprocess.run') +def test_superman(mock_run, _, input): + mock_run.reset_mock() + 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) + __main__.main(args) + 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') +def test_rename(mock_rename, _, __): + utils.get_repos.cache_clear() + 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') + + +@patch('os.path.isfile', return_value=False) +def test_info(mock_isfile, capfd): + __main__.f_info(None) + out, err = capfd.readouterr() + assert 'In use: branch,commit_msg\nUnused: path\n' == out + assert err == '' diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..3128041 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,118 @@ +import pytest +import asyncio +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, +) + + +@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'), +]) +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') +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): + 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) +def test_custom_push_cmd(*_): + with patch('builtins.open', + mock_open(read_data='push:\n cmd: hand\n help: me')): + cmds = utils.get_cmds_from_files() + assert cmds['push'] == {'cmd': 'hand', 'help': 'me'} + + +@pytest.mark.parametrize( + '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) +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+') + handle = mock_file() + if type(expected) == str: + handle.write.assert_called_once_with(expected) + else: + handle.write.assert_called_once() + 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') + + +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) + ] + # 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 + # itself uses asyncio (or maybe pytest-xdist)? + asyncio.set_event_loop(asyncio.new_event_loop()) + 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' |