Repository: nosarthur/gita

Issue:
Add context sub-command
This is inspired by the [taskwarrior](https://taskwarrior.org/docs/)

By using 
```
gita context project1
```
only repos in `project1` will be shown from this point on. For example `gita ll` only shows repos in `project1`.

Maybe `project1` can be a `group` for now.

To remove the context
```
gita context none
```

relates to #82 

Discussion:

Patch:
diff --git a/gita/__main__.py b/gita/__main__.py
index 1e47e31..03f3146 100644
--- a/gita/__main__.py
+++ b/gita/__main__.py
@@ -21,7 +21,7 @@ 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):
@@ -50,6 +50,9 @@ 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}
@@ -89,6 +92,25 @@ def f_group(args: argparse.Namespace):
             utils.write_to_groups_file({gname: sorted(args.to_group)}, 'a+')
 
 
+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:
+            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
@@ -108,6 +130,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:
@@ -214,6 +239,14 @@ def main(argv=None):
                       help="show repos in the chosen group")
     p_ll.set_defaults(func=f_ll)
 
+    p_context = subparsers.add_parser('context',
+            help='')
+    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',
diff --git a/gita/utils.py b/gita/utils.py
index dd5cca3..900aefa 100644
--- a/gita/utils.py
+++ b/gita/utils.py
@@ -3,6 +3,7 @@ import yaml
 import asyncio
 import platform
 from functools import lru_cache
+from pathlib import Path
 from typing import List, Dict, Coroutine, Union
 
 from . import info
@@ -17,6 +18,17 @@ def get_config_fname(fname: str) -> str:
     return os.path.join(root, fname)
 
 
+@lru_cache()
+def get_context() -> Union[Path, None]:
+    """
+    Return the context: either a group name or 'none'
+    """
+    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()
 def get_repos() -> Dict[str, str]:
     """
diff --git a/setup.py b/setup.py
index 32d6859..7137178 100644
--- a/setup.py
+++ b/setup.py
@@ -7,9 +7,9 @@ with open('README.md', encoding='utf-8') as f:
 setup(
     name='gita',
     packages=['gita'],
-    version='0.11.2',
+    version='0.11.3',
     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',


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index fcea12b..708ba26 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,5 +1,6 @@
 import pytest
 from unittest.mock import patch
+from pathlib import Path
 import argparse
 import shlex
 
@@ -7,7 +8,7 @@ from gita import __main__
 from gita import utils
 from conftest import (
     PATH_FNAME, PATH_FNAME_EMPTY, PATH_FNAME_CLASH, GROUP_FNAME,
-    async_mock
+    async_mock, TEST_DIR,
 )
 
 
@@ -131,6 +132,46 @@ def test_superman(mock_run, _, input):
     mock_run.assert_called_once_with(expected_cmds, cwd='path7')
 
 
+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()
+
+
 class TestGroupCmd:
 
     @patch('gita.utils.get_config_fname', return_value=GROUP_FNAME)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 3128041..dfabcee 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -4,7 +4,7 @@ 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,
 )
 
 
@@ -48,6 +48,17 @@ def test_get_repos(mock_path_fname, _, path_fname, 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', [
     (GROUP_FNAME, {'xx': ['a', 'b'], 'yy': ['a', 'c', 'd']}),
 ])