Repository: jorisroovers/gitlint

Issue:
Gitlint requires at least one commit to function
### The Problem

*Gitlint version: 0.15.1*

When linting a staged commit with `--staged` in an empty repo or orphan branch with no commits a `GitContextError` is raised with the message:

> Current branch has no commits. Gitlint requires at least one commit to function.

While the error is clear, I feel that under the circumstances gitlint should give a little leeway, especially since the exception in this case comes down to a debug log line in **gitlint/lint.py** (when removed, gitlint works without issue):

```
 10 class GitLinter:
    ...
 69     def lint(self, commit):
 70         """ Lint the last commit in a given git context by applying all ignore, title, body and commit rules. """
 71         LOG.debug("Linting commit %s", commit.sha or "[SHA UNKNOWN]")
 72         LOG.debug("Commit Object\n" + str(commit))
                                          ^
```
In **gitlint/git.py**:

```
127 class GitCommit:
    ...
162     def __str__(self):                                                                       
163         date_str = arrow.get(self.date).format(GIT_TIMEFORMAT) if self.date else None
164         return (f"--- Commit Message ----\n{self.message}\n"
165                 "--- Meta info ---------\n"
166                 f"Author: {self.author_name} <{self.author_email}>\n"
167                 f"Date:   {date_str}\n"
168                 f"is-merge-commit:  {self.is_merge_commit}\n"
169                 f"is-fixup-commit:  {self.is_fixup_commit}\n"
170                 f"is-squash-commit: {self.is_squash_commit}\n"
171                 f"is-revert-commit: {self.is_revert_commit}\n"
172                 f"Branches: {self.branches}\n"
                                 ^
173                 f"Changed Files: {self.changed_files}\n"
174                 "-----------------------")
```

`StagedLocalGitCommit.branches` attempts to get the current branch name by calling `git rev-parse --abbrev-ref HEAD` which fails.


### Possible Solutions

#### 1) Be forgiving of `GitContextError` in `gitlint.git.StagedLocalGitCommit.branches`

Catch the exception and return an empty list.  I don't know where this property is normally used other than debugging messages but if users can handle an empty list then this seems reasonable.

#### 2) Use `git branch --show-current` to get the branch name

The `--show-current` option was added to git in 2.22.0 (June 2019) so may be a little new to rely on.  It can also return empty output for detached HEAD which will need to be handled.

Comments:

Patch:
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index b477e11..4e76ff1 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -7,7 +7,7 @@ jobs:
     runs-on: "ubuntu-latest"
     strategy:
       matrix:
-        python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", pypy3]
+        python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", pypy-3.9]
         os: ["macos-latest", "ubuntu-latest"]
     steps:
       - uses: actions/checkout@v3
@@ -21,7 +21,7 @@ jobs:
         run: mv .git ._git
 
       - name: Setup python
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v3.1.2
         with:
           python-version: ${{ matrix.python-version }}
 
@@ -92,7 +92,7 @@ jobs:
         run: Rename-Item .git ._git
     
       - name: Setup python
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v3.1.2
         with:
           python-version: ${{ matrix.python-version }}
 
diff --git a/gitlint-core/gitlint/git.py b/gitlint-core/gitlint/git.py
index 2ac8b3d..117f5f4 100644
--- a/gitlint-core/gitlint/git.py
+++ b/gitlint-core/gitlint/git.py
@@ -337,7 +337,11 @@ class GitContext(PropertyCache):
     @property
     @cache
     def current_branch(self):
-        current_branch = _git("rev-parse", "--abbrev-ref", "HEAD", _cwd=self.repository_path).strip()
+        try:
+            current_branch = _git("rev-parse", "--abbrev-ref", "HEAD", _cwd=self.repository_path).strip()
+        except GitContextError:
+            # Maybe there is no commit.  Try another way to get current branch (need Git 2.22+)
+            current_branch = _git("branch", "--show-current", _cwd=self.repository_path).strip()
         return current_branch
 
     @staticmethod


Test patch:
diff --git a/gitlint-core/gitlint/tests/cli/test_cli_hooks.py b/gitlint-core/gitlint/tests/cli/test_cli_hooks.py
index 825345f..94c35dc 100644
--- a/gitlint-core/gitlint/tests/cli/test_cli_hooks.py
+++ b/gitlint-core/gitlint/tests/cli/test_cli_hooks.py
@@ -132,6 +132,9 @@ class CLIHookTests(BaseTestCase):
         for i in range(0, len(set_editors)):
             if set_editors[i]:
                 os.environ['EDITOR'] = set_editors[i]
+            else:
+                # When set_editors[i] == None, ensure we don't fallback to EDITOR set in shell invocating the tests
+                os.environ.pop('EDITOR', None)
 
             with self.patch_input(['e', 'e', 'n']):
                 with self.tempdir() as tmpdir:
