1
0
Fork 0

Adding 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:36 +01:00
parent 1b2b356ce0
commit 3ac0de4543
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 # 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) ![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 - white: local has no remote
- green: local is the same as remote - green: local is the same as remote
@ -50,32 +57,50 @@ The additional status symbols denote
The bookkeeping sub-commands are The bookkeeping sub-commands are
- `gita add <repo-path(s)>`: add repo(s) to `gita` - `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 context`: context sub-command
- `gita group`: show grouping of the repos - `gita context`: show current context
- `gita group <repo-name(s)>`: group repos - `gita context none`: remove context
- `gita ungroup <repo-name(s)>`: remove grouping for repos - `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`: 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 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
- `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 - `gita -v`: display gita version
Repo paths are saved in `$XDG_CONFIG_HOME/gita/repo_path` (most likely `~/.config/gita/repo_path`).
The delegating sub-commands are of two formats The delegating sub-commands are of two formats
- `gita <sub-command> [repo-name(s) or group-name(s)]`: - `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)>`: - `gita <sub-command> <repo-name(s) or groups-name(s)>`:
required repo name(s) or group name(s) input 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. 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, 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. 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 ## Installation
To install the latest version, run To install the latest version, run
@ -110,7 +135,7 @@ or
[.gita-completion.zsh](https://github.com/nosarthur/gita/blob/master/.gita-completion.zsh) [.gita-completion.zsh](https://github.com/nosarthur/gita/blob/master/.gita-completion.zsh)
and source it in the .rc file. and source it in the .rc file.
## Superman mode ## <a name='superman'></a> Superman mode
The superman mode delegates any git command/alias. The superman mode delegates any git command/alias.
Usage: Usage:
@ -126,7 +151,7 @@ For example,
- `gita super frontend-repo backend-repo commit -am 'implement a new feature'` - `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` 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` Custom delegating sub-commands can be defined in `$XDG_CONFIG_HOME/gita/cmds.yml`
(most likely `~/.config/gita/cmds.yml`). (most likely `~/.config/gita/cmds.yml`).
@ -164,8 +189,11 @@ comaster:
Another customization is the information items displayed by `gita ll`. Another customization is the information items displayed by `gita ll`.
The used and unused information items are shown with `gita info` and one can 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 create `$XDG_CONFIG_HOME/gita/info.yml` to customize it.
default information items setting corresponds to (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 ```yaml
- branch - branch

View file

@ -14,7 +14,7 @@
| | ____ | | | | | ___ | | | ____ | | | | | ___ |
| | \_ ) | | | | | ( ) | | | \_ ) | | | | | ( ) |
| (___) |__) (___ | | | ) ( | | (___) |__) (___ | | | ) ( |
(_______)_______/ )_( |/ \| v0.10 (_______)_______/ )_( |/ \| v0.11
``` ```
# Gita一个管理多个 git 库的命令行工具 # Gita一个管理多个 git 库的命令行工具
@ -46,15 +46,23 @@
基础指令: 基础指令:
- `gita add <repo-path(s)>`: 添加库 - `gita add <repo-path(s)>`: 添加库
- `gita rm <repo-name(s)>`: 移除库(不会删除文件) - `gita context`: 情境命令
- `gita group`: 显示库的组群 - `gita context`: 显示当前的情境
- `gita group` <repo-name(s)>: 将库分组 - `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`: 显示所有库的状态信息
- `gita ll <group-name>`: 显示一个组群中库的状态信息 - `gita ll <group-name>`: 显示一个组群中库的状态信息
- `gita ls`: 显示所有库的名字 - `gita ls`: 显示所有库的名字
- `gita ls <repo-name>`: 显示一个库的绝对路径 - `gita ls <repo-name>`: 显示一个库的绝对路径
- `gita rename <repo-name> <new-name>`: 重命名一个库 - `gita rename <repo-name> <new-name>`: 重命名一个库
- `gita info`: 显示已用的和未用的信息项 - `gita rm <repo-name(s)>`: 移除库(不会删除文件)
- `gita -v`: 显示版本号 - `gita -v`: 显示版本号
库的路径存在`$XDG_CONFIG_HOME/gita/repo_path` (多半是`~/.config/gita/repo_path`)。 库的路径存在`$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 os
import sys
import yaml
import argparse import argparse
import subprocess import subprocess
import pkg_resources 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): def f_add(args: argparse.Namespace):
repos = utils.get_repos() 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): 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) utils.rename_repo(repos, args.repo[0], args.new_name)
def f_info(_): def f_color(args: argparse.Namespace):
all_items, to_display = info.get_info_items() cmd = args.color_cmd or 'll'
print('In use:', ','.join(to_display)) if cmd == 'll': # pragma: no cover
unused = set(all_items) - set(to_display) info.show_colors()
if unused: elif cmd == 'set':
print('Unused:', ' '.join(unused)) 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): def f_ll(args: argparse.Namespace):
@ -45,10 +73,13 @@ def f_ll(args: argparse.Namespace):
Display details of all repos Display details of all repos
""" """
repos = utils.get_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 if args.group: # only display repos in this group
group_repos = utils.get_groups()[args.group] group_repos = utils.get_groups()[args.group]
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}
for line in utils.describe(repos): for line in utils.describe(repos, no_colors=args.no_colors):
print(line) print(line)
@ -62,8 +93,26 @@ def f_ls(args: argparse.Namespace):
def f_group(args: argparse.Namespace): def f_group(args: argparse.Namespace):
groups = utils.get_groups() groups = utils.get_groups()
if args.to_group: cmd = args.group_cmd or 'll'
gname = input('group name? ') 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: if gname in groups:
gname_repos = set(groups[gname]) gname_repos = set(groups[gname])
gname_repos.update(args.to_group) gname_repos.update(args.to_group)
@ -71,31 +120,32 @@ def f_group(args: argparse.Namespace):
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+') 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): def f_context(args: argparse.Namespace):
groups = utils.get_groups() choice = args.choice
to_ungroup = set(args.to_ungroup) ctx = utils.get_context()
to_del = [] if choice is None:
for name, repos in groups.items(): if ctx:
remaining = set(repos) - to_ungroup group = ctx.stem
if remaining: print(f"{group}: {' '.join(utils.get_groups()[group])}")
groups[name] = list(sorted(remaining))
else: else:
to_del.append(name) print('Context is not set')
for name in to_del: elif choice == 'none': # remove context
del groups[name] ctx and ctx.unlink()
utils.write_to_groups_file(groups, 'w') 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): def f_rm(args: argparse.Namespace):
""" """
Unregister repo(s) from gita 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): if os.path.isfile(path_file):
repos = utils.get_repos() repos = utils.get_repos()
for repo in args.repo: for repo in args.repo:
@ -110,6 +160,9 @@ def f_git_cmd(args: argparse.Namespace):
""" """
repos = utils.get_repos() repos = utils.get_repos()
groups = utils.get_groups() 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) if args.repo: # with user specified repo(s) or group(s)
chosen = {} chosen = {}
for k in args.repo: for k in args.repo:
@ -170,6 +223,8 @@ def main(argv=None):
# bookkeeping sub-commands # bookkeeping sub-commands
p_add = subparsers.add_parser('add', help='add repo(s)') p_add = subparsers.add_parser('add', help='add repo(s)')
p_add.add_argument('paths', nargs='+', 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_add.set_defaults(func=f_add)
p_rm = subparsers.add_parser('rm', help='remove repo(s)') p_rm = subparsers.add_parser('rm', help='remove repo(s)')
@ -190,8 +245,38 @@ def main(argv=None):
help="new name") help="new name")
p_rename.set_defaults(func=f_rename) 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) 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: ll_doc = f''' status symbols:
+: staged changes +: staged changes
@ -212,8 +297,19 @@ def main(argv=None):
nargs='?', nargs='?',
choices=utils.get_groups(), choices=utils.get_groups(),
help="show repos in the chosen group") 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_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( p_ls = subparsers.add_parser(
'ls', help='display names of all repos, or path of a chosen repo') 'ls', help='display names of all repos, or path of a chosen repo')
p_ls.add_argument('repo', p_ls.add_argument('repo',
@ -223,21 +319,34 @@ def main(argv=None):
p_ls.set_defaults(func=f_ls) p_ls.set_defaults(func=f_ls)
p_group = subparsers.add_parser( p_group = subparsers.add_parser(
'group', help='group repos or display names of all groups if no repo is provided') 'group', help='list, add, or remove repo group(s)')
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_group.set_defaults(func=f_group)
group_cmds = p_group.add_subparsers(dest='group_cmd',
p_ungroup = subparsers.add_parser( help='additional help with sub-command -h')
'ungroup', help='remove group information for repos', group_cmds.add_parser('ll', description='List all groups with repos.')
description="Remove group information on repos") group_cmds.add_parser('ls', description='List all group names.')
p_ungroup.add_argument('to_ungroup', pg_add = group_cmds.add_parser('add', description='Add repo(s) to a group.')
nargs='+', pg_add.add_argument('to_group',
choices=utils.get_repos(), nargs='+',
help="repo(s) to be ungrouped") metavar='repo',
p_ungroup.set_defaults(func=f_ungroup) 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 # superman mode
p_super = subparsers.add_parser( p_super = subparsers.add_parser(

View file

@ -6,3 +6,11 @@ def get_config_dir() -> str:
os.path.expanduser('~'), '.config') os.path.expanduser('~'), '.config')
root = os.path.join(parent, "gita") root = os.path.join(parent, "gita")
return root 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 sys
import yaml import yaml
import subprocess import subprocess
from enum import Enum
from pathlib import Path
from functools import lru_cache
from typing import Tuple, List, Callable, Dict from typing import Tuple, List, Callable, Dict
from . import common from . import common
class Color: class Color(str, Enum):
""" """
Terminal color Terminal color
""" """
black = '\x1b[30m'
red = '\x1b[31m' # local diverges from remote red = '\x1b[31m' # local diverges from remote
green = '\x1b[32m' # local == remote green = '\x1b[32m' # local == remote
yellow = '\x1b[33m' # local is behind yellow = '\x1b[33m' # local is behind
@ -18,6 +23,43 @@ class Color:
cyan = '\x1b[36m' cyan = '\x1b[36m'
white = '\x1b[37m' # no remote branch white = '\x1b[37m' # no remote branch
end = '\x1b[0m' 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]]: 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. take the repo path as input and return the corresponding information as str.
See `get_path`, `get_repo_status`, `get_common_commit` for examples. See `get_path`, `get_repo_status`, `get_common_commit` for examples.
""" """
info_items, to_display = get_info_items() to_display = get_info_items()
return [info_items[k] for k in to_display] # 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` Return the information items to be displayed in the `gita ll` command.
sub-command, and the ones to be displayed.
It loads custom information functions and configuration if they exist.
""" """
# default settings # default settings
info_items = {'branch': get_repo_status,
'commit_msg': get_commit_msg,
'path': get_path, }
display_items = ['branch', 'commit_msg'] display_items = ['branch', 'commit_msg']
# custom settings # custom settings
root = common.get_config_dir() yml_config = Path(common.get_config_fname('info.yml'))
src_fname = os.path.join(root, 'extra_repo_info.py') if yml_config.is_file():
yml_fname = os.path.join(root, 'info.yml') with open(yml_config, 'r') as stream:
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 = yaml.load(stream, Loader=yaml.FullLoader)
display_items = [x for x in display_items if x in info_items] display_items = [x for x in display_items if x in ALL_INFO_ITEMS]
return info_items, display_items return display_items
def get_path(path): def get_path(path):
@ -113,13 +150,15 @@ def get_commit_msg(path: str) -> str:
return result.stdout.strip() 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) head = get_head(path)
dirty, staged, untracked, color = _get_repo_status(path) dirty, staged, untracked, color = _get_repo_status(path, no_colors)
return f'{color}{head+" "+dirty+staged+untracked:<10}{Color.end}' 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 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 '' staged = '+' if run_quiet_diff(['--cached']) else ''
untracked = '_' if has_untracked() else '' untracked = '_' if has_untracked() else ''
if no_colors:
return dirty, staged, untracked, ''
colors = get_color_encoding()
diff_returncode = run_quiet_diff(['@{u}', '@{0}']) diff_returncode = run_quiet_diff(['@{u}', '@{0}'])
has_no_remote = diff_returncode == 128 has_no_remote = diff_returncode == 128
has_no_diff = diff_returncode == 0 has_no_diff = diff_returncode == 0
if has_no_remote: if has_no_remote:
color = Color.white color = colors['no-remote']
elif has_no_diff: elif has_no_diff:
color = Color.green color = colors['in-sync']
else: else:
common_commit = get_common_commit() common_commit = get_common_commit()
outdated = run_quiet_diff(['@{u}', common_commit]) outdated = run_quiet_diff(['@{u}', common_commit])
if outdated: if outdated:
diverged = run_quiet_diff(['@{0}', common_commit]) 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 else: # local is ahead of remote
color = Color.purple color = colors['local-ahead']
return dirty, staged, untracked, color 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 yaml
import asyncio import asyncio
import platform 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 typing import List, Dict, Coroutine, Union
from . import info from . import info
from . import common 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() config_dir = Path(common.get_config_dir())
return os.path.join(root, fname) matches = list(config_dir.glob('*.context'))
assert len(matches) < 2, "Cannot have multiple .context file"
return matches[0] if matches else None
@lru_cache() @lru_cache()
@ -22,7 +26,7 @@ def get_repos() -> Dict[str, str]:
""" """
Return a `dict` of repo name to repo absolute path 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 = {} repos = {}
# Each line is a repo path and repo name separated by , # 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: 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. Return a `dict` of group name to repo names.
""" """
fname = get_config_fname('groups.yml') fname = common.get_config_fname('groups.yml')
groups = {} groups = {}
# Each line is a repo path and repo name separated by , # Each line is a repo path and repo name separated by ,
if os.path.isfile(fname) and os.stat(fname).st_size > 0: 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()) 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) os.makedirs(os.path.dirname(fname), exist_ok=True)
with open(fname, mode) as f: with open(fname, mode) as f:
f.write(data) 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) os.makedirs(os.path.dirname(fname), exist_ok=True)
with open(fname, mode) as f: if not groups: # all groups are deleted
yaml.dump(groups, f, default_flow_style=None) 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]): 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 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 Return the status of all repos
""" """
if repos: if repos:
name_width = max(len(n) for n in repos) + 1 name_width = max(len(n) for n in repos) + 1
funcs = info.get_info_funcs() 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): for name in sorted(repos):
path = repos[name] path = repos[name]
display_items = ' '.join(f(path) for f in funcs) info_items = ' '.join(f(path) for f in funcs)
yield f'{name:<{name_width}}{display_items}' yield f'{name:<{name_width}}{info_items}'
def get_cmds_from_files() -> Dict[str, Dict[str, str]]: 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-cov>=2.6.1
pytest-xdist>=1.26.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 pyyaml>=5.1

View file

@ -7,9 +7,9 @@ with open('README.md', encoding='utf-8') as f:
setup( setup(
name='gita', name='gita',
packages=['gita'], packages=['gita'],
version='0.10.10', version='0.11.9',
license='MIT', license='MIT',
description='Manage multiple git repos', 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',

View file

@ -1,22 +1,28 @@
import pytest import pytest
from unittest.mock import patch from unittest.mock import patch, mock_open
from pathlib import Path
import argparse import argparse
import shlex import shlex
from gita import __main__ from gita import __main__
from gita import utils from gita import utils, info
from conftest import ( from conftest import (
PATH_FNAME, PATH_FNAME_EMPTY, PATH_FNAME_CLASH, GROUP_FNAME, PATH_FNAME, PATH_FNAME_EMPTY, PATH_FNAME_CLASH, GROUP_FNAME,
async_mock async_mock, TEST_DIR,
) )
class TestLsLl: class TestLsLl:
@patch('gita.utils.get_config_fname') @patch('gita.common.get_config_fname')
def testLl(self, mock_path_fname, capfd, tmp_path): def testLl(self, mock_path_fname, capfd, tmp_path):
""" functional test """ """
functional test
"""
# avoid modifying the local configuration # 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', '.']) __main__.main(['add', '.'])
out, err = capfd.readouterr() out, err = capfd.readouterr()
assert err == '' assert err == ''
@ -34,6 +40,14 @@ class TestLsLl:
out, err = capfd.readouterr() out, err = capfd.readouterr()
assert err == '' assert err == ''
assert 'gita' in out 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']) __main__.main(['ls', 'gita'])
out, err = capfd.readouterr() out, err = capfd.readouterr()
@ -65,10 +79,14 @@ class TestLsLl:
@patch('gita.info.get_head', return_value="master") @patch('gita.info.get_head', return_value="master")
@patch('gita.info._get_repo_status', return_value=("d", "s", "u", "c")) @patch('gita.info._get_repo_status', return_value=("d", "s", "u", "c"))
@patch('gita.info.get_commit_msg', return_value="msg") @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, def testWithPathFiles(self, mock_path_fname, _0, _1, _2, _3, path_fname,
expected, capfd): 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() utils.get_repos.cache_clear()
__main__.main(['ll']) __main__.main(['ll'])
out, err = capfd.readouterr() out, err = capfd.readouterr()
@ -78,7 +96,7 @@ class TestLsLl:
@patch('os.path.isfile', return_value=True) @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.get_repos', return_value={'repo1': '/a/', 'repo2': '/b/'})
@patch('gita.utils.write_to_repo_file') @patch('gita.utils.write_to_repo_file')
def test_rm(mock_write, *_): 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') mock_run.assert_called_once_with(expected_cmds, cwd='path7')
@pytest.mark.parametrize('input, expected', [ class TestContext:
('a', {'xx': ['b'], 'yy': ['c', 'd']}),
("c", {'xx': ['a', 'b'], 'yy': ['a', 'd']}), @patch('gita.utils.get_context', return_value=None)
("a b", {'yy': ['c', 'd']}), def testDisplayNoContext(self, _, capfd):
]) __main__.main(['context'])
@patch('gita.utils.get_repos', return_value={'a': '', 'b': '', 'c': '', 'd': ''}) out, err = capfd.readouterr()
@patch('gita.utils.get_config_fname', return_value=GROUP_FNAME) assert err == ''
@patch('gita.utils.write_to_groups_file') assert 'Context is not set\n' == out
def test_ungroup(mock_write, _, __, input, expected):
utils.get_groups.cache_clear() @patch('gita.utils.get_context', return_value=Path('gname.context'))
args = ['ungroup'] + shlex.split(input) @patch('gita.utils.get_groups', return_value={'gname': ['a', 'b']})
__main__.main(args) def testDisplayContext(self, _, __, capfd):
mock_write.assert_called_once_with(expected, 'w') __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) class TestGroupCmd:
def test_group_display(_, capfd):
args = argparse.Namespace() @patch('gita.common.get_config_fname', return_value=GROUP_FNAME)
args.to_group = None def testLs(self, _, capfd):
utils.get_groups.cache_clear() args = argparse.Namespace()
__main__.f_group(args) args.to_group = None
out, err = capfd.readouterr() args.group_cmd = 'ls'
assert err == '' utils.get_groups.cache_clear()
assert 'xx: a b\nyy: a c d\n' == out __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.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') @patch('gita.utils.rename_repo')
def test_rename(mock_rename, _, __): def test_rename(mock_rename, _, __):
utils.get_repos.cache_clear() utils.get_repos.cache_clear()
@ -170,9 +287,39 @@ def test_rename(mock_rename, _, __):
'repo1', 'abc') 'repo1', 'abc')
@patch('os.path.isfile', return_value=False) class TestInfo:
def test_info(mock_isfile, capfd):
__main__.f_info(None) @patch('gita.common.get_config_fname', return_value='')
out, err = capfd.readouterr() def testLl(self, _, capfd):
assert 'In use: branch,commit_msg\nUnused: path\n' == out args = argparse.Namespace()
assert err == '' 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 gita import utils, info
from conftest import ( 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', [ @pytest.mark.parametrize('test_input, diff_return, expected', [
({ ([{'abc': '/root/repo/'}, False], True, 'abc \x1b[31mrepo *+_ \x1b[0m msg'),
'abc': '/root/repo/' ([{'abc': '/root/repo/'}, True], True, 'abc repo *+_ msg'),
}, True, 'abc \x1b[31mrepo *+_ \x1b[0m msg'), ([{'repo': '/root/repo2/'}, False], False, 'repo \x1b[32mrepo _ \x1b[0m msg'),
({
'repo': '/root/repo2/'
}, False, 'repo \x1b[32mrepo _ \x1b[0m msg'),
]) ])
def test_describe(test_input, diff_return, expected, monkeypatch): def test_describe(test_input, diff_return, expected, monkeypatch):
monkeypatch.setattr(info, 'get_head', lambda x: 'repo') 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(info, 'has_untracked', lambda: True)
monkeypatch.setattr('os.chdir', lambda x: None) monkeypatch.setattr('os.chdir', lambda x: None)
print('expected: ', repr(expected)) print('expected: ', repr(expected))
print('got: ', repr(next(utils.describe(test_input)))) print('got: ', repr(next(utils.describe(*test_input))))
assert expected == next(utils.describe(test_input)) assert expected == next(utils.describe(*test_input))
@pytest.mark.parametrize('path_fname, expected', [ @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.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): def test_get_repos(mock_path_fname, _, path_fname, expected):
mock_path_fname.return_value = path_fname mock_path_fname.return_value = path_fname
utils.get_repos.cache_clear() utils.get_repos.cache_clear()
assert utils.get_repos() == expected 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', [ @pytest.mark.parametrize('group_fname, expected', [
(GROUP_FNAME, {'xx': ['a', 'b'], 'yy': ['a', 'c', 'd']}), (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): def test_get_groups(mock_group_fname, group_fname, expected):
mock_group_fname.return_value = group_fname mock_group_fname.return_value = group_fname
utils.get_groups.cache_clear() utils.get_groups.cache_clear()

0
tests/xx.context Normal file
View file