1
0
Fork 0

Merging upstream version 0.11.9.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-11 18:39:54 +01:00
parent e4d80afee6
commit bb3b60775d
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
11 changed files with 528 additions and 157 deletions

View file

@ -14,7 +14,7 @@
| | ____ | | | | | ___ |
| | \_ ) | | | | | ( ) |
| (___) |__) (___ | | | ) ( |
(_______)_______/ )_( |/ \| v0.10
(_______)_______/ )_( |/ \| v0.11
```
# Gita: a command-line tool to manage multiple git repos
@ -29,7 +29,14 @@ 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:
In the screenshot, the `gita remote nowhub` command translates to `git remote -v`
for the `nowhub` repo.
To see the pre-defined sub-commands, run `gita -h` or take a look at
[cmds.yml](https://github.com/nosarthur/gita/blob/master/gita/cmds.yml).
To add your own sub-commands, see the [customization section](#custom).
To run arbitrary `git` command, see the [superman mode section](#superman).
The branch color distinguishes 5 situations between local and remote branches:
- white: local has no remote
- green: local is the same as remote
@ -50,32 +57,50 @@ The additional status symbols denote
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 context`: context sub-command
- `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 color`: color sub-command
- `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 group`: group sub-command
- `gita group add <repo-name(s)> -n <group-name>`: add repo(s) to a new group or existing group
- `gita group [ll]`: display existing groups with repos
- `gita group ls`: display existing group names
- `gita group rename <group-name> <new-name>`: change group name
- `gita group rm <group-name(s)>`: delete group(s)
- `gita info`: info sub-command
- `gita info [ll]`: display the used and unused information items
- `gita info add <info-item>`: enable information item
- `gita info rm <info-item>`: disable information item
- `gita ll`: display the status of all repos
- `gita ll <group-name>`: display the status of repos in a group
- `gita ls`: display the names of all repos
- `gita ls <repo-name>`: display the absolute path of one repo
- `gita rename <repo-name> <new-name>`: rename a repo
- `gita info`: display the used and unused information items
- `gita rm <repo-name(s)>`: remove repo(s) from `gita` (won't remove files from disk)
- `gita -v`: display gita version
Repo paths are saved in `$XDG_CONFIG_HOME/gita/repo_path` (most likely `~/.config/gita/repo_path`).
The delegating sub-commands are of two formats
- `gita <sub-command> [repo-name(s) or group-name(s)]`:
optional repo or group input, and no input means all repos.
optional repo or group input, and **no input means all repos**.
- `gita <sub-command> <repo-name(s) or groups-name(s)>`:
required repo name(s) or group name(s) input
In either case, the `gita` command translates to running `git <sub-command>` for the corresponding repos.
By default, only `fetch` and `pull` take optional input.
To see the pre-defined sub-commands, run `gita -h` or take a look at
[cmds.yml](https://github.com/nosarthur/gita/blob/master/gita/cmds.yml).
To add your own sub-commands, see the [customization section](#custom).
To run arbitrary `git` command, see the [superman mode section](#superman).
If more than one repos are specified, the git command will run asynchronously,
with the exception of `log`, `difftool` and `mergetool`, which require non-trivial user input.
Repo paths are saved in `$XDG_CONFIG_HOME/gita/repo_path` (most likely `~/.config/gita/repo_path`).
## Installation
To install the latest version, run
@ -110,7 +135,7 @@ or
[.gita-completion.zsh](https://github.com/nosarthur/gita/blob/master/.gita-completion.zsh)
and source it in the .rc file.
## Superman mode
## <a name='superman'></a> Superman mode
The superman mode delegates any git command/alias.
Usage:
@ -126,7 +151,7 @@ For example,
- `gita super frontend-repo backend-repo commit -am 'implement a new feature'`
executes `git commit -am 'implement a new feature'` for `frontend-repo` and `backend-repo`
## Customization
## <a name='custom'></a> Customization
Custom delegating sub-commands can be defined in `$XDG_CONFIG_HOME/gita/cmds.yml`
(most likely `~/.config/gita/cmds.yml`).
@ -164,8 +189,11 @@ comaster:
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
create `$XDG_CONFIG_HOME/gita/info.yml` to customize it.
(I am thinking of hiding all these details from user at the moment, which means
you probably don't need to read the rest of this section.)
For example, the default information items setting corresponds to
```yaml
- branch

View file

@ -14,7 +14,7 @@
| | ____ | | | | | ___ |
| | \_ ) | | | | | ( ) |
| (___) |__) (___ | | | ) ( |
(_______)_______/ )_( |/ \| v0.10
(_______)_______/ )_( |/ \| v0.11
```
# Gita一个管理多个 git 库的命令行工具
@ -46,15 +46,23 @@
基础指令:
- `gita add <repo-path(s)>`: 添加库
- `gita rm <repo-name(s)>`: 移除库(不会删除文件)
- `gita group`: 显示库的组群
- `gita group` <repo-name(s)>: 将库分组
- `gita context`: 情境命令
- `gita context`: 显示当前的情境
- `gita context none`: 去除情境
- `gita context <group-name>`: 把情境设置成`group-name`, 之后所有的操作只作用到这个组里的库
- `gita group`: 组群命令
- `gita group add <repo-name(s)>`: 把库加入新的或者已经存在的组
- `gita group [ll]`: 显示已有的组和它们的库
- `gita group ls`: 显示已有的组名
- `gita group rename <group-name> <new-name>`: 改组名
- `gita group rm group(s): 删除组
- `gita info`: 显示已用的和未用的信息项
- `gita ll`: 显示所有库的状态信息
- `gita ll <group-name>`: 显示一个组群中库的状态信息
- `gita ls`: 显示所有库的名字
- `gita ls <repo-name>`: 显示一个库的绝对路径
- `gita rename <repo-name> <new-name>`: 重命名一个库
- `gita info`: 显示已用的和未用的信息项
- `gita rm <repo-name(s)>`: 移除库(不会删除文件)
- `gita -v`: 显示版本号
库的路径存在`$XDG_CONFIG_HOME/gita/repo_path` (多半是`~/.config/gita/repo_path`)。

View file

@ -15,16 +15,23 @@ https://github.com/nosarthur/gita/blob/master/.gita-completion.bash
'''
import os
import sys
import yaml
import argparse
import subprocess
import pkg_resources
from itertools import chain
from pathlib import Path
from . import utils, info
from . import utils, info, common
def f_add(args: argparse.Namespace):
repos = utils.get_repos()
utils.add_repos(repos, args.paths)
paths = args.paths
if args.recursive:
paths = chain.from_iterable(Path(p).glob('**') for p in args.paths)
utils.add_repos(repos, paths)
def f_rename(args: argparse.Namespace):
@ -32,12 +39,33 @@ def f_rename(args: argparse.Namespace):
utils.rename_repo(repos, args.repo[0], args.new_name)
def f_info(_):
all_items, to_display = info.get_info_items()
print('In use:', ','.join(to_display))
unused = set(all_items) - set(to_display)
if unused:
print('Unused:', ' '.join(unused))
def f_color(args: argparse.Namespace):
cmd = args.color_cmd or 'll'
if cmd == 'll': # pragma: no cover
info.show_colors()
elif cmd == 'set':
print('not implemented')
def f_info(args: argparse.Namespace):
to_display = info.get_info_items()
cmd = args.info_cmd or 'll'
if cmd == 'll':
print('In use:', ','.join(to_display))
unused = set(info.ALL_INFO_ITEMS) - set(to_display)
if unused:
print('Unused:', ' '.join(unused))
return
if cmd == 'add' and args.info_item not in to_display:
to_display.append(args.info_item)
yml_config = common.get_config_fname('info.yml')
with open(yml_config, 'w') as f:
yaml.dump(to_display, f, default_flow_style=None)
elif cmd == 'rm' and args.info_item in to_display:
to_display.remove(args.info_item)
yml_config = common.get_config_fname('info.yml')
with open(yml_config, 'w') as f:
yaml.dump(to_display, f, default_flow_style=None)
def f_ll(args: argparse.Namespace):
@ -45,10 +73,13 @@ def f_ll(args: argparse.Namespace):
Display details of all repos
"""
repos = utils.get_repos()
ctx = utils.get_context()
if args.group is None and ctx:
args.group = ctx.stem
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):
for line in utils.describe(repos, no_colors=args.no_colors):
print(line)
@ -62,8 +93,26 @@ def f_ls(args: argparse.Namespace):
def f_group(args: argparse.Namespace):
groups = utils.get_groups()
if args.to_group:
gname = input('group name? ')
cmd = args.group_cmd or 'll'
if cmd == 'll':
for group, repos in groups.items():
print(f"{group}: {' '.join(repos)}")
elif cmd == 'ls':
print(' '.join(groups))
elif cmd == 'rename':
new_name = args.new_name
if new_name in groups:
sys.exit(f'{new_name} already exists.')
gname = args.gname
groups[new_name] = groups[gname]
del groups[gname]
utils.write_to_groups_file(groups, 'w')
elif cmd == 'rm':
for name in args.to_ungroup:
del groups[name]
utils.write_to_groups_file(groups, 'w')
elif cmd == 'add':
gname = args.gname
if gname in groups:
gname_repos = set(groups[gname])
gname_repos.update(args.to_group)
@ -71,31 +120,32 @@ def f_group(args: argparse.Namespace):
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))
def f_context(args: argparse.Namespace):
choice = args.choice
ctx = utils.get_context()
if choice is None:
if ctx:
group = ctx.stem
print(f"{group}: {' '.join(utils.get_groups()[group])}")
else:
to_del.append(name)
for name in to_del:
del groups[name]
utils.write_to_groups_file(groups, 'w')
print('Context is not set')
elif choice == 'none': # remove context
ctx and ctx.unlink()
else: # set context
fname = Path(common.get_config_dir()) / (choice + '.context')
if ctx:
ctx.rename(fname)
else:
open(fname, 'w').close()
def f_rm(args: argparse.Namespace):
"""
Unregister repo(s) from gita
"""
path_file = utils.get_config_fname('repo_path')
path_file = common.get_config_fname('repo_path')
if os.path.isfile(path_file):
repos = utils.get_repos()
for repo in args.repo:
@ -110,6 +160,9 @@ def f_git_cmd(args: argparse.Namespace):
"""
repos = utils.get_repos()
groups = utils.get_groups()
ctx = utils.get_context()
if not args.repo and ctx:
args.repo = [ctx.stem]
if args.repo: # with user specified repo(s) or group(s)
chosen = {}
for k in args.repo:
@ -170,6 +223,8 @@ def main(argv=None):
# 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.add_argument('-r', dest='recursive', action='store_true',
help="recursively add repo(s) in the given path.")
p_add.set_defaults(func=f_add)
p_rm = subparsers.add_parser('rm', help='remove repo(s)')
@ -190,8 +245,38 @@ def main(argv=None):
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_color = subparsers.add_parser('color',
help='display and modify branch coloring of the ll sub-command.')
p_color.set_defaults(func=f_color)
color_cmds = p_color.add_subparsers(dest='color_cmd',
help='additional help with sub-command -h')
color_cmds.add_parser('ll',
description='display available colors and the current branch coloring in the ll sub-command')
pc_set = color_cmds.add_parser('set',
description='Set color for local/remote situation.')
pc_set.add_argument('situation',
choices=info.get_color_encoding(),
help="5 possible local/remote situations")
pc_set.add_argument('color',
choices=[c.name for c in info.Color],
help="available colors")
p_info = subparsers.add_parser('info',
help='list, add, or remove information items of the ll sub-command.')
p_info.set_defaults(func=f_info)
info_cmds = p_info.add_subparsers(dest='info_cmd',
help='additional help with sub-command -h')
info_cmds.add_parser('ll',
description='show used and unused information items of the ll sub-command')
info_cmds.add_parser('add', description='Enable information item.'
).add_argument('info_item',
choices=('branch', 'commit_msg', 'path'),
help="information item to add")
info_cmds.add_parser('rm', description='Disable information item.'
).add_argument('info_item',
choices=('branch', 'commit_msg', 'path'),
help="information item to delete")
ll_doc = f''' status symbols:
+: staged changes
@ -212,8 +297,19 @@ def main(argv=None):
nargs='?',
choices=utils.get_groups(),
help="show repos in the chosen group")
p_ll.add_argument('-n', '--no-colors', action='store_true',
help='Disable coloring on the branch names.')
p_ll.set_defaults(func=f_ll)
p_context = subparsers.add_parser('context',
help='Set and remove context. A context is a group.'
' When set, all operations apply only to repos in that group.')
p_context.add_argument('choice',
nargs='?',
choices=set().union(utils.get_groups(), {'none'}),
help="Without argument, show current context. Otherwise choose a group as context. To remove context, use 'none'. ")
p_context.set_defaults(func=f_context)
p_ls = subparsers.add_parser(
'ls', help='display names of all repos, or path of a chosen repo')
p_ls.add_argument('repo',
@ -223,21 +319,34 @@ def main(argv=None):
p_ls.set_defaults(func=f_ls)
p_group = subparsers.add_parser(
'group', help='group repos or display names of all groups if no repo is provided')
p_group.add_argument('to_group',
nargs='*',
choices=utils.get_choices(),
help="repo(s) to be grouped")
'group', help='list, add, or remove repo group(s)')
p_group.set_defaults(func=f_group)
p_ungroup = subparsers.add_parser(
'ungroup', help='remove group information for repos',
description="Remove group information on repos")
p_ungroup.add_argument('to_ungroup',
nargs='+',
choices=utils.get_repos(),
help="repo(s) to be ungrouped")
p_ungroup.set_defaults(func=f_ungroup)
group_cmds = p_group.add_subparsers(dest='group_cmd',
help='additional help with sub-command -h')
group_cmds.add_parser('ll', description='List all groups with repos.')
group_cmds.add_parser('ls', description='List all group names.')
pg_add = group_cmds.add_parser('add', description='Add repo(s) to a group.')
pg_add.add_argument('to_group',
nargs='+',
metavar='repo',
choices=utils.get_repos(),
help="repo(s) to be grouped")
pg_add.add_argument('-n', '--name',
dest='gname',
metavar='group-name',
required=True,
help="group name")
pg_rename = group_cmds.add_parser('rename', description='Change group name.')
pg_rename.add_argument('gname', metavar='group-name',
choices=utils.get_groups(),
help="existing group to rename")
pg_rename.add_argument('new_name', metavar='new-name',
help="new group name")
group_cmds.add_parser('rm',
description='Remove group(s).').add_argument('to_ungroup',
nargs='+',
choices=utils.get_groups(),
help="group(s) to delete")
# superman mode
p_super = subparsers.add_parser(

View file

@ -6,3 +6,11 @@ def get_config_dir() -> str:
os.path.expanduser('~'), '.config')
root = os.path.join(parent, "gita")
return root
def get_config_fname(fname: str) -> str:
"""
Return the file name that stores the repo locations.
"""
root = get_config_dir()
return os.path.join(root, fname)

View file

@ -2,14 +2,19 @@ import os
import sys
import yaml
import subprocess
from enum import Enum
from pathlib import Path
from functools import lru_cache
from typing import Tuple, List, Callable, Dict
from . import common
class Color:
class Color(str, Enum):
"""
Terminal color
"""
black = '\x1b[30m'
red = '\x1b[31m' # local diverges from remote
green = '\x1b[32m' # local == remote
yellow = '\x1b[33m' # local is behind
@ -18,6 +23,43 @@ class Color:
cyan = '\x1b[36m'
white = '\x1b[37m' # no remote branch
end = '\x1b[0m'
b_black = '\x1b[30;1m'
b_red = '\x1b[31;1m'
b_green = '\x1b[32;1m'
b_yellow = '\x1b[33;1m'
b_blue = '\x1b[34;1m'
b_purple = '\x1b[35;1m'
b_cyan = '\x1b[36;1m'
b_white = '\x1b[37;1m'
def show_colors(): # pragma: no cover
"""
"""
for i, c in enumerate(Color, start=1):
if c != Color.end:
print(f'{c.value}{c.name:<8} ', end='')
if i % 9 == 0:
print()
print(f'{Color.end}')
for situation, c in get_color_encoding().items():
print(f'{situation:<12}: {c.value}{c.name:<8}{Color.end} ')
@lru_cache()
def get_color_encoding():
"""
"""
# TODO: add config file
return {
'no-remote': Color.white,
'in-sync': Color.green,
'diverged': Color.red,
'local-ahead': Color.purple,
'remote-ahead': Color.yellow,
}
def get_info_funcs() -> List[Callable[[str], str]]:
@ -26,35 +68,30 @@ def get_info_funcs() -> List[Callable[[str], str]]:
take the repo path as input and return the corresponding information as str.
See `get_path`, `get_repo_status`, `get_common_commit` for examples.
"""
info_items, to_display = get_info_items()
return [info_items[k] for k in to_display]
to_display = get_info_items()
# This re-definition is to make unit test mocking to work
all_info_items = {
'branch': get_repo_status,
'commit_msg': get_commit_msg,
'path': get_path,
}
return [all_info_items[k] for k in to_display]
def get_info_items() -> Tuple[Dict[str, Callable[[str], str]], List[str]]:
def get_info_items() -> List[str]:
"""
Return the available information items for display in the `gita ll`
sub-command, and the ones to be displayed.
It loads custom information functions and configuration if they exist.
Return the information items to be displayed in the `gita ll` command.
"""
# default settings
info_items = {'branch': get_repo_status,
'commit_msg': get_commit_msg,
'path': get_path, }
display_items = ['branch', 'commit_msg']
# custom settings
root = common.get_config_dir()
src_fname = os.path.join(root, 'extra_repo_info.py')
yml_fname = os.path.join(root, 'info.yml')
if os.path.isfile(src_fname):
sys.path.append(root)
from extra_repo_info import extra_info_items
info_items.update(extra_info_items)
if os.path.isfile(yml_fname):
with open(yml_fname, 'r') as stream:
yml_config = Path(common.get_config_fname('info.yml'))
if yml_config.is_file():
with open(yml_config, '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
display_items = [x for x in display_items if x in ALL_INFO_ITEMS]
return display_items
def get_path(path):
@ -113,13 +150,15 @@ def get_commit_msg(path: str) -> str:
return result.stdout.strip()
def get_repo_status(path: str) -> str:
def get_repo_status(path: str, no_colors=False) -> str:
head = get_head(path)
dirty, staged, untracked, color = _get_repo_status(path)
return f'{color}{head+" "+dirty+staged+untracked:<10}{Color.end}'
dirty, staged, untracked, color = _get_repo_status(path, no_colors)
if color:
return f'{color}{head+" "+dirty+staged+untracked:<10}{Color.end}'
return f'{head+" "+dirty+staged+untracked:<10}'
def _get_repo_status(path: str) -> Tuple[str]:
def _get_repo_status(path: str, no_colors: bool) -> Tuple[str]:
"""
Return the status of one repo
"""
@ -128,19 +167,30 @@ def _get_repo_status(path: str) -> Tuple[str]:
staged = '+' if run_quiet_diff(['--cached']) else ''
untracked = '_' if has_untracked() else ''
if no_colors:
return dirty, staged, untracked, ''
colors = get_color_encoding()
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
color = colors['no-remote']
elif has_no_diff:
color = Color.green
color = colors['in-sync']
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
color = colors['diverged'] if diverged else colors['remote-ahead']
else: # local is ahead of remote
color = Color.purple
color = colors['local-ahead']
return dirty, staged, untracked, color
ALL_INFO_ITEMS = {
'branch': get_repo_status,
'commit_msg': get_commit_msg,
'path': get_path,
}

View file

@ -2,19 +2,23 @@ import os
import yaml
import asyncio
import platform
from functools import lru_cache
from functools import lru_cache, partial
from pathlib import Path
from typing import List, Dict, Coroutine, Union
from . import info
from . import common
def get_config_fname(fname: str) -> str:
@lru_cache()
def get_context() -> Union[Path, None]:
"""
Return the file name that stores the repo locations.
Return the context: either a group name or 'none'
"""
root = common.get_config_dir()
return os.path.join(root, fname)
config_dir = Path(common.get_config_dir())
matches = list(config_dir.glob('*.context'))
assert len(matches) < 2, "Cannot have multiple .context file"
return matches[0] if matches else None
@lru_cache()
@ -22,7 +26,7 @@ def get_repos() -> Dict[str, str]:
"""
Return a `dict` of repo name to repo absolute path
"""
path_file = get_config_fname('repo_path')
path_file = common.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:
@ -47,7 +51,7 @@ def get_groups() -> Dict[str, List[str]]:
"""
Return a `dict` of group name to repo names.
"""
fname = get_config_fname('groups.yml')
fname = common.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:
@ -102,7 +106,7 @@ 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')
fname = common.get_config_fname('repo_path')
os.makedirs(os.path.dirname(fname), exist_ok=True)
with open(fname, mode) as f:
f.write(data)
@ -112,10 +116,13 @@ def write_to_groups_file(groups: Dict[str, List[str]], mode: str):
"""
"""
fname = get_config_fname('groups.yml')
fname = common.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)
if not groups: # all groups are deleted
open(fname, 'w').close()
else:
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]):
@ -182,17 +189,23 @@ def exec_async_tasks(tasks: List[Coroutine]) -> List[Union[None, str]]:
return errors
def describe(repos: Dict[str, str]) -> str:
def describe(repos: Dict[str, str], no_colors: bool=False) -> str:
"""
Return the status of all repos
"""
if repos:
name_width = max(len(n) for n in repos) + 1
funcs = info.get_info_funcs()
get_repo_status = info.get_repo_status
if get_repo_status in funcs and no_colors:
idx = funcs.index(get_repo_status)
funcs[idx] = partial(get_repo_status, no_colors=True)
for name in sorted(repos):
path = repos[name]
display_items = ' '.join(f(path) for f in funcs)
yield f'{name:<{name_width}}{display_items}'
info_items = ' '.join(f(path) for f in funcs)
yield f'{name:<{name_width}}{info_items}'
def get_cmds_from_files() -> Dict[str, Dict[str, str]]:

View file

@ -1,6 +1,6 @@
pytest>=4.4.0
pytest>=6.1.2
pytest-cov>=2.6.1
pytest-xdist>=1.26.0
pytest-xdist>=2.1.0
setuptools>=40.6.3
twine>=1.12.1
pyyaml>=5.1

View file

@ -7,9 +7,9 @@ with open('README.md', encoding='utf-8') as f:
setup(
name='gita',
packages=['gita'],
version='0.10.10',
version='0.11.9',
license='MIT',
description='Manage multiple git repos',
description='Manage multiple git repos with sanity',
long_description=long_description,
long_description_content_type='text/markdown',
url='https://github.com/nosarthur/gita',

View file

@ -1,22 +1,28 @@
import pytest
from unittest.mock import patch
from unittest.mock import patch, mock_open
from pathlib import Path
import argparse
import shlex
from gita import __main__
from gita import utils
from gita import utils, info
from conftest import (
PATH_FNAME, PATH_FNAME_EMPTY, PATH_FNAME_CLASH, GROUP_FNAME,
async_mock
async_mock, TEST_DIR,
)
class TestLsLl:
@patch('gita.utils.get_config_fname')
@patch('gita.common.get_config_fname')
def testLl(self, mock_path_fname, capfd, tmp_path):
""" functional test """
"""
functional test
"""
# avoid modifying the local configuration
mock_path_fname.return_value = tmp_path / 'path_config.txt'
def side_effect(input):
return tmp_path / f'{input}.txt'
#mock_path_fname.return_value = tmp_path / 'path_config.txt'
mock_path_fname.side_effect = side_effect
__main__.main(['add', '.'])
out, err = capfd.readouterr()
assert err == ''
@ -34,6 +40,14 @@ class TestLsLl:
out, err = capfd.readouterr()
assert err == ''
assert 'gita' in out
assert info.Color.end in out
# no color on branch name
__main__.main(['ll', '-n'])
out, err = capfd.readouterr()
assert err == ''
assert 'gita' in out
assert info.Color.end not in out
__main__.main(['ls', 'gita'])
out, err = capfd.readouterr()
@ -65,10 +79,14 @@ class TestLsLl:
@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')
@patch('gita.common.get_config_fname')
def testWithPathFiles(self, mock_path_fname, _0, _1, _2, _3, path_fname,
expected, capfd):
mock_path_fname.return_value = path_fname
def side_effect(input):
if input == 'repo_path':
return path_fname
return f'/{input}'
mock_path_fname.side_effect = side_effect
utils.get_repos.cache_clear()
__main__.main(['ll'])
out, err = capfd.readouterr()
@ -78,7 +96,7 @@ class TestLsLl:
@patch('os.path.isfile', return_value=True)
@patch('gita.utils.get_config_fname', return_value='some path')
@patch('gita.common.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, *_):
@ -131,34 +149,133 @@ def test_superman(mock_run, _, 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')
class TestContext:
@patch('gita.utils.get_context', return_value=None)
def testDisplayNoContext(self, _, capfd):
__main__.main(['context'])
out, err = capfd.readouterr()
assert err == ''
assert 'Context is not set\n' == out
@patch('gita.utils.get_context', return_value=Path('gname.context'))
@patch('gita.utils.get_groups', return_value={'gname': ['a', 'b']})
def testDisplayContext(self, _, __, capfd):
__main__.main(['context'])
out, err = capfd.readouterr()
assert err == ''
assert 'gname: a b\n' == out
@patch('gita.utils.get_context')
def testReset(self, mock_ctx):
__main__.main(['context', 'none'])
mock_ctx.return_value.unlink.assert_called()
@patch('gita.utils.get_context', return_value=None)
@patch('gita.common.get_config_dir', return_value=TEST_DIR)
@patch('gita.utils.get_groups', return_value={'lala': ['b'], 'kaka': []})
def testSetFirstTime(self, *_):
ctx = TEST_DIR / 'lala.context'
assert not ctx.is_file()
__main__.main(['context', 'lala'])
assert ctx.is_file()
ctx.unlink()
@patch('gita.common.get_config_dir', return_value=TEST_DIR)
@patch('gita.utils.get_groups', return_value={'lala': ['b'], 'kaka': []})
@patch('gita.utils.get_context')
def testSetSecondTime(self, mock_ctx, *_):
__main__.main(['context', 'kaka'])
mock_ctx.return_value.rename.assert_called()
@patch('gita.utils.get_config_fname', return_value=GROUP_FNAME)
def test_group_display(_, capfd):
args = argparse.Namespace()
args.to_group = None
utils.get_groups.cache_clear()
__main__.f_group(args)
out, err = capfd.readouterr()
assert err == ''
assert 'xx: a b\nyy: a c d\n' == out
class TestGroupCmd:
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
def testLs(self, _, capfd):
args = argparse.Namespace()
args.to_group = None
args.group_cmd = 'ls'
utils.get_groups.cache_clear()
__main__.f_group(args)
out, err = capfd.readouterr()
assert err == ''
assert 'xx yy\n' == out
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
def testLl(self, _, capfd):
args = argparse.Namespace()
args.to_group = None
args.group_cmd = None
utils.get_groups.cache_clear()
__main__.f_group(args)
out, err = capfd.readouterr()
assert err == ''
assert 'xx: a b\nyy: a c d\n' == out
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
@patch('gita.utils.write_to_groups_file')
def testRename(self, mock_write, _):
args = argparse.Namespace()
args.gname = 'xx'
args.new_name = 'zz'
args.group_cmd = 'rename'
utils.get_groups.cache_clear()
__main__.f_group(args)
expected = {'yy': ['a', 'c', 'd'], 'zz': ['a', 'b']}
mock_write.assert_called_once_with(expected, 'w')
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
def testRenameError(self, *_):
args = argparse.Namespace()
args.gname = 'xx'
args.new_name = 'yy'
args.group_cmd = 'rename'
utils.get_groups.cache_clear()
with pytest.raises(SystemExit, match='yy already exists.'):
__main__.f_group(args)
@pytest.mark.parametrize('input, expected', [
('xx', {'yy': ['a', 'c', 'd']}),
("xx yy", {}),
])
@patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''})
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
@patch('gita.utils.write_to_groups_file')
def testRm(self, mock_write, _, __, input, expected):
utils.get_groups.cache_clear()
args = ['group', 'rm'] + shlex.split(input)
__main__.main(args)
mock_write.assert_called_once_with(expected, 'w')
@patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''})
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
@patch('gita.utils.write_to_groups_file')
def testAdd(self, mock_write, *_):
args = argparse.Namespace()
args.to_group = ['a', 'c']
args.group_cmd = 'add'
args.gname = 'zz'
utils.get_groups.cache_clear()
__main__.f_group(args)
mock_write.assert_called_once_with({'zz': ['a', 'c']}, 'a+')
@patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''})
@patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
@patch('gita.utils.write_to_groups_file')
def testAddToExisting(self, mock_write, *_):
args = argparse.Namespace()
args.to_group = ['a', 'c']
args.group_cmd = 'add'
args.gname = 'xx'
utils.get_groups.cache_clear()
__main__.f_group(args)
mock_write.assert_called_once_with(
{'xx': ['a', 'b', 'c'], 'yy': ['a', 'c', 'd']}, 'w')
@patch('gita.utils.is_git', return_value=True)
@patch('gita.utils.get_config_fname', return_value=PATH_FNAME)
@patch('gita.common.get_config_fname', return_value=PATH_FNAME)
@patch('gita.utils.rename_repo')
def test_rename(mock_rename, _, __):
utils.get_repos.cache_clear()
@ -170,9 +287,39 @@ def test_rename(mock_rename, _, __):
'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 == ''
class TestInfo:
@patch('gita.common.get_config_fname', return_value='')
def testLl(self, _, capfd):
args = argparse.Namespace()
args.info_cmd = None
__main__.f_info(args)
out, err = capfd.readouterr()
assert 'In use: branch,commit_msg\nUnused: path\n' == out
assert err == ''
@patch('gita.common.get_config_fname', return_value='')
@patch('yaml.dump')
def testAdd(self, mock_dump, _):
args = argparse.Namespace()
args.info_cmd = 'add'
args.info_item = 'path'
with patch('builtins.open', mock_open(), create=True):
__main__.f_info(args)
mock_dump.assert_called_once()
args, kwargs = mock_dump.call_args
assert args[0] == ['branch', 'commit_msg', 'path']
assert kwargs == {'default_flow_style': None}
@patch('gita.common.get_config_fname', return_value='')
@patch('yaml.dump')
def testRm(self, mock_dump, _):
args = argparse.Namespace()
args.info_cmd = 'rm'
args.info_item = 'commit_msg'
with patch('builtins.open', mock_open(), create=True):
__main__.f_info(args)
mock_dump.assert_called_once()
args, kwargs = mock_dump.call_args
assert args[0] == ['branch']
assert kwargs == {'default_flow_style': None}

View file

@ -4,17 +4,14 @@ from unittest.mock import patch, mock_open
from gita import utils, info
from conftest import (
PATH_FNAME, PATH_FNAME_EMPTY, PATH_FNAME_CLASH, GROUP_FNAME,
PATH_FNAME, PATH_FNAME_EMPTY, PATH_FNAME_CLASH, GROUP_FNAME, TEST_DIR,
)
@pytest.mark.parametrize('test_input, diff_return, expected', [
({
'abc': '/root/repo/'
}, True, 'abc \x1b[31mrepo *+_ \x1b[0m msg'),
({
'repo': '/root/repo2/'
}, False, 'repo \x1b[32mrepo _ \x1b[0m msg'),
([{'abc': '/root/repo/'}, False], True, 'abc \x1b[31mrepo *+_ \x1b[0m msg'),
([{'abc': '/root/repo/'}, True], True, 'abc repo *+_ msg'),
([{'repo': '/root/repo2/'}, False], False, 'repo \x1b[32mrepo _ \x1b[0m msg'),
])
def test_describe(test_input, diff_return, expected, monkeypatch):
monkeypatch.setattr(info, 'get_head', lambda x: 'repo')
@ -23,8 +20,8 @@ def test_describe(test_input, diff_return, expected, monkeypatch):
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))
print('got: ', repr(next(utils.describe(*test_input))))
assert expected == next(utils.describe(*test_input))
@pytest.mark.parametrize('path_fname, expected', [
@ -41,17 +38,28 @@ def test_describe(test_input, diff_return, expected, monkeypatch):
}),
])
@patch('gita.utils.is_git', return_value=True)
@patch('gita.utils.get_config_fname')
@patch('gita.common.get_config_fname')
def test_get_repos(mock_path_fname, _, path_fname, expected):
mock_path_fname.return_value = path_fname
utils.get_repos.cache_clear()
assert utils.get_repos() == expected
@patch('gita.common.get_config_dir')
def test_get_context(mock_config_dir):
mock_config_dir.return_value = TEST_DIR
utils.get_context.cache_clear()
assert utils.get_context() == TEST_DIR / 'xx.context'
mock_config_dir.return_value = '/'
utils.get_context.cache_clear()
assert utils.get_context() == None
@pytest.mark.parametrize('group_fname, expected', [
(GROUP_FNAME, {'xx': ['a', 'b'], 'yy': ['a', 'c', 'd']}),
])
@patch('gita.utils.get_config_fname')
@patch('gita.common.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()

0
tests/xx.context Normal file
View file