Repository: twisted/towncrier

Issue:
add ability for towncrier's check to included staged files as part of its comparison against another branch
To enable using towncrier as a pre-commit hook (vs. a post-commit hook) it would be helpful if the `towncrier check` command included staged files as part of its comparison

This is a follow on to: https://github.com/twisted/towncrier/issues/600#issuecomment-2515722908

Discussion:

Patch:
diff --git a/docs/cli.rst b/docs/cli.rst
index c804046..12baf1d 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -121,3 +121,10 @@ By default, ``towncrier`` compares the current branch against ``origin/main`` (a
    Use ``REMOTE-BRANCH`` instead of ``origin/main``::
 
       $ towncrier check --compare-with origin/trunk
+
+.. option:: --staged
+
+   Include files that have been staged for commit when checking for news fragments::
+
+      $ towncrier check --staged
+      $ towncrier check --staged --compare-with origin/trunk
diff --git a/src/towncrier/_git.py b/src/towncrier/_git.py
index ff3d544..457e70f 100644
--- a/src/towncrier/_git.py
+++ b/src/towncrier/_git.py
@@ -41,7 +41,7 @@ def get_remote_branches(base_directory: str) -> list[str]:
 
 
 def list_changed_files_compared_to_branch(
-    base_directory: str, compare_with: str
+    base_directory: str, compare_with: str, include_staged: bool
 ) -> list[str]:
     output = check_output(
         ["git", "diff", "--name-only", compare_with + "..."],
@@ -49,5 +49,14 @@ def list_changed_files_compared_to_branch(
         encoding="utf-8",
         stderr=STDOUT,
     )
-
-    return output.strip().splitlines()
+    filenames = output.strip().splitlines()
+    if include_staged:
+        output = check_output(
+            ["git", "diff", "--name-only", "--cached"],
+            cwd=base_directory,
+            encoding="utf-8",
+            stderr=STDOUT,
+        )
+        filenames.extend(output.strip().splitlines())
+
+    return filenames
diff --git a/src/towncrier/check.py b/src/towncrier/check.py
index 8b05754..bf3c6e2 100644
--- a/src/towncrier/check.py
+++ b/src/towncrier/check.py
@@ -57,15 +57,27 @@ def _get_default_compare_branch(branches: Container[str]) -> str | None:
     metavar="FILE_PATH",
     help=config_option_help,
 )
-def _main(compare_with: str | None, directory: str | None, config: str | None) -> None:
+@click.option(
+    "--staged",
+    "staged",
+    is_flag=True,
+    default=False,
+    help="Include staged files as part of the branch checked in the --compare-with",
+)
+def _main(
+    compare_with: str | None, directory: str | None, config: str | None, staged: bool
+) -> None:
     """
     Check for new fragments on a branch.
     """
-    __main(compare_with, directory, config)
+    __main(compare_with, directory, config, staged)
 
 
 def __main(
-    comparewith: str | None, directory: str | None, config_path: str | None
+    comparewith: str | None,
+    directory: str | None,
+    config_path: str | None,
+    staged: bool,
 ) -> None:
     base_directory, config = load_config_from_options(directory, config_path)
 
@@ -80,7 +92,7 @@ def __main(
 
     try:
         files_changed = list_changed_files_compared_to_branch(
-            base_directory, comparewith
+            base_directory, comparewith, staged
         )
     except CalledProcessError as e:
         click.echo("git produced output while failing:")
diff --git a/src/towncrier/newsfragments/676.feature.rst b/src/towncrier/newsfragments/676.feature.rst
new file mode 100644
index 0000000..a0e0cff
--- /dev/null
+++ b/src/towncrier/newsfragments/676.feature.rst
@@ -0,0 +1,1 @@
+The `towncrier check` command now has a `--staged` flag to inspect the files staged for commit when checking for a news fragment: useful in a pre-commit hook


Test patch:
diff --git a/src/towncrier/test/test_check.py b/src/towncrier/test/test_check.py
index 9d8c05a..bfb7619 100644
--- a/src/towncrier/test/test_check.py
+++ b/src/towncrier/test/test_check.py
@@ -6,7 +6,7 @@ import os.path
 import warnings
 
 from pathlib import Path
-from subprocess import call
+from subprocess import check_call
 
 from click.testing import CliRunner
 from twisted.trial.unittest import TestCase
@@ -28,7 +28,7 @@ def create_project(
     setup_simple_project(pyproject_path=pyproject_path, extra_config=extra_config)
     Path("foo/newsfragments/123.feature").write_text("Adds levitation")
     initial_commit(branch=main_branch)
-    call(["git", "checkout", "-b", "otherbranch"])
+    check_call(["git", "checkout", "-b", "otherbranch"])
 
 
 def commit(message):
@@ -37,8 +37,17 @@ def commit(message):
     There must be uncommitted changes otherwise git will complain:
     "nothing to commit, working tree clean"
     """
-    call(["git", "add", "."])
-    call(["git", "commit", "-m", message])
+    check_call(["git", "add", "."])
+    check_call(["git", "commit", "-m", message])
+
+
+def stage():
+    """Stage a commit to the repo in the current working directory
+
+    There must be uncommitted changes otherwise git will complain:
+    "nothing to commit, working tree clean"
+    """
+    check_call(["git", "add", "."])
 
 
 def initial_commit(branch="main"):
@@ -50,11 +59,11 @@ def initial_commit(branch="main"):
     """
     # --initial-branch is explicitly set to `main` because
     # git has deprecated the default branch name.
-    call(["git", "init", f"--initial-branch={branch}"])
+    check_call(["git", "init", f"--initial-branch={branch}"])
     # Without ``git config` user.name and user.email `git commit` fails
     # unless the settings are set globally
-    call(["git", "config", "user.name", "user"])
-    call(["git", "config", "user.email", "user@example.com"])
+    check_call(["git", "config", "user.name", "user"])
+    check_call(["git", "config", "user.email", "user@example.com"])
     commit("Initial Commit")
 
 
@@ -156,8 +165,8 @@ class TestChecker(TestCase):
             with open(file_path, "w") as f:
                 f.write("import os")
 
-            call(["git", "add", "foo/somefile.py"])
-            call(["git", "commit", "-m", "add a file"])
+            check_call(["git", "add", "foo/somefile.py"])
+            check_call(["git", "commit", "-m", "add a file"])
 
             result = runner.invoke(towncrier_check, ["--compare-with", "master"])
 
@@ -204,6 +213,41 @@ class TestChecker(TestCase):
                 (result.output, str(fragment_path)),
             )
 
+    def test_fragment_exists_and_staged(self):
+        """A fragment exists and is added in staging. Pass only if staging on the command line"""
+        runner = CliRunner()
+
+        with runner.isolated_filesystem():
+            create_project(
+                "pyproject.toml",
+                main_branch="master",
+                extra_config="[[tool.towncrier.type]]\n"
+                'directory = "feature"\n'
+                'name = "Features"\n'
+                "showcontent = true\n"
+                "[[tool.towncrier.type]]\n"
+                'directory = "sut"\n'
+                'name = "System Under Test"\n'
+                "showcontent = true\n",
+            )
+
+            file_path = "foo/somefile.py"
+            write(file_path, "import os")
+
+            commit("add some files for test initialization")
+
+            fragment_path = Path("foo/newsfragments/1234.feature").absolute()
+            write(fragment_path, "Adds gravity back")
+            stage()
+
+            result = runner.invoke(towncrier_check, ["--compare-with", "master"])
+
+            self.assertEqual(1, result.exit_code)
+            result = runner.invoke(
+                towncrier_check, ["--staged", "--compare-with", "master"]
+            )
+            self.assertEqual(0, result.exit_code)
+
     def test_fragment_exists_and_in_check(self):
         """
         A fragment that exists but is not marked as check=False is
@@ -254,8 +298,8 @@ class TestChecker(TestCase):
             with open(fragment_path, "w") as f:
                 f.write("Adds gravity back")
 
-            call(["git", "add", fragment_path])
-            call(["git", "commit", "-m", "add a newsfragment"])
+            check_call(["git", "add", fragment_path])
+            check_call(["git", "commit", "-m", "add a newsfragment"])
 
             runner = CliRunner(mix_stderr=False)
             result = runner.invoke(towncrier_check, ["--compare-with", "master"])
@@ -310,16 +354,18 @@ class TestChecker(TestCase):
             commit("First release")
             # The news file is now created.
             self.assertIn("NEWS.rst", os.listdir("."))
-            call(["git", "checkout", "main"])
-            call(["git", "merge", "otherbranch", "-m", "Sync release in main branch."])
+            check_call(["git", "checkout", "main"])
+            check_call(
+                ["git", "merge", "otherbranch", "-m", "Sync release in main branch."]
+            )
 
             # We have a new feature branch that has a news fragment that
             # will be merged to the main branch.
-            call(["git", "checkout", "-b", "new-feature-branch"])
+            check_call(["git", "checkout", "-b", "new-feature-branch"])
             write("foo/newsfragments/456.feature", "Foo the bar")
             commit("A feature in the second release.")
-            call(["git", "checkout", "main"])
-            call(
+            check_call(["git", "checkout", "main"])
+            check_call(
                 [
                     "git",
                     "merge",
@@ -330,7 +376,7 @@ class TestChecker(TestCase):
             )
 
             # We now have the new release branch.
-            call(["git", "checkout", "-b", "next-release"])
+            check_call(["git", "checkout", "-b", "next-release"])
             runner.invoke(towncrier_build, ["--yes", "--version", "2.0"])
             commit("Second release")
 
@@ -392,7 +438,7 @@ class TestChecker(TestCase):
         (subproject1 / "changelog.d").mkdir(parents=True)
         (subproject1 / "changelog.d/123.feature").write_text("Adds levitation")
         initial_commit(branch=main_branch)
-        call(["git", "checkout", "-b", "otherbranch"])
+        check_call(["git", "checkout", "-b", "otherbranch"])
 
         # We add a code change but forget to add a news fragment.
         write(subproject1 / "foo/somefile.py", "import os")