Adding upstream version 0.15.7.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
d8a18b006a
commit
6204f01115
10 changed files with 324 additions and 206 deletions
73
README.md
73
README.md
|
@ -57,9 +57,11 @@ See the [customization section](#custom).
|
||||||
|
|
||||||
The additional status symbols denote
|
The additional status symbols denote
|
||||||
|
|
||||||
- `+`: staged changes
|
symbol | meaning
|
||||||
- `*`: unstaged changes
|
---|---
|
||||||
- `_`: untracked files/folders
|
`+`| staged changes
|
||||||
|
`*`| unstaged changes
|
||||||
|
`_`| untracked files/folders
|
||||||
|
|
||||||
The bookkeeping sub-commands are
|
The bookkeeping sub-commands are
|
||||||
|
|
||||||
|
@ -67,21 +69,22 @@ The bookkeeping sub-commands are
|
||||||
- `gita add -a <repo-parent-path(s)>`: add repo(s) in <repo-parent-path(s)> recursively
|
- `gita add -a <repo-parent-path(s)>`: add repo(s) in <repo-parent-path(s)> recursively
|
||||||
and automatically generate hierarchical groups. See the [customization section](#custom) for more details.
|
and automatically generate hierarchical groups. See the [customization section](#custom) for more details.
|
||||||
- `gita add -b <bare-repo-path(s)>`: add bare repo(s) to `gita`. See the [customization section](#custom) for more details on setting custom worktree.
|
- `gita add -b <bare-repo-path(s)>`: add bare repo(s) to `gita`. See the [customization section](#custom) for more details on setting custom worktree.
|
||||||
- `gita add -m <main-repo-path(s)>`: add main repo(s) to `gita`. See the [customization section](#custom) for more details.
|
|
||||||
- `gita add -r <repo-parent-path(s)>`: add repo(s) in <repo-parent-path(s)> recursively
|
- `gita add -r <repo-parent-path(s)>`: add repo(s) in <repo-parent-path(s)> recursively
|
||||||
- `gita clone <config-file>`: clone repos in `config-file` (generated by `gita freeze`) to current directory.
|
- `gita clone <config-file>`: clone repos in `config-file` (generated by `gita freeze`) to current directory.
|
||||||
- `gita clone -p <config-file>`: clone repos in `config-file` to prescribed paths.
|
- `gita clone -p <config-file>`: clone repos in `config-file` to prescribed paths.
|
||||||
- `gita context`: context sub-command
|
- `gita context`: context sub-command
|
||||||
- `gita context`: show current context
|
- `gita context`: show current context
|
||||||
- `gita context none`: remove context
|
|
||||||
- `gita context <group-name>`: set context to `group-name`, all operations then only apply to repos in this group
|
- `gita context <group-name>`: set context to `group-name`, all operations then only apply to repos in this group
|
||||||
|
- `gita context auto`: set context automatically according to the current working directory
|
||||||
|
- `gita context none`: remove context
|
||||||
- `gita color`: color sub-command
|
- `gita color`: color sub-command
|
||||||
- `gita color [ll]`: Show available colors and the current coloring scheme
|
- `gita color [ll]`: Show available colors and the current coloring scheme
|
||||||
- `gita color set <situation> <color>`: Use the specified color for the local-remote situation
|
- `gita color set <situation> <color>`: Use the specified color for the local-remote situation
|
||||||
- `gita flags`: flags sub-command
|
- `gita flags`: flags sub-command
|
||||||
- `gita flags set <repo-name> <flags>`: add custom `flags` to repo
|
- `gita flags set <repo-name> <flags>`: add custom `flags` to repo
|
||||||
- `gita flags [ll]`: display repos with custom flags
|
- `gita flags [ll]`: display repos with custom flags
|
||||||
- `gita freeze`: print information of all repos such as URL, name, and path.
|
- `gita freeze`: print information of all repos such as URL, name, and path. Use with
|
||||||
|
`gita clone`.
|
||||||
- `gita group`: group sub-command
|
- `gita group`: group sub-command
|
||||||
- `gita group add <repo-name(s)> -n <group-name>`: add repo(s) to a new or existing group
|
- `gita group add <repo-name(s)> -n <group-name>`: add repo(s) to a new or existing group
|
||||||
- `gita group [ll]`: display existing groups with repos
|
- `gita group [ll]`: display existing groups with repos
|
||||||
|
@ -95,6 +98,7 @@ The bookkeeping sub-commands are
|
||||||
- `gita info rm <info-item>`: disable information item
|
- `gita info rm <info-item>`: disable information item
|
||||||
- `gita ll`: display the status of all repos
|
- `gita ll`: display the status of all repos
|
||||||
- `gita ll <group-name>`: display the status of repos in a group
|
- `gita ll <group-name>`: display the status of repos in a group
|
||||||
|
- `gita ll -g`: display the repo summaries by groups
|
||||||
- `gita ls`: display the names of all repos
|
- `gita ls`: display the names of all repos
|
||||||
- `gita ls <repo-name>`: display the absolute path of one repo
|
- `gita ls <repo-name>`: display the absolute path of one repo
|
||||||
- `gita rename <repo-name> <new-name>`: rename a repo
|
- `gita rename <repo-name> <new-name>`: rename a repo
|
||||||
|
@ -211,6 +215,20 @@ gita ll
|
||||||
gita pull
|
gita pull
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The most useful context maybe `auto`.
|
||||||
|
In this mode, the context is automatically determined from the
|
||||||
|
current working directory (CWD): the context is the group whose member repo's
|
||||||
|
path contains CWD. To set it, run
|
||||||
|
|
||||||
|
```
|
||||||
|
gita context auto
|
||||||
|
```
|
||||||
|
|
||||||
|
To remove the context, run
|
||||||
|
```
|
||||||
|
gita context none
|
||||||
|
```
|
||||||
|
|
||||||
It is also possible to recursively add repos within a directory and
|
It is also possible to recursively add repos within a directory and
|
||||||
generate hierarchical groups automatically. For example, running
|
generate hierarchical groups automatically. For example, running
|
||||||
|
|
||||||
|
@ -229,41 +247,13 @@ src
|
||||||
│ └── repo5
|
│ └── repo5
|
||||||
└── repo6
|
└── repo6
|
||||||
```
|
```
|
||||||
gives rise to
|
gives rise to 3 groups:
|
||||||
```
|
```
|
||||||
src:repo1,repo2,repo3,repo4,repo5,repo6
|
src:repo1,repo2,repo3,repo4,repo5,repo6
|
||||||
src-project1:repo1,repo2
|
src-project1:repo1,repo2
|
||||||
src-project2:repo4,repo5
|
src-project2:repo4,repo5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### define main repos and shadow the global configuration setting with local setting
|
|
||||||
|
|
||||||
The so-called main repos contain `.gita` folder for local configurations.
|
|
||||||
It works best for the repos-within-repo project structure, for example,
|
|
||||||
|
|
||||||
```
|
|
||||||
main-repo
|
|
||||||
├── sub-repo1
|
|
||||||
│ └── sub-sub-repo
|
|
||||||
├── sub-repo2
|
|
||||||
└── sub-repo3
|
|
||||||
```
|
|
||||||
|
|
||||||
When executing `gita` commands within/relative to a main repo, local configurations
|
|
||||||
are used. And only repos within the current main repos are in the scope.
|
|
||||||
|
|
||||||
To add a main repo, run
|
|
||||||
|
|
||||||
```
|
|
||||||
gita add -m main-repo-path
|
|
||||||
```
|
|
||||||
|
|
||||||
Subordinate repos are added recursively to the local configuration.
|
|
||||||
Only the main repo is saved to the global configuration.
|
|
||||||
|
|
||||||
In the `gita ll` display, the main repos are underlined.
|
|
||||||
|
|
||||||
### add user-defined sub-command using json file
|
### add user-defined sub-command using json file
|
||||||
|
|
||||||
Custom delegating sub-commands can be defined in `$XDG_CONFIG_HOME/gita/cmds.json`
|
Custom delegating sub-commands can be defined in `$XDG_CONFIG_HOME/gita/cmds.json`
|
||||||
|
@ -341,21 +331,24 @@ branch,commit_msg,commit_time
|
||||||
|
|
||||||
### customize git command flags
|
### customize git command flags
|
||||||
|
|
||||||
One can set custom flags to run `git` commands. For example
|
One can set custom flags to run `git` commands. For example, with
|
||||||
|
|
||||||
```
|
```
|
||||||
gita flags set my-repo --git-dir=$HOME/somefolder --work-tree=$HOME
|
gita flags set my-repo --git-dir=`gita ls dotfiles` --work-tree=$HOME
|
||||||
```
|
```
|
||||||
|
|
||||||
Then any `git` command/alias triggered from `gita` on `my-repo` will use these flags.
|
any `git` command/alias triggered from `gita` on `dotfiles` will use these flags.
|
||||||
Note that the flags are applied immediately after `git`. For example,
|
Note that the flags are applied immediately after `git`. For example,
|
||||||
`gita st my-repo` translates to
|
`gita st dotfiles` translates to
|
||||||
|
|
||||||
```
|
```
|
||||||
git --git-dir=$HOME/somefolder --work-tree=$HOME status
|
git --git-dir=$HOME/somefolder --work-tree=$HOME status
|
||||||
```
|
```
|
||||||
|
|
||||||
running from the `my-repo` directory.
|
running from the `dotfiles` directory.
|
||||||
|
|
||||||
|
This feature was originally added to deal with
|
||||||
|
[bare repo dotfiles](https://www.atlassian.com/git/tutorials/dotfiles).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
|
@ -26,50 +26,58 @@
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
本地和远程分支之间的关系有5种情况,在这里分别用5种颜色对应着:
|
在这个截屏里,`gita ll`显示所有库的状态信息,`gita remote dotfiles`等价于在
|
||||||
|
`dotfiles`库里运行`git remote -v`,`gita fetch`对所有库做`fetch`操作,在这个例子
|
||||||
|
里,两个库有更新.
|
||||||
|
|
||||||
- 绿色:本地和远程保持一致
|
本地和远程分支之间的5种关系分别用5种颜色对应:
|
||||||
- 红色:本地和远程产生了分叉
|
|
||||||
- 黄色:本地落后于远程(适合合并merge)
|
|
||||||
- 白色:本地没有指定远程
|
|
||||||
- 紫色:本地超前于远程(适合推送push)
|
|
||||||
|
|
||||||
为什么选择了紫色作为超前以及黄色作为落后,绿色作为基准 的理由在这两篇文章中解释:
|
颜色|含义
|
||||||
[blueshift](https://en.wikipedia.org/wiki/Blueshift)、[redshift](https://en.wikipedia.org/wiki/Redshift)
|
----|----
|
||||||
|
绿色|本地和远程保持一致
|
||||||
|
红色|本地和远程产生了分叉
|
||||||
|
黄色|本地落后于远程(适合合并merge)
|
||||||
|
白色|本地没有指定远程
|
||||||
|
紫色|本地超前于远程(适合推送push)
|
||||||
|
|
||||||
|
紫色作为超前,黄色作为落后,绿色作为基准的理由取自蓝移和红移:
|
||||||
|
[blueshift](https://en.wikipedia.org/wiki/Blueshift)、[redshift](https://en.wikipedia.org/wiki/Redshift).
|
||||||
|
|
||||||
额外的状态符号意义:
|
额外的状态符号意义:
|
||||||
|
|
||||||
- `+`: 暂存(staged)
|
- `+`: 暂存(staged)
|
||||||
- `*`: 未暂存(unstaged)
|
- `*`: 未暂存(unstaged)
|
||||||
- `_`: 未追踪(untracked)
|
- `_`: 未追踪(untracked)
|
||||||
|
|
||||||
基础指令:
|
基础指令:
|
||||||
|
|
||||||
- `gita add <repo-path(s)>`: 添加库
|
- `gita add <repo-path(s)>`: 添加库
|
||||||
- `gita add -a <repo-parent-path(s)>`:
|
- `gita add -a <repo-parent-path(s)>`: 递归添加路径下的所有库并自动产生层级分组,见
|
||||||
- `gita add -b <bare-repo-path(s)>`:
|
[customization section](#custom)
|
||||||
- `gita add -m <main-repo-path(s)>`:
|
- `gita add -b <bare-repo-path(s)>`: 添加bare库
|
||||||
- `gita add -r <repo-parent-path(s)>`:
|
- `gita add -m <main-repo-path(s)>`: 添加main库, main库的定义见
|
||||||
- `gita clone <config-file>`:
|
[customization section](#custom)
|
||||||
- `gita clone -p <config-file>`:
|
- `gita add -r <repo-parent-path(s)>`: 递归添加路径下的所有库
|
||||||
|
- `gita clone <config-file>`: 克隆`<config-file>` (由`gita freeze`生成)里的库
|
||||||
|
- `gita clone -p <config-file>`: 克隆`<config-file>`里的库并放到指定路径
|
||||||
- `gita context`: 情境命令
|
- `gita context`: 情境命令
|
||||||
- `gita context`: 显示当前的情境
|
- `gita context`: 显示当前的情境
|
||||||
- `gita context none`: 去除情境
|
- `gita context none`: 去除情境
|
||||||
- `gita context <group-name>`: 把情境设置成`group-name`, 之后所有的操作只作用到这个组里的库
|
- `gita context <group-name>`: 把情境设置成`group-name`, 之后所有的操作只作用到这个组里的库
|
||||||
- `gita color`:
|
- `gita color`:
|
||||||
- `gita color [ll]`:
|
- `gita color [ll]`:显示颜色设置
|
||||||
- `gita color set <situation> <color>`:
|
- `gita color set <situation> <color>`:给本地/远程关系设置颜色
|
||||||
- `gita flags`:
|
- `gita flags`:
|
||||||
- `gita flags set <repo-name> <flags>`:
|
- `gita flags set <repo-name> <flags>`:给库设置flags
|
||||||
- `gita flags [ll]`:
|
- `gita flags [ll]`:显示已有的flags
|
||||||
- `gita freeze`:
|
- `gita freeze`:显示URL, 路径之类的库信息(配合`gita clone`使用)
|
||||||
- `gita group`: 组群命令
|
- `gita group`: 组群命令
|
||||||
- `gita group add <repo-name(s)>`: 把库加入新的或者已经存在的组
|
- `gita group add <repo-name(s)>`: 把库加入新的或者已经存在的组
|
||||||
- `gita group [ll]`: 显示已有的组和它们的库
|
- `gita group [ll]`: 显示已有的组和它们的库
|
||||||
- `gita group ls`: 显示已有的组名
|
- `gita group ls`: 显示已有的组名
|
||||||
- `gita group rename <group-name> <new-name>`: 改组名
|
- `gita group rename <group-name> <new-name>`: 改组名
|
||||||
- `gita group rm group(s): 删除组
|
- `gita group rm group(s): 删除组
|
||||||
- `gita group rmrepo -n <group-name>:
|
- `gita group rmrepo -n <group-name>:删除组里的库
|
||||||
- `gita info`: 显示已用的和未用的信息项
|
- `gita info`: 显示已用的和未用的信息项
|
||||||
- `gita info [ll]`
|
- `gita info [ll]`
|
||||||
- `gita info add <info-item>`
|
- `gita info add <info-item>`
|
||||||
|
@ -97,7 +105,7 @@
|
||||||
|
|
||||||
## 安装指南
|
## 安装指南
|
||||||
|
|
||||||
正常人类按装:
|
正常人类安装:
|
||||||
|
|
||||||
```
|
```
|
||||||
pip3 install -U gita
|
pip3 install -U gita
|
||||||
|
|
139
gita/__main__.py
139
gita/__main__.py
|
@ -19,6 +19,7 @@ import sys
|
||||||
import csv
|
import csv
|
||||||
import argparse
|
import argparse
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from functools import partial
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
@ -27,21 +28,37 @@ import glob
|
||||||
from . import utils, info, common
|
from . import utils, info, common
|
||||||
|
|
||||||
|
|
||||||
def _group_name(name: str) -> str:
|
def _group_name(name: str, exclude_old_names=True) -> str:
|
||||||
"""
|
"""
|
||||||
|
Return valid group name
|
||||||
"""
|
"""
|
||||||
repos = utils.get_repos()
|
repos = utils.get_repos()
|
||||||
if name in repos:
|
if name in repos:
|
||||||
print(f"Cannot use group name {name} since it's a repo name.")
|
print(f"Cannot use group name {name} since it's a repo name.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
if exclude_old_names:
|
||||||
|
if name in utils.get_groups():
|
||||||
|
print(f"Cannot use group name {name} since it's already in use.")
|
||||||
|
sys.exit(1)
|
||||||
|
if name in {'none', 'auto'}:
|
||||||
|
print(f"Cannot use group name {name} since it's a reserved keyword.")
|
||||||
|
sys.exit(1)
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _path_name(name: str) -> str:
|
||||||
|
"""
|
||||||
|
Return absolute path without trailing /
|
||||||
|
"""
|
||||||
|
if name:
|
||||||
|
return os.path.abspath(name).rstrip(os.path.sep)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def f_add(args: argparse.Namespace):
|
def f_add(args: argparse.Namespace):
|
||||||
repos = utils.get_repos()
|
repos = utils.get_repos()
|
||||||
paths = args.paths
|
paths = args.paths
|
||||||
if args.main:
|
if 0:
|
||||||
# add to global and tag as main
|
# add to global and tag as main
|
||||||
main_repos = utils.add_repos(repos, paths, repo_type='m')
|
main_repos = utils.add_repos(repos, paths, repo_type='m')
|
||||||
# add sub-repo recursively and save to local config
|
# add sub-repo recursively and save to local config
|
||||||
|
@ -53,11 +70,11 @@ def f_add(args: argparse.Namespace):
|
||||||
utils.add_repos({}, sub_paths, root=main_path)
|
utils.add_repos({}, sub_paths, root=main_path)
|
||||||
else:
|
else:
|
||||||
if args.recursive or args.auto_group:
|
if args.recursive or args.auto_group:
|
||||||
paths = chain.from_iterable(
|
paths = (p.rstrip(os.path.sep) for p in chain.from_iterable(
|
||||||
glob.glob(os.path.join(p, '**/'), recursive=True)
|
glob.glob(os.path.join(p, '**/'), recursive=True)
|
||||||
for p in args.paths)
|
for p in args.paths))
|
||||||
new_repos = utils.add_repos(repos, paths, is_bare=args.bare)
|
new_repos = utils.add_repos(repos, paths, is_bare=args.bare)
|
||||||
if args.auto_group:
|
if new_repos and args.auto_group:
|
||||||
new_groups = utils.auto_group(new_repos, args.paths)
|
new_groups = utils.auto_group(new_repos, args.paths)
|
||||||
if new_groups:
|
if new_groups:
|
||||||
print(f'Created {len(new_groups)} new group(s).')
|
print(f'Created {len(new_groups)} new group(s).')
|
||||||
|
@ -159,9 +176,22 @@ def f_ll(args: argparse.Namespace):
|
||||||
ctx = utils.get_context()
|
ctx = utils.get_context()
|
||||||
if args.group is None and ctx:
|
if args.group is None and ctx:
|
||||||
args.group = ctx.stem
|
args.group = ctx.stem
|
||||||
|
group_repos = None
|
||||||
if args.group: # only display repos in this group
|
if args.group: # only display repos in this group
|
||||||
group_repos = utils.get_groups()[args.group]
|
group_repos = utils.get_groups()[args.group]['repos']
|
||||||
repos = {k: repos[k] for k in group_repos if k in repos}
|
repos = {k: repos[k] for k in group_repos if k in repos}
|
||||||
|
if args.g: # display by group
|
||||||
|
if group_repos:
|
||||||
|
print(f'{args.group}:')
|
||||||
|
for line in utils.describe(repos, no_colors=args.no_colors):
|
||||||
|
print(' ', line)
|
||||||
|
else:
|
||||||
|
for g, g_repos in utils.get_groups().items():
|
||||||
|
print(f'{g}:')
|
||||||
|
g_repos = {k: repos[k] for k in g_repos if k in repos}
|
||||||
|
for line in utils.describe(g_repos, no_colors=args.no_colors):
|
||||||
|
print(' ', line)
|
||||||
|
else:
|
||||||
for line in utils.describe(repos, no_colors=args.no_colors):
|
for line in utils.describe(repos, no_colors=args.no_colors):
|
||||||
print(line)
|
print(line)
|
||||||
|
|
||||||
|
@ -180,69 +210,73 @@ def f_group(args: argparse.Namespace):
|
||||||
if cmd == 'll':
|
if cmd == 'll':
|
||||||
if 'to_show' in args and args.to_show:
|
if 'to_show' in args and args.to_show:
|
||||||
gname = args.to_show
|
gname = args.to_show
|
||||||
print(' '.join(groups[gname]))
|
print(' '.join(groups[gname]['repos']))
|
||||||
else:
|
else:
|
||||||
for group, repos in groups.items():
|
for group, prop in groups.items():
|
||||||
print(f"{group}: {' '.join(repos)}")
|
print(f"{info.Color.underline}{group}{info.Color.end}: {prop['path']}")
|
||||||
|
for r in prop['repos']:
|
||||||
|
print(' -', r)
|
||||||
elif cmd == 'ls':
|
elif cmd == 'ls':
|
||||||
print(' '.join(groups))
|
print(' '.join(groups))
|
||||||
elif cmd == 'rename':
|
elif cmd == 'rename':
|
||||||
new_name = args.new_name
|
new_name = args.new_name
|
||||||
if new_name in groups:
|
|
||||||
sys.exit(f'{new_name} already exists.')
|
|
||||||
gname = args.gname
|
gname = args.gname
|
||||||
groups[new_name] = groups[gname]
|
groups[new_name] = groups[gname]
|
||||||
del groups[gname]
|
del groups[gname]
|
||||||
utils.write_to_groups_file(groups, 'w')
|
utils.write_to_groups_file(groups, 'w')
|
||||||
# change context
|
# change context
|
||||||
ctx = utils.get_context()
|
ctx = utils.get_context()
|
||||||
if ctx and str(ctx.stem) == gname:
|
if ctx and ctx.stem == gname:
|
||||||
# ctx.rename(ctx.with_stem(new_name)) # only works in py3.9
|
utils.replace_context(ctx, new_name)
|
||||||
ctx.rename(ctx.with_name(f'{new_name}.context'))
|
|
||||||
elif cmd == 'rm':
|
elif cmd == 'rm':
|
||||||
ctx = utils.get_context()
|
ctx = utils.get_context()
|
||||||
for name in args.to_ungroup:
|
for name in args.to_ungroup:
|
||||||
del groups[name]
|
del groups[name]
|
||||||
if ctx and str(ctx.stem) == name:
|
if ctx and str(ctx.stem) == name:
|
||||||
ctx.unlink()
|
utils.replace_context(ctx, '')
|
||||||
utils.write_to_groups_file(groups, 'w')
|
utils.write_to_groups_file(groups, 'w')
|
||||||
elif cmd == 'add':
|
elif cmd == 'add':
|
||||||
gname = args.gname
|
gname = args.gname
|
||||||
if gname in groups:
|
if gname in groups:
|
||||||
gname_repos = set(groups[gname])
|
gname_repos = set(groups[gname]['repos'])
|
||||||
gname_repos.update(args.to_group)
|
gname_repos.update(args.to_group)
|
||||||
groups[gname] = sorted(gname_repos)
|
groups[gname]['repos'] = sorted(gname_repos)
|
||||||
|
if 'gpath' in args:
|
||||||
|
groups[gname]['path'] = args.gpath
|
||||||
utils.write_to_groups_file(groups, 'w')
|
utils.write_to_groups_file(groups, 'w')
|
||||||
else:
|
else:
|
||||||
utils.write_to_groups_file({gname: sorted(args.to_group)}, 'a+')
|
gpath = ''
|
||||||
|
if 'gpath' in args:
|
||||||
|
gpath = args.gpath
|
||||||
|
utils.write_to_groups_file(
|
||||||
|
{gname: {'repos': sorted(args.to_group),
|
||||||
|
'path': gpath}},
|
||||||
|
'a+')
|
||||||
elif cmd == 'rmrepo':
|
elif cmd == 'rmrepo':
|
||||||
gname = args.gname
|
gname = args.gname
|
||||||
if gname in groups:
|
if gname in groups:
|
||||||
for repo in args.from_group:
|
group = {gname: {'repos': groups[gname]['repos'],
|
||||||
try:
|
'path': groups[gname]['path']
|
||||||
groups[gname].remove(repo)
|
}}
|
||||||
except ValueError as e:
|
for repo in args.to_rm:
|
||||||
pass
|
utils.delete_repo_from_groups(repo, group)
|
||||||
|
groups[gname] = group[gname]
|
||||||
utils.write_to_groups_file(groups, 'w')
|
utils.write_to_groups_file(groups, 'w')
|
||||||
|
|
||||||
|
|
||||||
def f_context(args: argparse.Namespace):
|
def f_context(args: argparse.Namespace):
|
||||||
choice = args.choice
|
choice = args.choice
|
||||||
ctx = utils.get_context()
|
ctx = utils.get_context()
|
||||||
if choice is None:
|
if choice is None: # display current context
|
||||||
if ctx:
|
if ctx:
|
||||||
group = ctx.stem
|
group = ctx.stem
|
||||||
print(f"{group}: {' '.join(utils.get_groups()[group])}")
|
print(f"{group}: {' '.join(utils.get_groups()[group]['repos'])}")
|
||||||
|
elif (Path(common.get_config_dir()) / 'auto.context').exists():
|
||||||
|
print('auto: none detected!')
|
||||||
else:
|
else:
|
||||||
print('Context is not set')
|
print('Context is not set')
|
||||||
elif choice == 'none': # remove context
|
|
||||||
ctx and ctx.unlink()
|
|
||||||
else: # set context
|
else: # set context
|
||||||
fname = Path(common.get_config_dir()) / (choice + '.context')
|
utils.replace_context(ctx, choice)
|
||||||
if ctx:
|
|
||||||
ctx.rename(fname)
|
|
||||||
else:
|
|
||||||
open(fname, 'w').close()
|
|
||||||
|
|
||||||
|
|
||||||
def f_rm(args: argparse.Namespace):
|
def f_rm(args: argparse.Namespace):
|
||||||
|
@ -255,12 +289,19 @@ def f_rm(args: argparse.Namespace):
|
||||||
main_paths = [prop['path'] for prop in repos.values() if prop['type'] == 'm']
|
main_paths = [prop['path'] for prop in repos.values() if prop['type'] == 'm']
|
||||||
# TODO: add test case to delete main repo from main repo
|
# TODO: add test case to delete main repo from main repo
|
||||||
# only local setting should be affected instead of the global one
|
# only local setting should be affected instead of the global one
|
||||||
|
group_updated = False
|
||||||
for repo in args.repo:
|
for repo in args.repo:
|
||||||
del repos[repo]
|
del repos[repo]
|
||||||
|
groups = utils.get_groups()
|
||||||
|
group_updated = group_updated or utils.delete_repo_from_groups(repo, groups)
|
||||||
|
if group_updated:
|
||||||
|
utils.write_to_groups_file(groups, 'w')
|
||||||
|
|
||||||
# If cwd is relative to any main repo, write to local config
|
# If cwd is relative to any main repo, write to local config
|
||||||
cwd = os.getcwd()
|
cwd = os.getcwd()
|
||||||
|
# TODO: delete main path mechanism
|
||||||
for p in main_paths:
|
for p in main_paths:
|
||||||
if utils.is_relative_to(cwd, p):
|
if utils.get_relative_path(cwd, p) is not None:
|
||||||
utils.write_to_repo_file(repos, 'w', p)
|
utils.write_to_repo_file(repos, 'w', p)
|
||||||
break
|
break
|
||||||
else: # global config
|
else: # global config
|
||||||
|
@ -283,7 +324,7 @@ def f_git_cmd(args: argparse.Namespace):
|
||||||
if k in repos:
|
if k in repos:
|
||||||
chosen[k] = repos[k]
|
chosen[k] = repos[k]
|
||||||
if k in groups:
|
if k in groups:
|
||||||
for r in groups[k]:
|
for r in groups[k]['repos']:
|
||||||
chosen[r] = repos[r]
|
chosen[r] = repos[r]
|
||||||
repos = chosen
|
repos = chosen
|
||||||
per_repo_cmds = []
|
per_repo_cmds = []
|
||||||
|
@ -343,7 +384,6 @@ def f_shell(args):
|
||||||
chosen[r] = repos[r]
|
chosen[r] = repos[r]
|
||||||
repos = chosen
|
repos = chosen
|
||||||
cmds = ' '.join(args.man[i:]) # join the shell command into a single string
|
cmds = ' '.join(args.man[i:]) # join the shell command into a single string
|
||||||
#cmds = args.man[i:]
|
|
||||||
for name, prop in repos.items():
|
for name, prop in repos.items():
|
||||||
# TODO: pull this out as a function
|
# TODO: pull this out as a function
|
||||||
got = subprocess.run(cmds, cwd=prop['path'], shell=True,
|
got = subprocess.run(cmds, cwd=prop['path'], shell=True,
|
||||||
|
@ -387,12 +427,10 @@ def main(argv=None):
|
||||||
# bookkeeping sub-commands
|
# bookkeeping sub-commands
|
||||||
p_add = subparsers.add_parser('add', description='add repo(s)',
|
p_add = subparsers.add_parser('add', description='add repo(s)',
|
||||||
help='add repo(s)')
|
help='add repo(s)')
|
||||||
p_add.add_argument('paths', nargs='+', type=os.path.abspath, help="repo(s) to add")
|
p_add.add_argument('paths', nargs='+', type=_path_name, help="repo(s) to add")
|
||||||
xgroup = p_add.add_mutually_exclusive_group()
|
xgroup = p_add.add_mutually_exclusive_group()
|
||||||
xgroup.add_argument('-r', '--recursive', action='store_true',
|
xgroup.add_argument('-r', '--recursive', action='store_true',
|
||||||
help="recursively add repo(s) in the given path(s).")
|
help="recursively add repo(s) in the given path(s).")
|
||||||
xgroup.add_argument('-m', '--main', action='store_true',
|
|
||||||
help="make main repo(s), sub-repos are recursively added.")
|
|
||||||
xgroup.add_argument('-a', '--auto-group', action='store_true',
|
xgroup.add_argument('-a', '--auto-group', action='store_true',
|
||||||
help="recursively add repo(s) in the given path(s) "
|
help="recursively add repo(s) in the given path(s) "
|
||||||
"and create hierarchical groups based on folder structure.")
|
"and create hierarchical groups based on folder structure.")
|
||||||
|
@ -504,6 +542,8 @@ def main(argv=None):
|
||||||
help="show repos in the chosen group")
|
help="show repos in the chosen group")
|
||||||
p_ll.add_argument('-C', '--no-colors', action='store_true',
|
p_ll.add_argument('-C', '--no-colors', action='store_true',
|
||||||
help='Disable coloring on the branch names.')
|
help='Disable coloring on the branch names.')
|
||||||
|
p_ll.add_argument('-g', action='store_true',
|
||||||
|
help='Show repo summaries by group.')
|
||||||
p_ll.set_defaults(func=f_ll)
|
p_ll.set_defaults(func=f_ll)
|
||||||
|
|
||||||
p_context = subparsers.add_parser('context',
|
p_context = subparsers.add_parser('context',
|
||||||
|
@ -512,8 +552,12 @@ def main(argv=None):
|
||||||
' When set, all operations apply only to repos in that group.')
|
' When set, all operations apply only to repos in that group.')
|
||||||
p_context.add_argument('choice',
|
p_context.add_argument('choice',
|
||||||
nargs='?',
|
nargs='?',
|
||||||
choices=set().union(utils.get_groups(), {'none'}),
|
choices=set().union(utils.get_groups(), {'none', 'auto'}),
|
||||||
help="Without argument, show current context. Otherwise choose a group as context. To remove context, use 'none'. ")
|
help="Without this argument, show current context. "
|
||||||
|
"Otherwise choose a group as context, or use 'auto', "
|
||||||
|
"which sets the context/group automatically based on "
|
||||||
|
"the current working directory. "
|
||||||
|
"To remove context, use 'none'. ")
|
||||||
p_context.set_defaults(func=f_context)
|
p_context.set_defaults(func=f_context)
|
||||||
|
|
||||||
p_ls = subparsers.add_parser(
|
p_ls = subparsers.add_parser(
|
||||||
|
@ -545,12 +589,16 @@ def main(argv=None):
|
||||||
help="repo(s) to be grouped")
|
help="repo(s) to be grouped")
|
||||||
pg_add.add_argument('-n', '--name',
|
pg_add.add_argument('-n', '--name',
|
||||||
dest='gname',
|
dest='gname',
|
||||||
type=_group_name,
|
type=partial(_group_name, exclude_old_names=False),
|
||||||
metavar='group-name',
|
metavar='group-name',
|
||||||
required=True,
|
required=True)
|
||||||
help="group name")
|
pg_add.add_argument('-p', '--path',
|
||||||
|
dest='gpath',
|
||||||
|
type=_path_name,
|
||||||
|
metavar='group-path')
|
||||||
|
|
||||||
pg_rmrepo = group_cmds.add_parser('rmrepo', description='remove repo(s) from a group.')
|
pg_rmrepo = group_cmds.add_parser('rmrepo', description='remove repo(s) from a group.')
|
||||||
pg_rmrepo.add_argument('from_group',
|
pg_rmrepo.add_argument('to_rm',
|
||||||
nargs='+',
|
nargs='+',
|
||||||
metavar='repo',
|
metavar='repo',
|
||||||
choices=utils.get_repos(),
|
choices=utils.get_repos(),
|
||||||
|
@ -641,6 +689,5 @@ def main(argv=None):
|
||||||
else:
|
else:
|
||||||
p.print_help() # pragma: no cover
|
p.print_help() # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main() # pragma: no cover
|
main() # pragma: no cover
|
||||||
|
|
18
gita/info.py
18
gita/info.py
|
@ -1,6 +1,5 @@
|
||||||
import os
|
import os
|
||||||
import csv
|
import csv
|
||||||
import yaml
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
@ -34,6 +33,15 @@ class Color(str, Enum):
|
||||||
underline = '\x1B[4m'
|
underline = '\x1B[4m'
|
||||||
|
|
||||||
|
|
||||||
|
default_colors = {
|
||||||
|
'no-remote': Color.white.name,
|
||||||
|
'in-sync': Color.green.name,
|
||||||
|
'diverged': Color.red.name,
|
||||||
|
'local-ahead': Color.purple.name,
|
||||||
|
'remote-ahead': Color.yellow.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def show_colors(): # pragma: no cover
|
def show_colors(): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -61,13 +69,7 @@ def get_color_encoding() -> Dict[str, str]:
|
||||||
reader = csv.DictReader(f)
|
reader = csv.DictReader(f)
|
||||||
colors = next(reader)
|
colors = next(reader)
|
||||||
else:
|
else:
|
||||||
colors = {
|
colors = default_colors
|
||||||
'no-remote': Color.white.name,
|
|
||||||
'in-sync': Color.green.name,
|
|
||||||
'diverged': Color.red.name,
|
|
||||||
'local-ahead': Color.purple.name,
|
|
||||||
'remote-ahead': Color.yellow.name,
|
|
||||||
}
|
|
||||||
return colors
|
return colors
|
||||||
|
|
||||||
|
|
||||||
|
|
148
gita/utils.py
148
gita/utils.py
|
@ -1,3 +1,4 @@
|
||||||
|
import sys
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import csv
|
import csv
|
||||||
|
@ -13,12 +14,27 @@ from . import info
|
||||||
from . import common
|
from . import common
|
||||||
|
|
||||||
|
|
||||||
# TODO: python3.9 pathlib has is_relative_to() function
|
MAX_INT = sys.maxsize
|
||||||
def is_relative_to(kid: str, parent: str) -> bool:
|
|
||||||
|
|
||||||
|
def get_relative_path(kid: str, parent: str) -> Union[List[str], None]:
|
||||||
"""
|
"""
|
||||||
Both the `kid` and `parent` should be absolute path
|
Return the relative path depth if relative, otherwise MAX_INT.
|
||||||
|
|
||||||
|
Both the `kid` and `parent` should be absolute paths without trailing /
|
||||||
"""
|
"""
|
||||||
return parent == os.path.commonpath((kid, parent))
|
# Note that os.path.commonpath has no trailing /
|
||||||
|
# TODO: python3.9 pathlib has is_relative_to() function
|
||||||
|
# TODO: Maybe use os.path.commonprefix? since it's faster?
|
||||||
|
if parent == '':
|
||||||
|
return None
|
||||||
|
if parent == os.path.commonpath((kid, parent)):
|
||||||
|
rel = os.path.normpath(os.path.relpath(kid, parent)).split(os.sep)
|
||||||
|
if rel == ['.']:
|
||||||
|
rel = []
|
||||||
|
return rel
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
|
@ -43,7 +59,7 @@ def get_repos(root=None) -> Dict[str, Dict[str, str]]:
|
||||||
cwd = os.getcwd()
|
cwd = os.getcwd()
|
||||||
for prop in repos.values():
|
for prop in repos.values():
|
||||||
path = prop['path']
|
path = prop['path']
|
||||||
if prop['type'] == 'm' and is_relative_to(cwd, path):
|
if prop['type'] == 'm' and get_relative_path(cwd, path) != MAX_INT:
|
||||||
return get_repos(path)
|
return get_repos(path)
|
||||||
return repos
|
return repos
|
||||||
|
|
||||||
|
@ -51,29 +67,94 @@ def get_repos(root=None) -> Dict[str, Dict[str, str]]:
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
def get_context() -> Union[Path, None]:
|
def get_context() -> Union[Path, None]:
|
||||||
"""
|
"""
|
||||||
Return the context: either a group name or 'none'
|
Return context file path, or None if not set. Note that if in auto context
|
||||||
|
mode, the return value is not auto.context but the resolved context,
|
||||||
|
which could be None.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
config_dir = Path(common.get_config_dir())
|
config_dir = Path(common.get_config_dir())
|
||||||
matches = list(config_dir.glob('*.context'))
|
matches = list(config_dir.glob('*.context'))
|
||||||
assert len(matches) < 2, "Cannot have multiple .context file"
|
if len(matches) > 1:
|
||||||
return matches[0] if matches else None
|
print("Cannot have multiple .context file")
|
||||||
|
sys.exit(1)
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
ctx = matches[0]
|
||||||
|
if ctx.stem == 'auto':
|
||||||
|
cwd = str(Path.cwd())
|
||||||
|
repos = get_repos()
|
||||||
|
# The context is set to be the group with minimal distance to cwd
|
||||||
|
candidate = None
|
||||||
|
min_dist = MAX_INT
|
||||||
|
for gname, prop in get_groups().items():
|
||||||
|
rel = get_relative_path(cwd, prop['path'])
|
||||||
|
if rel is None:
|
||||||
|
continue
|
||||||
|
d = len(rel)
|
||||||
|
if d < min_dist:
|
||||||
|
candidate = gname
|
||||||
|
min_dist = d
|
||||||
|
if not candidate:
|
||||||
|
ctx = None
|
||||||
|
else:
|
||||||
|
ctx = ctx.with_name(f'{candidate}.context')
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
def get_groups() -> Dict[str, List[str]]:
|
def get_groups() -> Dict[str, Dict]:
|
||||||
"""
|
"""
|
||||||
Return a `dict` of group name to repo names.
|
Return a `dict` of group name to group properties such as repo names and
|
||||||
|
group path.
|
||||||
"""
|
"""
|
||||||
fname = common.get_config_fname('groups.csv')
|
fname = common.get_config_fname('groups.csv')
|
||||||
groups = {}
|
groups = {}
|
||||||
# Each line is a repo path and repo name separated by ,
|
# Each line is: group-name:repo1 repo2 repo3:group-path
|
||||||
if os.path.isfile(fname) and os.stat(fname).st_size > 0:
|
if os.path.isfile(fname) and os.stat(fname).st_size > 0:
|
||||||
with open(fname, 'r') as f:
|
with open(fname, 'r') as f:
|
||||||
rows = csv.reader(f, delimiter=':')
|
rows = csv.DictReader(f, ['name', 'repos', 'path'],
|
||||||
groups = {r[0]: r[1].split() for r in rows}
|
restval='', delimiter=':')
|
||||||
|
groups = {
|
||||||
|
r['name']: {
|
||||||
|
'repos': r['repos'].split(),
|
||||||
|
'path': r['path']
|
||||||
|
}
|
||||||
|
for r in rows}
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def delete_repo_from_groups(repo: str, groups: Dict[str, Dict]) -> bool:
|
||||||
|
"""
|
||||||
|
Delete repo from groups
|
||||||
|
"""
|
||||||
|
deleted = False
|
||||||
|
for name in groups:
|
||||||
|
try:
|
||||||
|
groups[name]['repos'].remove(repo)
|
||||||
|
except ValueError as e:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
deleted = True
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def replace_context(old: Union[Path, None], new: str):
|
||||||
|
"""
|
||||||
|
|
||||||
|
"""
|
||||||
|
auto = Path(common.get_config_dir()) / 'auto.context'
|
||||||
|
if auto.exists():
|
||||||
|
old = auto
|
||||||
|
|
||||||
|
if new == 'none': # delete
|
||||||
|
old and old.unlink()
|
||||||
|
elif old:
|
||||||
|
# ctx.rename(ctx.with_stem(new_name)) # only works in py3.9
|
||||||
|
old.rename(old.with_name(f'{new}.context'))
|
||||||
|
else:
|
||||||
|
open(auto.with_name(f'{new}.context'), 'w').close()
|
||||||
|
|
||||||
|
|
||||||
def get_choices() -> List[Union[str, None]]:
|
def get_choices() -> List[Union[str, None]]:
|
||||||
"""
|
"""
|
||||||
Return all repo names, group names, and an additional empty list. The empty
|
Return all repo names, group names, and an additional empty list. The empty
|
||||||
|
@ -117,6 +198,7 @@ def is_git(path: str, is_bare=False) -> bool:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def rename_repo(repos: Dict[str, Dict[str, str]], repo: str, new_name: str):
|
def rename_repo(repos: Dict[str, Dict[str, str]], repo: str, new_name: str):
|
||||||
"""
|
"""
|
||||||
Write new repo name to file
|
Write new repo name to file
|
||||||
|
@ -131,8 +213,9 @@ def rename_repo(repos: Dict[str, Dict[str, str]], repo: str, new_name: str):
|
||||||
main_paths = (prop['path'] for prop in repos.values() if prop['type'] == 'm')
|
main_paths = (prop['path'] for prop in repos.values() if prop['type'] == 'm')
|
||||||
cwd = os.getcwd()
|
cwd = os.getcwd()
|
||||||
is_local_config = True
|
is_local_config = True
|
||||||
|
# TODO: delete
|
||||||
for p in main_paths:
|
for p in main_paths:
|
||||||
if is_relative_to(cwd, p):
|
if get_relative_path(cwd, p) != MAX_INT:
|
||||||
write_to_repo_file(repos, 'w', p)
|
write_to_repo_file(repos, 'w', p)
|
||||||
break
|
break
|
||||||
else: # global config
|
else: # global config
|
||||||
|
@ -163,7 +246,8 @@ def write_to_repo_file(repos: Dict[str, Dict[str, str]], mode: str, root=None):
|
||||||
writer.writerows(data)
|
writer.writerows(data)
|
||||||
|
|
||||||
|
|
||||||
def write_to_groups_file(groups: Dict[str, List[str]], mode: str):
|
# TODO: combine with the repo writer
|
||||||
|
def write_to_groups_file(groups: Dict[str, Dict], mode: str):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
@ -174,8 +258,8 @@ def write_to_groups_file(groups: Dict[str, List[str]], mode: str):
|
||||||
else:
|
else:
|
||||||
with open(fname, mode, newline='') as f:
|
with open(fname, mode, newline='') as f:
|
||||||
data = [
|
data = [
|
||||||
(group, ' '.join(repos))
|
(group, ' '.join(prop['repos']), prop['path'])
|
||||||
for group, repos in groups.items()
|
for group, prop in groups.items()
|
||||||
]
|
]
|
||||||
writer = csv.writer(f, delimiter=':', quotechar='"', quoting=csv.QUOTE_MINIMAL)
|
writer = csv.writer(f, delimiter=':', quotechar='"', quoting=csv.QUOTE_MINIMAL)
|
||||||
writer.writerows(data)
|
writer.writerows(data)
|
||||||
|
@ -191,11 +275,13 @@ def _make_name(path: str, repos: Dict[str, Dict[str, str]],
|
||||||
"""
|
"""
|
||||||
name = os.path.basename(os.path.normpath(path))
|
name = os.path.basename(os.path.normpath(path))
|
||||||
if name in repos or name_counts[name] > 1:
|
if name in repos or name_counts[name] > 1:
|
||||||
|
# path has no trailing /
|
||||||
par_name = os.path.basename(os.path.dirname(path))
|
par_name = os.path.basename(os.path.dirname(path))
|
||||||
return os.path.join(par_name, name)
|
return os.path.join(par_name, name)
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: delete
|
||||||
def _get_repo_type(path, repo_type, root) -> str:
|
def _get_repo_type(path, repo_type, root) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -236,37 +322,45 @@ def add_repos(repos: Dict[str, Dict[str, str]], new_paths: List[str],
|
||||||
return new_repos
|
return new_repos
|
||||||
|
|
||||||
|
|
||||||
def _generate_dir_hash(repo_path: str, paths: List[str]) -> Tuple[str, ...]:
|
def _generate_dir_hash(repo_path: str, paths: List[str]) -> Tuple[
|
||||||
|
Tuple[str, ...], str]:
|
||||||
"""
|
"""
|
||||||
Return relative parent strings
|
Return relative parent strings, and the parent head string
|
||||||
|
|
||||||
For example, if `repo_path` is /a/b/c/d/here, and one of `paths` is /a/b/
|
For example, if `repo_path` is /a/b/c/d/here, and one of `paths` is /a/b/
|
||||||
then return (b, c, d)
|
then return (b, c, d)
|
||||||
"""
|
"""
|
||||||
for p in paths:
|
for p in paths:
|
||||||
if is_relative_to(repo_path, p):
|
rel = get_relative_path(repo_path, p)[:-1]
|
||||||
|
if rel is not None:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
return ()
|
return (), ''
|
||||||
return (os.path.basename(p),
|
head, tail = os.path.split(p)
|
||||||
*os.path.normpath(os.path.relpath(repo_path, p)).split(os.sep)[:-1])
|
return (tail, *rel), head
|
||||||
|
|
||||||
|
|
||||||
def auto_group(repos: Dict[str, Dict[str, str]], paths: List[str]
|
def auto_group(repos: Dict[str, Dict[str, str]], paths: List[str]
|
||||||
) -> Dict[str, List[str]]:
|
) -> Dict[str, Dict]:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@params repos: repos to be grouped
|
||||||
"""
|
"""
|
||||||
# FIXME: the upstream code should make sure that paths are all independent
|
# FIXME: the upstream code should make sure that paths are all independent
|
||||||
# i.e., each repo should be contained in one and only one path
|
# i.e., each repo should be contained in one and only one path
|
||||||
new_groups = defaultdict(list)
|
new_groups = defaultdict(dict)
|
||||||
for repo_name, prop in repos.items():
|
for repo_name, prop in repos.items():
|
||||||
hash = _generate_dir_hash(prop['path'], paths)
|
hash, head = _generate_dir_hash(prop['path'], paths)
|
||||||
if not hash:
|
if not hash:
|
||||||
continue
|
continue
|
||||||
for i in range(1, len(hash)+1):
|
for i in range(1, len(hash)+1):
|
||||||
group_name = '-'.join(hash[:i])
|
group_name = '-'.join(hash[:i])
|
||||||
new_groups[group_name].append(repo_name)
|
prop = new_groups[group_name]
|
||||||
|
prop['path'] = os.path.join(head, *hash[:i])
|
||||||
|
if 'repos' not in prop:
|
||||||
|
prop['repos'] = [repo_name]
|
||||||
|
else:
|
||||||
|
prop['repos'].append(repo_name)
|
||||||
# FIXME: need to make sure the new group names don't clash with old ones
|
# FIXME: need to make sure the new group names don't clash with old ones
|
||||||
# or repo names
|
# or repo names
|
||||||
return new_groups
|
return new_groups
|
||||||
|
|
|
@ -3,4 +3,3 @@ pytest-cov>=2.6.1
|
||||||
pytest-xdist>=2.1.0
|
pytest-xdist>=2.1.0
|
||||||
setuptools>=40.6.3
|
setuptools>=40.6.3
|
||||||
twine>=1.12.1
|
twine>=1.12.1
|
||||||
pyyaml>=5.1
|
|
||||||
|
|
4
setup.py
4
setup.py
|
@ -7,14 +7,14 @@ with open('README.md', encoding='utf-8') as f:
|
||||||
setup(
|
setup(
|
||||||
name='gita',
|
name='gita',
|
||||||
packages=['gita'],
|
packages=['gita'],
|
||||||
version='0.15.2',
|
version='0.15.7',
|
||||||
license='MIT',
|
license='MIT',
|
||||||
description='Manage multiple git repos with sanity',
|
description='Manage multiple git repos with sanity',
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
long_description_content_type='text/markdown',
|
long_description_content_type='text/markdown',
|
||||||
url='https://github.com/nosarthur/gita',
|
url='https://github.com/nosarthur/gita',
|
||||||
platforms=['linux', 'osx', 'win32'],
|
platforms=['linux', 'osx', 'win32'],
|
||||||
keywords=['git', 'manage multiple repositories'],
|
keywords=['git', 'manage multiple repositories', 'cui', 'command-line'],
|
||||||
author='Dong Zhou',
|
author='Dong Zhou',
|
||||||
author_email='zhou.dong@gmail.com',
|
author_email='zhou.dong@gmail.com',
|
||||||
entry_points={'console_scripts': ['gita = gita.__main__:main']},
|
entry_points={'console_scripts': ['gita = gita.__main__:main']},
|
||||||
|
|
|
@ -26,7 +26,6 @@ class TestAdd:
|
||||||
|
|
||||||
@pytest.mark.parametrize('input, expected', [
|
@pytest.mark.parametrize('input, expected', [
|
||||||
(['add', '.'], ''),
|
(['add', '.'], ''),
|
||||||
(['add', '-m', '.'], 'm'),
|
|
||||||
])
|
])
|
||||||
@patch('gita.common.get_config_fname')
|
@patch('gita.common.get_config_fname')
|
||||||
def test_add(self, mock_path_fname, tmp_path, input, expected):
|
def test_add(self, mock_path_fname, tmp_path, input, expected):
|
||||||
|
@ -40,35 +39,6 @@ class TestAdd:
|
||||||
assert len(got) == 1
|
assert len(got) == 1
|
||||||
assert got['gita']['type'] == expected
|
assert got['gita']['type'] == expected
|
||||||
|
|
||||||
@patch('gita.utils.is_git', return_value=True)
|
|
||||||
def test_add_main(self, _, tmp_path, monkeypatch, tmpdir):
|
|
||||||
def side_effect(root=None):
|
|
||||||
if root is None:
|
|
||||||
return os.path.join(tmp_path, "gita")
|
|
||||||
else:
|
|
||||||
return os.path.join(root, ".gita")
|
|
||||||
|
|
||||||
def desc(repos, **_):
|
|
||||||
print(len(repos), repos.keys())
|
|
||||||
assert len(repos) > 0
|
|
||||||
for r, prop in repos.items():
|
|
||||||
if prop['type'] == 'm':
|
|
||||||
assert 'test_add_main' in r
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
assert 0, 'no main repo found'
|
|
||||||
return ''
|
|
||||||
|
|
||||||
monkeypatch.setattr(common, 'get_config_dir', side_effect)
|
|
||||||
monkeypatch.setattr(utils, 'describe', desc)
|
|
||||||
|
|
||||||
utils.get_repos.cache_clear()
|
|
||||||
|
|
||||||
with tmpdir.as_cwd():
|
|
||||||
__main__.main(['add', '-m', '.'])
|
|
||||||
utils.get_repos.cache_clear()
|
|
||||||
__main__.main(['ll'])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('path_fname, expected', [
|
@pytest.mark.parametrize('path_fname, expected', [
|
||||||
(PATH_FNAME, ''),
|
(PATH_FNAME, ''),
|
||||||
|
@ -293,14 +263,14 @@ def test_shell(mock_run, _, input):
|
||||||
class TestContext:
|
class TestContext:
|
||||||
|
|
||||||
@patch('gita.utils.get_context', return_value=None)
|
@patch('gita.utils.get_context', return_value=None)
|
||||||
def testDisplayNoContext(self, _, capfd):
|
def test_display_no_context(self, _, capfd):
|
||||||
__main__.main(['context'])
|
__main__.main(['context'])
|
||||||
out, err = capfd.readouterr()
|
out, err = capfd.readouterr()
|
||||||
assert err == ''
|
assert err == ''
|
||||||
assert 'Context is not set\n' == out
|
assert 'Context is not set\n' == out
|
||||||
|
|
||||||
@patch('gita.utils.get_context', return_value=Path('gname.context'))
|
@patch('gita.utils.get_context', return_value=Path('gname.context'))
|
||||||
@patch('gita.utils.get_groups', return_value={'gname': ['a', 'b']})
|
@patch('gita.utils.get_groups', return_value={'gname': {'repos': ['a', 'b']}})
|
||||||
def test_display_context(self, _, __, capfd):
|
def test_display_context(self, _, __, capfd):
|
||||||
__main__.main(['context'])
|
__main__.main(['context'])
|
||||||
out, err = capfd.readouterr()
|
out, err = capfd.readouterr()
|
||||||
|
@ -353,7 +323,7 @@ class TestGroupCmd:
|
||||||
__main__.f_group(args)
|
__main__.f_group(args)
|
||||||
out, err = capfd.readouterr()
|
out, err = capfd.readouterr()
|
||||||
assert err == ''
|
assert err == ''
|
||||||
assert 'xx: a b\nyy: a c d\n' == out
|
assert out == '\x1b[4mxx\x1b[0m: \n - a\n - b\n\x1b[4myy\x1b[0m: \n - a\n - c\n - d\n'
|
||||||
|
|
||||||
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
|
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
|
||||||
def test_ll_with_group(self, _, capfd):
|
def test_ll_with_group(self, _, capfd):
|
||||||
|
@ -376,21 +346,19 @@ class TestGroupCmd:
|
||||||
args.group_cmd = 'rename'
|
args.group_cmd = 'rename'
|
||||||
utils.get_groups.cache_clear()
|
utils.get_groups.cache_clear()
|
||||||
__main__.f_group(args)
|
__main__.f_group(args)
|
||||||
expected = {'yy': ['a', 'c', 'd'], 'zz': ['a', 'b']}
|
expected = {'yy': {'repos': ['a', 'c', 'd'], 'path': ''},
|
||||||
|
'zz': {'repos': ['a', 'b'], 'path': ''}}
|
||||||
mock_write.assert_called_once_with(expected, 'w')
|
mock_write.assert_called_once_with(expected, 'w')
|
||||||
|
|
||||||
|
@patch('gita.info.get_color_encoding', return_value=info.default_colors)
|
||||||
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
|
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
|
||||||
def test_rename_error(self, *_):
|
def test_rename_error(self, *_):
|
||||||
args = argparse.Namespace()
|
|
||||||
args.gname = 'xx'
|
|
||||||
args.new_name = 'yy'
|
|
||||||
args.group_cmd = 'rename'
|
|
||||||
utils.get_groups.cache_clear()
|
utils.get_groups.cache_clear()
|
||||||
with pytest.raises(SystemExit, match='yy already exists.'):
|
with pytest.raises(SystemExit, match="1"):
|
||||||
__main__.f_group(args)
|
__main__.main('group rename xx yy'.split())
|
||||||
|
|
||||||
@pytest.mark.parametrize('input, expected', [
|
@pytest.mark.parametrize('input, expected', [
|
||||||
('xx', {'yy': ['a', 'c', 'd']}),
|
('xx', {'yy': {'repos': ['a', 'c', 'd'], 'path': ''}}),
|
||||||
("xx yy", {}),
|
("xx yy", {}),
|
||||||
])
|
])
|
||||||
@patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''})
|
@patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''})
|
||||||
|
@ -412,7 +380,8 @@ class TestGroupCmd:
|
||||||
args.gname = 'zz'
|
args.gname = 'zz'
|
||||||
utils.get_groups.cache_clear()
|
utils.get_groups.cache_clear()
|
||||||
__main__.f_group(args)
|
__main__.f_group(args)
|
||||||
mock_write.assert_called_once_with({'zz': ['a', 'c']}, 'a+')
|
mock_write.assert_called_once_with(
|
||||||
|
{'zz': {'repos': ['a', 'c'], 'path': ''}}, 'a+')
|
||||||
|
|
||||||
@patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''})
|
@patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''})
|
||||||
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
|
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
|
||||||
|
@ -425,20 +394,22 @@ class TestGroupCmd:
|
||||||
utils.get_groups.cache_clear()
|
utils.get_groups.cache_clear()
|
||||||
__main__.f_group(args)
|
__main__.f_group(args)
|
||||||
mock_write.assert_called_once_with(
|
mock_write.assert_called_once_with(
|
||||||
{'xx': ['a', 'b', 'c'], 'yy': ['a', 'c', 'd']}, 'w')
|
{'xx': {'repos': ['a', 'b', 'c'], 'path': ''},
|
||||||
|
'yy': {'repos': ['a', 'c', 'd'], 'path': ''}}, 'w')
|
||||||
|
|
||||||
@patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''})
|
@patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''})
|
||||||
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
|
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
|
||||||
@patch('gita.utils.write_to_groups_file')
|
@patch('gita.utils.write_to_groups_file')
|
||||||
def test_rm_repo(self, mock_write, *_):
|
def test_rm_repo(self, mock_write, *_):
|
||||||
args = argparse.Namespace()
|
args = argparse.Namespace()
|
||||||
args.from_group = ['a', 'c']
|
args.to_rm = ['a', 'c']
|
||||||
args.group_cmd = 'rmrepo'
|
args.group_cmd = 'rmrepo'
|
||||||
args.gname = 'xx'
|
args.gname = 'xx'
|
||||||
utils.get_groups.cache_clear()
|
utils.get_groups.cache_clear()
|
||||||
__main__.f_group(args)
|
__main__.f_group(args)
|
||||||
mock_write.assert_called_once_with(
|
mock_write.assert_called_once_with(
|
||||||
{'xx': ['b'], 'yy': ['a', 'c', 'd']}, 'w')
|
{'xx': {'repos': ['b'], 'path': ''},
|
||||||
|
'yy': {'repos': ['a', 'c', 'd'], 'path': ''}}, 'w')
|
||||||
|
|
||||||
@patch('gita.common.get_config_fname')
|
@patch('gita.common.get_config_fname')
|
||||||
def test_integration(self, mock_path_fname, tmp_path, capfd):
|
def test_integration(self, mock_path_fname, tmp_path, capfd):
|
||||||
|
@ -510,15 +481,16 @@ class TestInfo:
|
||||||
def test_set_color(mock_get_fname, tmpdir):
|
def test_set_color(mock_get_fname, tmpdir):
|
||||||
args = argparse.Namespace()
|
args = argparse.Namespace()
|
||||||
args.color_cmd = 'set'
|
args.color_cmd = 'set'
|
||||||
args.color = 'redrum' # this color doesn't exist
|
args.color = 'b_white'
|
||||||
args.situation = 'in-sync'
|
args.situation = 'no-remote'
|
||||||
with tmpdir.as_cwd():
|
with tmpdir.as_cwd():
|
||||||
csv_config = Path.cwd() / 'colors.csv'
|
csv_config = Path.cwd() / 'colors.csv'
|
||||||
mock_get_fname.return_value = csv_config
|
mock_get_fname.return_value = csv_config
|
||||||
__main__.f_color(args)
|
__main__.f_color(args)
|
||||||
|
|
||||||
info.get_color_encoding.cache_clear() # avoid side effect
|
info.get_color_encoding.cache_clear() # avoid side effect
|
||||||
items = info.get_color_encoding()
|
items = info.get_color_encoding()
|
||||||
info.get_color_encoding.cache_clear() # avoid side effect
|
info.get_color_encoding.cache_clear() # avoid side effect
|
||||||
assert items == {'no-remote': 'white', 'in-sync': 'redrum',
|
assert items == {'no-remote': 'b_white', 'in-sync': 'green',
|
||||||
'diverged': 'red', 'local-ahead': 'purple',
|
'diverged': 'red', 'local-ahead': 'purple',
|
||||||
'remote-ahead': 'yellow'}
|
'remote-ahead': 'yellow'}
|
||||||
|
|
|
@ -11,7 +11,7 @@ from conftest import (
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('repo_path, paths, expected', [
|
@pytest.mark.parametrize('repo_path, paths, expected', [
|
||||||
('/a/b/c/repo', ['/a/b'], ('b', 'c')),
|
('/a/b/c/repo', ['/a/b'], (('b', 'c'), '/a')),
|
||||||
])
|
])
|
||||||
def test_generate_dir_hash(repo_path, paths, expected):
|
def test_generate_dir_hash(repo_path, paths, expected):
|
||||||
got = utils._generate_dir_hash(repo_path, paths)
|
got = utils._generate_dir_hash(repo_path, paths)
|
||||||
|
@ -20,11 +20,13 @@ def test_generate_dir_hash(repo_path, paths, expected):
|
||||||
|
|
||||||
@pytest.mark.parametrize('repos, paths, expected', [
|
@pytest.mark.parametrize('repos, paths, expected', [
|
||||||
({'r1': {'path': '/a/b//repo1'}, 'r2': {'path': '/a/b/repo2'}},
|
({'r1': {'path': '/a/b//repo1'}, 'r2': {'path': '/a/b/repo2'}},
|
||||||
['/a/b'], {'b': ['r1', 'r2']}),
|
['/a/b'], {'b': {'repos': ['r1', 'r2'], 'path': '/a/b'}}),
|
||||||
({'r1': {'path': '/a/b//repo1'}, 'r2': {'path': '/a/b/c/repo2'}},
|
({'r1': {'path': '/a/b//repo1'}, 'r2': {'path': '/a/b/c/repo2'}},
|
||||||
['/a/b'], {'b': ['r1', 'r2'], 'b-c': ['r2']}),
|
['/a/b'], {'b': {'repos': ['r1', 'r2'], 'path': '/a/b'},
|
||||||
|
'b-c': {'repos': ['r2'], 'path': "/a/b/c"}}),
|
||||||
({'r1': {'path': '/a/b/c/repo1'}, 'r2': {'path': '/a/b/c/repo2'}},
|
({'r1': {'path': '/a/b/c/repo1'}, 'r2': {'path': '/a/b/c/repo2'}},
|
||||||
['/a/b'], {'b-c': ['r1', 'r2'], 'b': ['r1', 'r2']}),
|
['/a/b'], {'b-c': {'repos': ['r1', 'r2'], 'path': '/a/b/c'},
|
||||||
|
'b': {'path': '/a/b', 'repos': ['r1', 'r2']}}),
|
||||||
])
|
])
|
||||||
def test_auto_group(repos, paths, expected):
|
def test_auto_group(repos, paths, expected):
|
||||||
got = utils.auto_group(repos, paths)
|
got = utils.auto_group(repos, paths)
|
||||||
|
@ -46,8 +48,8 @@ def test_describe(test_input, diff_return, expected, monkeypatch):
|
||||||
monkeypatch.setattr(info, 'get_commit_time', lambda *_: "xx")
|
monkeypatch.setattr(info, 'get_commit_time', lambda *_: "xx")
|
||||||
monkeypatch.setattr(info, 'has_untracked', lambda *_: True)
|
monkeypatch.setattr(info, 'has_untracked', lambda *_: True)
|
||||||
monkeypatch.setattr('os.chdir', lambda x: None)
|
monkeypatch.setattr('os.chdir', lambda x: None)
|
||||||
print('expected: ', repr(expected))
|
|
||||||
print('got: ', repr(next(utils.describe(*test_input))))
|
info.get_color_encoding.cache_clear() # avoid side effect
|
||||||
assert expected == next(utils.describe(*test_input))
|
assert expected == next(utils.describe(*test_input))
|
||||||
|
|
||||||
|
|
||||||
|
@ -83,7 +85,8 @@ def test_get_context(mock_config_dir):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('group_fname, expected', [
|
@pytest.mark.parametrize('group_fname, expected', [
|
||||||
(GROUP_FNAME, {'xx': ['a', 'b'], 'yy': ['a', 'c', 'd']}),
|
(GROUP_FNAME, {'xx': {'repos': ['a', 'b'], 'path': ''},
|
||||||
|
'yy': {'repos': ['a', 'c', 'd'], 'path': ''}}),
|
||||||
])
|
])
|
||||||
@patch('gita.common.get_config_fname')
|
@patch('gita.common.get_config_fname')
|
||||||
def test_get_groups(mock_group_fname, group_fname, expected):
|
def test_get_groups(mock_group_fname, group_fname, expected):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue