Repository: nosarthur/gita

Issue:
`gita shell` without argument fails with UnboundLocalError: local variable 'i' referenced before assignment
By now I have figured `gita shell` needs to be followed by arguments, but the error message is not very user-friendly. Typing `gita shell` by itself yields the following output:

```
gita shell
Traceback (most recent call last):
  File "/usr/local/bin/gita", line 8, in <module>
    sys.exit(main())
  File "xxxxxx/gita/gita/__main__.py", line 680, in main
    args.func(args)
  File "xxxxxx/gita/gita/__main__.py", line 378, in f_shell
    cmds = ' '.join(args.man[i:])  # join the shell command into a single string
UnboundLocalError: local variable 'i' referenced before assignment
```

Comments:

Patch:
diff --git a/gita/__main__.py b/gita/__main__.py
index 73e12fb..d489f55 100644
--- a/gita/__main__.py
+++ b/gita/__main__.py
@@ -303,20 +303,11 @@ def f_git_cmd(args: argparse.Namespace):
     Delegate git command/alias defined in `args.cmd`. Asynchronous execution is
     disabled for commands in the `args.async_blacklist`.
     """
-    repos = utils.get_repos()
-    groups = utils.get_groups()
-    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:
-            if k in repos:
-                chosen[k] = repos[k]
-            if k in groups:
-                for r in groups[k]['repos']:
-                    chosen[r] = repos[r]
-        repos = chosen
+    if '_parsed_repos' in args:
+        repos = args._parsed_repos
+    else:
+        repos, _ = utils.parse_repos_and_rest(args.repo)
+
     per_repo_cmds = []
     for prop in repos.values():
         cmds = args.cmd.copy()
@@ -351,29 +342,12 @@ def f_shell(args):
     Delegate shell command defined in `args.man`, which may or may not
     contain repo names.
     """
-    names = []
-    repos = utils.get_repos()
-    groups = utils.get_groups()
-    ctx = utils.get_context()
-    for i, word in enumerate(args.man):
-        if word in repos or word in groups:
-            names.append(word)
-        else:
-            break
-    args.repo = names
-    # TODO: redundant with f_git_cmd
-    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:
-            if k in repos:
-                chosen[k] = repos[k]
-            if k in groups:
-                for r in groups[k]['repos']:
-                    chosen[r] = repos[r]
-        repos = chosen
-    cmds = ' '.join(args.man[i:])  # join the shell command into a single string
+    repos, cmds = utils.parse_repos_and_rest(args.man)
+    if not cmds:
+        print('Missing commands')
+        sys.exit(2)
+
+    cmds = ' '.join(cmds)  # join the shell command into a single string
     for name, prop in repos.items():
         # TODO: pull this out as a function
         got = subprocess.run(cmds, cwd=prop['path'], shell=True,
@@ -387,16 +361,13 @@ def f_super(args):
     Delegate git command/alias defined in `args.man`, which may or may not
     contain repo names.
     """
-    names = []
-    repos = utils.get_repos()
-    groups = utils.get_groups()
-    for i, word in enumerate(args.man):
-        if word in repos or word in groups:
-            names.append(word)
-        else:
-            break
-    args.cmd = ['git'] + args.man[i:]
-    args.repo = names
+    repos, cmds = utils.parse_repos_and_rest(args.man)
+    if not cmds:
+        print('Missing commands')
+        sys.exit(2)
+
+    args.cmd = ['git'] + cmds
+    args._parsed_repos = repos
     args.shell = False
     f_git_cmd(args)
 
diff --git a/gita/utils.py b/gita/utils.py
index e55c008..2431fde 100644
--- a/gita/utils.py
+++ b/gita/utils.py
@@ -479,3 +479,38 @@ def get_cmds_from_files() -> Dict[str, Dict[str, str]]:
     # custom commands shadow default ones
     cmds.update(custom_cmds)
     return cmds
+
+
+def parse_repos_and_rest(input: List[str]
+        ) -> Tuple[Dict[str, Dict[str, str]], List[str]]:
+    """
+    Parse gita input arguments
+
+    @return: repos and the rest (e.g., gita shell and super commands)
+    """
+    i = None
+    names = []
+    repos = get_repos()
+    groups = get_groups()
+    ctx = get_context()
+    for i, word in enumerate(input):
+        if word in repos or word in groups:
+            names.append(word)
+        else:
+            break
+    else:  # all input is repos and groups, shift the index once more
+        if i is not None:
+            i += 1
+    if not names and ctx:
+        names = [ctx.stem]
+    if names:
+        chosen = {}
+        for k in names:
+            if k in repos:
+                chosen[k] = repos[k]
+            if k in groups:
+                for r in groups[k]['repos']:
+                    chosen[r] = repos[r]
+        # if not set here, all repos are chosen
+        repos = chosen
+    return repos, input[i:]


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 3ff0cb1..65096e9 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -10,6 +10,19 @@ from conftest import (
 )
 
 
+@pytest.mark.parametrize('input, expected', [
+    ([], ({'repo1': {'path': '/a/bcd/repo1', 'type': '', 'flags': []}, 'xxx': {'path': '/a/b/c/repo3', 'type': '', 'flags': []}, 'repo2': {'path': '/e/fgh/repo2', 'type': '', 'flags': []}}, [])),
+    (['st'], ({'repo1': {'path': '/a/bcd/repo1', 'type': '', 'flags': []}, 'xxx': {'path': '/a/b/c/repo3', 'type': '', 'flags': []}, 'repo2': {'path': '/e/fgh/repo2', 'type': '', 'flags': []}}, ['st'])),
+    (['repo1', 'st'], ({'repo1': {'flags': [], 'path': '/a/bcd/repo1', 'type': ''}}, ['st'])),
+    (['repo1'], ({'repo1': {'flags': [], 'path': '/a/bcd/repo1', 'type': ''}}, [])),
+    ])
+@patch('gita.utils.is_git', return_value=True)
+@patch('gita.common.get_config_fname', return_value=PATH_FNAME)
+def test_parse_repos_and_rest(mock_path_fname, _, input, expected):
+    got = utils.parse_repos_and_rest(input)
+    assert got == expected
+
+
 @pytest.mark.parametrize('repo_path, paths, expected', [
     ('/a/b/c/repo', ['/a/b'], (('b', 'c'), '/a')),
     ])