Repository: swansonk14/typed-argument-parser

Issue:
Reproducibility info fails for git repo with no remote
If I have a git repository that does not have a remote, `get_reproducibility_info` will fail with a `CalledProcessError` exception. It appears that if a git repo is present, Tap assumes it has an `origin` remote, yet this need not be the case. I often use local git repos for initial development before pushing them somewhere.

Reproducer:
```
$ mkdir test
$ cd test
# Write test.py appropriately...
$ cat test.py
from tap import Tap

class ArgumentParser(Tap):
    foo: bool = False

if __name__ == '__main__':
    args = ArgumentParser().parse_args()
    print(args.get_reproducibility_info())

# Run with no git repo and it works fine:
$ python test.py           
{'command_line': 'python test.py', 'time': 'Tue Feb 28 13:03:00 2023'}

# Set up a git repo to break it:
$ git init
$ python test.py
Traceback (most recent call last):
  File "/home/ndryden/envs/test/lib/python3.9/site-packages/tap/utils.py", line 82, in get_git_url
    url = check_output(['git', 'remote', 'get-url', 'origin'], cwd=self.repo_path)
  File "/home/ndryden/envs/test/lib/python3.9/site-packages/tap/utils.py", line 45, in check_output
    output = subprocess.check_output(command, stderr=devnull, **kwargs).decode('utf-8').strip()
  File "/home/ndryden/envs/test/lib/python3.9/subprocess.py", line 424, in check_output
    return run(*popenargs, stdout=PIPE, timeout=timeout, check=True,
  File "/home/ndryden/envs/test/lib/python3.9/subprocess.py", line 528, in run
    raise CalledProcessError(retcode, process.args,
subprocess.CalledProcessError: Command '['git', 'remote', 'get-url', 'origin']' returned non-zero exit status 2.

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/ndryden/test/test.py", line 8, in <module>
    print(args.get_reproducibility_info())
  File "/home/ndryden/envs/test/lib/python3.9/site-packages/tap/tap.py", line 392, in get_reproducibility_info
    reproducibility['git_url'] = git_info.get_git_url(commit_hash=True)
  File "/home/ndryden/envs/test/lib/python3.9/site-packages/tap/utils.py", line 85, in get_git_url
    url = check_output(['git', 'config', '--get', 'remote.origin.url'], cwd=self.repo_path)
  File "/home/ndryden/envs/test/lib/python3.9/site-packages/tap/utils.py", line 45, in check_output
    output = subprocess.check_output(command, stderr=devnull, **kwargs).decode('utf-8').strip()
  File "/home/ndryden/envs/test/lib/python3.9/subprocess.py", line 424, in check_output
    return run(*popenargs, stdout=PIPE, timeout=timeout, check=True,
  File "/home/ndryden/envs/test/lib/python3.9/subprocess.py", line 528, in run
    raise CalledProcessError(retcode, process.args,
subprocess.CalledProcessError: Command '['git', 'config', '--get', 'remote.origin.url']' returned non-zero exit status 1.
```

This is because the remote `origin` does not exist, and `git remote get-url origin` returns status 2 in this case:
```
$ git remote get-url origin 
error: No such remote 'origin'
```

(Likewise, `git config --get remote.origin.url` returns status 1.)

You might be able to solve this by checking whether `origin` exists with `git remote -v` before executing this.

Incidentally, after adding a fake origin remote this reveals another bug, where `git rev-parse HEAD` fails when there are no commits, leading to a similar exception. Not sure if this deserves a separate issue (and seems like even more of an edge-case).

Discussion:

Patch:
diff --git a/src/tap/tap.py b/src/tap/tap.py
index 09a5134..17884b6 100644
--- a/src/tap/tap.py
+++ b/src/tap/tap.py
@@ -370,7 +370,7 @@ class Tap(ArgumentParser):
         pass
 
     @staticmethod
-    def get_reproducibility_info(repo_path: Optional[PathLike] = None) -> Dict[str, str]:
+    def get_reproducibility_info(repo_path: Optional[PathLike] = None) -> Dict[str, Optional[str]]:
         """Gets a dictionary of reproducibility information.
 
         Reproducibility information always includes:
@@ -380,7 +380,8 @@ class Tap(ArgumentParser):
         If git is installed, reproducibility information also includes:
         - git_root: The root of the git repo where the command is run.
         - git_url: The url of the current hash of the git repo where the command is run.
-                   Ex. https://github.com/swansonk14/rationale-alignment/tree/<hash>
+                   Ex. https://github.com/swansonk14/rationale-alignment/tree/<hash>.
+                   If it is a local repo, the url is None.
         - git_has_uncommitted_changes: Whether the current git repo has uncommitted changes.
 
         :param repo_path: Path to the git repo to examine for reproducibility info.
diff --git a/src/tap/utils.py b/src/tap/utils.py
index 37e6e16..b1feeeb 100644
--- a/src/tap/utils.py
+++ b/src/tap/utils.py
@@ -70,7 +70,20 @@ class GitInfo:
         """
         return check_output(["git", "rev-parse", "--show-toplevel"], cwd=self.repo_path)
 
-    def get_git_url(self, commit_hash: bool = True) -> str:
+    def get_git_version(self) -> tuple:
+        """Gets the version of git.
+
+        :return: The version of git, as a tuple of strings.
+
+        Example:
+        >>> get_git_version()
+        (2, 17, 1) # for git version 2.17.1
+        """
+        raw = check_output(["git", "--version"])
+        number_start_index = next(i for i, c in enumerate(raw) if c.isdigit())
+        return tuple(int(num) for num in raw[number_start_index:].split("."))
+
+    def get_git_url(self, commit_hash: bool = True) -> Optional[str]:
         """Gets the https url of the git repo where the command is run.
 
         :param commit_hash: If True, the url links to the latest local git commit hash.
@@ -78,11 +91,19 @@ class GitInfo:
         :return: The https url of the current git repo.
         """
         # Get git url (either https or ssh)
+        input_remote = (
+            ["git", "remote", "get-url", "origin"]
+            if self.get_git_version() >= (2, 0)
+            else ["git", "config", "--get", "remote.origin.url"]
+        )
         try:
-            url = check_output(["git", "remote", "get-url", "origin"], cwd=self.repo_path)
-        except subprocess.CalledProcessError:
-            # For git versions <2.0
-            url = check_output(["git", "config", "--get", "remote.origin.url"], cwd=self.repo_path)
+            url = check_output(input_remote, cwd=self.repo_path)
+        except subprocess.CalledProcessError as e:
+            if e.returncode == 2:
+                # https://git-scm.com/docs/git-remote#_exit_status
+                # 2: The remote does not exist.
+                return None
+            raise e
 
         # Remove .git at end
         url = url[: -len(".git")]


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index a3fabcf..01528c8 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -108,6 +108,17 @@ class GitTests(TestCase):
         url = f"{true_url}/tree/"
         self.assertEqual(self.git_info.get_git_url(commit_hash=True)[: len(url)], url)
 
+    def test_get_git_url_no_remote(self) -> None:
+        subprocess.run(["git", "remote", "remove", "origin"])
+        self.assertIsNone(self.git_info.get_git_url())
+
+    def test_get_git_version(self) -> None:
+        git_version = self.git_info.get_git_version()
+        self.assertEqual(len(git_version), 3)
+        self.assertIsInstance(git_version, tuple)
+        for v in git_version:
+            self.assertIsInstance(v, int)
+
     def test_has_uncommitted_changes_false(self) -> None:
         self.assertFalse(self.git_info.has_uncommitted_changes())