Repository: nosarthur/gita

Issue:
Allow execution of nested `git` commands
Hi, thanks again for this excellent tool. The motivation comes from trying to get `gita` to checkout the latest tag for an entire group. The command is `git describe --abbrev=0 --tags` to get the latest tag, and `git checkout <tag>` to checkout the tag. This can be combined in bash with
```
git checkout $(git describe --abbrev=0 --tags)
```
I'm wondering if it's possible to execute this for multiple repos. Both the `cmds.yml` approach and using superman did not work for me.

As a related issue, once a tag is checked out, `gita ll` simply shows `HEAD` for the status column. Is there a way to get it to display the checked out tag? I understand it's in detached head status, but `git status` shows `HEAD detached at v2.2.1` for example so there should be a way to fetch and display this information somehow.

Discussion:

Patch:
diff --git a/README.md b/README.md
index f76c759..1c9b321 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ 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).
+To run arbitrary shell command, see the [shell mode section](#shell).
 
 The branch color distinguishes 5 situations between local and remote branches:
 
@@ -157,6 +158,21 @@ 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`
 
+## <a name='shell'></a> Shell mode
+
+The shell mode delegates any shell command.
+Usage:
+
+```
+gita shell [repo-name(s) or group-name(s)] <any-shell-command>
+```
+
+Here `repo-name(s)` or `group-name(s)` are optional, and their absence means all repos.
+For example,
+
+- `gita shell ll` lists contents for all repos
+- `gita shell repo1 mkdir docs` create a new directory `docs` in repo1
+
 ## <a name='custom'></a> Customization
 
 ### user-defined sub-command using yaml file
@@ -195,6 +211,12 @@ comaster:
   help: checkout the master branch
 ```
 
+### customize the local/remote relationship coloring displayed by the `gita ll` command
+
+You can see the default color scheme and the available colors via `gita color`.
+To change the color coding, use `gita color set <situation> <color>`.
+The configuration is saved in `$XDG_CONFIG_HOME/gita/color.yml`.
+
 ### customize information displayed by the `gita ll` command
 
 You can customize the information displayed by `gita ll`.
@@ -208,12 +230,6 @@ For example, the default information items setting corresponds to
 - commit_msg
 ```
 
-### customize the local/remote relationship coloring displayed by the `gita ll` command
-
-You can see the default color scheme and the available colors via `gita color`.
-To change the color coding, use `gita color set <situation> <color>`.
-The configuration is saved in `$XDG_CONFIG_HOME/gita/color.yml`.
-
 ## Requirements
 
 Gita requires Python 3.6 or higher, due to the use of
diff --git a/gita/__main__.py b/gita/__main__.py
index 43116df..beecab2 100644
--- a/gita/__main__.py
+++ b/gita/__main__.py
@@ -222,6 +222,42 @@ def f_git_cmd(args: argparse.Namespace):
                 subprocess.run(cmds, cwd=path)
 
 
+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]:
+                    chosen[r] = repos[r]
+        repos = chosen
+    cmds = args.man[i:]
+    for name, path in repos.items():
+        # TODO: pull this out as a function
+        got = subprocess.run(cmds, cwd=path, check=True,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT)
+        print(utils.format_output(got.stdout.decode(), name))
+
+
 def f_super(args):
     """
     Delegate git command/alias defined in `args.man`, which may or may not
@@ -415,6 +451,21 @@ def main(argv=None):
         "Another: gita super checkout master ")
     p_super.set_defaults(func=f_super)
 
+    # shell mode
+    p_shell = subparsers.add_parser(
+        'shell',
+        description='shell mode: delegate any shell command in specified or '
+        'all repo(s).\n'
+        'Examples:\n \t gita shell pwd\n'
+        '\t gita shell repo1 repo2 repo3 touch xx')
+    p_shell.add_argument(
+        'man',
+        nargs=argparse.REMAINDER,
+        help="execute arbitrary shell command for specified or all repos "
+        "Example: gita shell myrepo1 ls"
+        "Another: gita shell git checkout master ")
+    p_shell.set_defaults(func=f_shell)
+
     # sub-commands that fit boilerplate
     cmds = utils.get_cmds_from_files()
     for name, data in cmds.items():
diff --git a/gita/utils.py b/gita/utils.py
index 1d334ad..9572f02 100644
--- a/gita/utils.py
+++ b/gita/utils.py
@@ -167,7 +167,7 @@ async def run_async(repo_name: str, path: str, cmds: List[str]) -> Union[None, s
     stdout, stderr = await process.communicate()
     for pipe in (stdout, stderr):
         if pipe:
-            print(format_output(pipe.decode(), f'{repo_name}: '))
+            print(format_output(pipe.decode(), repo_name))
     # The existence of stderr is not good indicator since git sometimes write
     # to stderr even if the execution is successful, e.g. git fetch
     if process.returncode != 0:
@@ -178,7 +178,7 @@ def format_output(s: str, prefix: str):
     """
     Prepends every line in given string with the given prefix.
     """
-    return ''.join([f'{prefix}{line}' for line in s.splitlines(keepends=True)])
+    return ''.join([f'{prefix}: {line}' for line in s.splitlines(keepends=True)])
 
 
 def exec_async_tasks(tasks: List[Coroutine]) -> List[Union[None, str]]:
diff --git a/setup.py b/setup.py
index c6dbe0e..c729963 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.12.6',
+    version='0.12.7',
     license='MIT',
     description='Manage multiple git repos with sanity',
     long_description=long_description,


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index 2f51acf..ad501f7 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -159,6 +159,20 @@ def test_superman(mock_run, _, input):
     mock_run.assert_called_once_with(expected_cmds, cwd='path7')
 
 
+@pytest.mark.parametrize('input', [
+    'diff --name-only --staged',
+    "commit -am 'lala kaka'",
+])
+@patch('gita.utils.get_repos', return_value={'repo7': 'path7'})
+@patch('subprocess.run')
+def test_shell(mock_run, _, input):
+    mock_run.reset_mock()
+    args = ['shell', 'repo7'] + shlex.split(input)
+    __main__.main(args)
+    expected_cmds = shlex.split(input)
+    mock_run.assert_called_once_with(expected_cmds, cwd='path7', check=True, stderr=-2, stdout=-1)
+
+
 class TestContext:
 
     @patch('gita.utils.get_context', return_value=None)