1
0
Fork 0

Adding upstream version 0.10.9.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-11 18:25:06 +01:00
parent c0f23aff1f
commit a9c588f707
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
27 changed files with 1822 additions and 0 deletions

37
.gita-completion.bash Normal file
View file

@ -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

25
.github/workflows/nos.yml vendored Normal file
View file

@ -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

102
.gitignore vendored Normal file
View file

@ -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/

11
.travis.yml Normal file
View file

@ -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)

21
LICENSE Normal file
View file

@ -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.

1
MANIFEST.in Normal file
View file

@ -0,0 +1 @@
include gita/cmds.yml

12
Makefile Normal file
View file

@ -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

230
README.md Normal file
View file

@ -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)

187
doc/README_CN.md Normal file
View file

@ -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)

81
doc/design.md Normal file
View file

@ -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.

BIN
doc/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

3
gita/__init__.py Normal file
View file

@ -0,0 +1,3 @@
import pkg_resources
__version__ = pkg_resources.get_distribution('gita').version

289
gita/__main__.py Normal file
View file

@ -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

65
gita/cmds.yml Normal file
View file

@ -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

7
gita/common.py Normal file
View file

@ -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

146
gita/info.py Normal file
View file

@ -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

225
gita/utils.py Normal file
View file

@ -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

6
requirements.txt Normal file
View file

@ -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

38
setup.py Normal file
View file

@ -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,
)

3
tests/clash_path_file Normal file
View file

@ -0,0 +1,3 @@
/a/bcd/repo1,repo1
/e/fgh/repo2,repo2
/root/x/repo1,repo1

26
tests/conftest.py Normal file
View file

@ -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

0
tests/empty_path_file Normal file
View file

2
tests/mock_group_file Normal file
View file

@ -0,0 +1,2 @@
xx: [a, b]
yy: [a, c, d]

4
tests/mock_path_file Normal file
View file

@ -0,0 +1,4 @@
/a/bcd/repo1,repo1
/a/b/c/repo3,xxx
/e/fgh/repo2,repo2

16
tests/test_info.py Normal file
View file

@ -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

167
tests/test_main.py Normal file
View file

@ -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 == ''

118
tests/test_utils.py Normal file
View file

@ -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'