diff --git a/gitlint-core/gitlint/tests/git/test_git.py b/gitlint-core/gitlint/tests/git/test_git.py
index 7b9b7c6..e9e5645 100644
--- a/gitlint-core/gitlint/tests/git/test_git.py
+++ b/gitlint-core/gitlint/tests/git/test_git.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 import os
 
-from unittest.mock import patch
+from unittest.mock import patch, call
 
 from gitlint.shell import ErrorReturnCode, CommandNotFound
 
@@ -64,8 +64,12 @@ class GitTests(BaseTestCase):
 
         # assert that commit message was read using git command
         sh.git.assert_called_once_with("log", "-1", "--pretty=%H", **self.expected_sh_special_args)
-        sh.git.reset_mock()
 
+    @patch('gitlint.git.sh')
+    def test_git_no_commits_get_branch(self, sh):
+        """ Check that we can still read the current branch name when there's no commits. This is useful when
+            when trying to lint the first commit using the --staged flag.
+        """
         # Unknown reference 'HEAD' commits: returned by 'git rev-parse'
         err = (b"HEAD"
                b"fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree."
@@ -74,15 +78,22 @@ class GitTests(BaseTestCase):
 
         sh.git.side_effect = [
             "#\n",  # git config --get core.commentchar
-            ErrorReturnCode("rev-parse --abbrev-ref HEAD", b"", err)
+            ErrorReturnCode("rev-parse --abbrev-ref HEAD", b"", err),
+            'test-branch',  # git branch --show-current
         ]
 
-        with self.assertRaisesMessage(GitContextError, expected_msg):
-            context = GitContext.from_commit_msg("test")
-            context.current_branch
+        context = GitContext.from_commit_msg("test")
+        self.assertEqual(context.current_branch, 'test-branch')
 
-        # assert that commit message was read using git command
-        sh.git.assert_called_with("rev-parse", "--abbrev-ref", "HEAD", _tty_out=False, _cwd=None)
+        # assert that we try using `git rev-parse` first, and if that fails (as will be the case with the first commit),
+        #  we fallback to `git branch --show-current` to determine the current branch name.
+        expected_calls = [
+            call("config", "--get", "core.commentchar", _tty_out=False, _cwd=None, _ok_code=[0, 1]),
+            call("rev-parse", "--abbrev-ref", "HEAD", _tty_out=False, _cwd=None),
+            call("branch", "--show-current", _tty_out=False, _cwd=None)
+        ]
+
+        self.assertEqual(sh.git.mock_calls, expected_calls)
 
     @patch("gitlint.git._git")
     def test_git_commentchar(self, git):
diff --git a/qa/test_gitlint.py b/qa/test_gitlint.py
index 0200d76..36fba69 100644
--- a/qa/test_gitlint.py
+++ b/qa/test_gitlint.py
@@ -178,7 +178,7 @@ class IntegrationTests(BaseTestCase):
         expected = "Missing git configuration: please set user.email\n"
         self.assertEqualStdout(output, expected)
 
-    def test_git_errors(self):
+    def test_git_empty_repo(self):
         # Repo has no commits: caused by `git log`
         empty_git_repo = self.create_tmp_git_repo()
         output = gitlint(_cwd=empty_git_repo, _tty_in=True, _ok_code=[self.GIT_CONTEXT_ERROR_CODE])
@@ -186,7 +186,13 @@ class IntegrationTests(BaseTestCase):
         expected = "Current branch has no commits. Gitlint requires at least one commit to function.\n"
         self.assertEqualStdout(output, expected)
 
-        # Repo has no commits: caused by `git rev-parse`
+    def test_git_empty_repo_staged(self):
+        """ When repo is empty, we can still use gitlint when using --staged flag and piping a message into it """
+        empty_git_repo = self.create_tmp_git_repo()
+        expected = ("1: T3 Title has trailing punctuation (.): \"WIP: Pïpe test.\"\n"
+                    "1: T5 Title contains the word \'WIP\' (case-insensitive): \"WIP: Pïpe test.\"\n"
+                    "3: B6 Body message is missing\n")
+
         output = gitlint(echo("WIP: Pïpe test."), "--staged", _cwd=empty_git_repo, _tty_in=False,
-                         _err_to_out=True, _ok_code=[self.GIT_CONTEXT_ERROR_CODE])
+                         _err_to_out=True, _ok_code=[3])
         self.assertEqualStdout(output, expected)
diff --git a/test-requirements.txt b/test-requirements.txt
index 78a383d..df3c4ab 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -4,5 +4,5 @@ python-coveralls==2.9.3
 radon==5.1.0
 flake8-polyfill==1.0.2 # Required when installing both flake8 and radon>=4.3.1 
 pytest==7.0.1;
-pylint==2.12.2;
+pylint==2.13.5;
 -r requirements.txt