Repository: nosarthur/gita

Issue:
Add sub-commands for `gita group`
IMO the command syntax is confusing - you have to pass a list of groups before you get any indication that a name will be requested. I spent 4-5min reading the help text thinking I was missing something, because there was no indication a name would be requested. 

At a minimum, update the help text to say "You will be prompted for the group name". However, ideally you woudl support a `--name` arg before the positional args, so the entire command could be scripted if needed 

Discussion:

Patch:
diff --git a/gita/__main__.py b/gita/__main__.py
index fa88600..1e47e31 100644
--- a/gita/__main__.py
+++ b/gita/__main__.py
@@ -67,8 +67,19 @@ 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 == 'rm':
+        for name in args.to_ungroup:
+            del groups[name]
+        utils.write_to_groups_file(groups, 'w')
+    elif cmd == 'add':
+        while True:
+            gname = input('group name?')
+            if gname:
+                break
         if gname in groups:
             gname_repos = set(groups[gname])
             gname_repos.update(args.to_group)
@@ -76,24 +87,6 @@ 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))
-        else:
-            to_del.append(name)
-    for name in to_del:
-        del groups[name]
-    utils.write_to_groups_file(groups, 'w')
 
 
 def f_rm(args: argparse.Namespace):
@@ -230,21 +223,21 @@ 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.')
+    group_cmds.add_parser('add',
+            description='Add repo(s) to a group.').add_argument('to_group',
+                    nargs='+',
+                    choices=utils.get_repos(),
+                    help="repo(s) to be grouped")
+    group_cmds.add_parser('rm',
+            description='Remove group(s).').add_argument('to_ungroup',
+                    nargs='+',
+                    choices=utils.get_groups(),
+                    help="group(s) to be deleted")
 
     # superman mode
     p_super = subparsers.add_parser(
diff --git a/gita/utils.py b/gita/utils.py
index d14484a..dd5cca3 100644
--- a/gita/utils.py
+++ b/gita/utils.py
@@ -114,8 +114,11 @@ def write_to_groups_file(groups: Dict[str, List[str]], mode: str):
     """
     fname = get_config_fname('groups.yml')
     os.makedirs(os.path.dirname(fname), exist_ok=True)
-    with open(fname, mode) as f:
-        yaml.dump(groups, f, default_flow_style=None)
+    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]):
diff --git a/setup.py b/setup.py
index 42d9502..32d6859 100644
--- a/setup.py
+++ b/setup.py
@@ -7,7 +7,7 @@ with open('README.md', encoding='utf-8') as f:
 setup(
     name='gita',
     packages=['gita'],
-    version='0.11.1',
+    version='0.11.2',
     license='MIT',
     description='Manage multiple git repos',
     long_description=long_description,


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index ff28111..fcea12b 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -131,30 +131,56 @@ 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 TestGroupCmd:
+
+    @patch('gita.utils.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.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
+    @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.utils.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.utils.get_config_fname', return_value=GROUP_FNAME)
+    @patch('gita.utils.write_to_groups_file')
+    def testAdd(self, mock_write, _, __, monkeypatch):
+        args = argparse.Namespace()
+        args.to_group =  ['a', 'c']
+        args.group_cmd = 'add'
+        utils.get_groups.cache_clear()
+        monkeypatch.setattr('builtins.input', lambda _: 'zz')
+        __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.utils.get_config_fname', return_value=GROUP_FNAME)
+    @patch('gita.utils.write_to_groups_file')
+    def testAddToExisting(self, mock_write, _, __, monkeypatch):
+        args = argparse.Namespace()
+        args.to_group =  ['a', 'c']
+        args.group_cmd = 'add'
+        utils.get_groups.cache_clear()
+        monkeypatch.setattr('builtins.input', lambda _: 'xx')
+        __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)