Repository: twisted/towncrier
Pull request #699 (2025-04-14 16:59:30)

Implement Mercurial support
# Description

Fixes #394

Add support for mercurial repositories.



Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 56bfc33..77fd061 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -164,6 +164,9 @@ skip_covered = true
 exclude_lines = [
     "pragma: no cover",
     "if TYPE_CHECKING:",
+    # Empty functions of a Protocol definition (used in _vcs.py) can never be
+    # executed. Ignoring them.
+    ": \\.\\.\\.$",
 ]
 omit = [
     "src/towncrier/__main__.py",
diff --git a/src/towncrier/_git.py b/src/towncrier/_git.py
index 457e70f..dc2197a 100644
--- a/src/towncrier/_git.py
+++ b/src/towncrier/_git.py
@@ -5,7 +5,23 @@ from __future__ import annotations
 
 import os
 
-from subprocess import STDOUT, CalledProcessError, call, check_output
+from subprocess import STDOUT, call, check_output
+from typing import Container
+from warnings import warn
+
+
+def get_default_compare_branch(branches: Container[str]) -> str | None:
+    if "origin/main" in branches:
+        return "origin/main"
+    if "origin/master" in branches:
+        warn(
+            'Using "origin/master" as default compare branch is deprecated '
+            "and will be removed in a future version.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return "origin/master"
+    return None
 
 
 def remove_files(fragment_filenames: list[str]) -> None:
@@ -13,13 +29,9 @@ def remove_files(fragment_filenames: list[str]) -> None:
         return
 
     # Filter out files that are unknown to git
-    try:
-        git_fragments = check_output(
-            ["git", "ls-files"] + fragment_filenames, encoding="utf-8"
-        ).split("\n")
-    except CalledProcessError:
-        # we may not be in a git repository
-        git_fragments = []
+    git_fragments = check_output(
+        ["git", "ls-files"] + fragment_filenames, encoding="utf-8"
+    ).split("\n")
 
     git_fragments = [os.path.abspath(f) for f in git_fragments if os.path.isfile(f)]
     call(["git", "rm", "--quiet", "--force"] + git_fragments)
diff --git a/src/towncrier/_hg.py b/src/towncrier/_hg.py
new file mode 100644
index 0000000..94cd06e
--- /dev/null
+++ b/src/towncrier/_hg.py
@@ -0,0 +1,63 @@
+# Copyright (c) towncrier contributors, 2025
+# See LICENSE for details.
+
+from __future__ import annotations
+
+import os
+
+from subprocess import STDOUT, call, check_output
+from typing import Container
+
+
+def get_default_compare_branch(branches: Container[str]) -> str | None:
+    if "default" in branches:
+        return "default"
+    return None
+
+
+def remove_files(fragment_filenames: list[str]) -> None:
+    if not fragment_filenames:
+        return
+
+    # Filter out files that are unknown to mercurial
+    hg_fragments = (
+        check_output(["hg", "files"] + fragment_filenames, encoding="utf-8")
+        .strip()
+        .split("\n")
+    )
+
+    hg_fragments = [os.path.abspath(f) for f in hg_fragments if os.path.isfile(f)]
+    fragment_filenames = [
+        os.path.abspath(f) for f in fragment_filenames if os.path.isfile(f)
+    ]
+    call(["hg", "rm", "--force"] + hg_fragments, encoding="utf-8")
+    unknown_fragments = set(fragment_filenames) - set(hg_fragments)
+    for unknown_fragment in unknown_fragments:
+        os.remove(unknown_fragment)
+
+
+def stage_newsfile(directory: str, filename: str) -> None:
+    call(["hg", "add", os.path.join(directory, filename)])
+
+
+def get_remote_branches(base_directory: str) -> list[str]:
+    branches = check_output(
+        ["hg", "branches", "--template", "{branch}\n"],
+        cwd=base_directory,
+        encoding="utf-8",
+    ).splitlines()
+
+    return branches
+
+
+def list_changed_files_compared_to_branch(
+    base_directory: str, compare_with: str, include_staged: bool
+) -> list[str]:
+    output = check_output(
+        ["hg", "diff", "--stat", "-r", compare_with],
+        cwd=base_directory,
+        encoding="utf-8",
+        stderr=STDOUT,
+    ).splitlines()
+
+    return [line.split("|")[0].strip() for line in output if "|" in line]
diff --git a/src/towncrier/_novcs.py b/src/towncrier/_novcs.py
new file mode 100644
index 0000000..32da477
--- /dev/null
+++ b/src/towncrier/_novcs.py
@@ -0,0 +1,34 @@
+# Copyright (c) towncrier contributors, 2025
+# See LICENSE for details.
+
+from __future__ import annotations
+
+import os
+
+from typing import Container
+
+
+def get_default_compare_branch(branches: Container[str]) -> str | None:
+    return None
+
+
+def remove_files(fragment_filenames: list[str]) -> None:
+    if not fragment_filenames:
+        return
+
+    for fragment in fragment_filenames:
+        os.remove(fragment)
+
+
+def stage_newsfile(directory: str, filename: str) -> None:
+    return
+
+
+def get_remote_branches(base_directory: str) -> list[str]:
+    return []
+
+
+def list_changed_files_compared_to_branch(
+    base_directory: str, compare_with: str, include_staged: bool
+) -> list[str]:
+    return []
diff --git a/src/towncrier/_vcs.py b/src/towncrier/_vcs.py
new file mode 100644
index 0000000..341ede6
--- /dev/null
+++ b/src/towncrier/_vcs.py
@@ -0,0 +1,72 @@
+# Copyright (c) towncrier contributors, 2025
+# See LICENSE for details.
+
+from __future__ import annotations
+
+import os
+
+from typing import Container, Protocol
+
+
+class VCSMod(Protocol):
+    def get_default_compare_branch(self, branches: Container[str]) -> str | None: ...
+    def remove_files(self, fragment_filenames: list[str]) -> None: ...
+    def stage_newsfile(self, directory: str, filename: str) -> None: ...
+    def get_remote_branches(self, base_directory: str) -> list[str]: ...
+
+    def list_changed_files_compared_to_branch(
+        self, base_directory: str, compare_with: str, include_staged: bool
+    ) -> list[str]: ...
+
+
+def _get_mod(base_directory: str) -> VCSMod:
+    base_directory = os.path.abspath(base_directory)
+    if os.path.exists(os.path.join(base_directory, ".git")):
+        from . import _git
+
+        return _git
+    elif os.path.exists(os.path.join(base_directory, ".hg")):
+        from . import _hg
+
+        hg: VCSMod = _hg
+
+        return hg
+    else:
+        # No VCS was found in the current directory
+        # We will try our luck in the parent directory.
+        parent = os.path.dirname(base_directory)
+        if parent == base_directory:
+            # We reached the fs root, abandoning
+            from . import _novcs
+
+            return _novcs
+
+        return _get_mod(parent)
+
+
+def get_default_compare_branch(
+    base_directory: str, branches: Container[str]
+) -> str | None:
+    return _get_mod(base_directory).get_default_compare_branch(branches)
+
+
+def remove_files(base_directory: str, fragment_filenames: list[str]) -> None:
+    return _get_mod(base_directory).remove_files(fragment_filenames)
+
+
+def stage_newsfile(directory: str, filename: str) -> None:
+    return _get_mod(directory).stage_newsfile(directory, filename)
+
+
+def get_remote_branches(base_directory: str) -> list[str]:
+    return _get_mod(base_directory).get_remote_branches(base_directory)
+
+
+def list_changed_files_compared_to_branch(
+    base_directory: str, compare_with: str, include_staged: bool
+) -> list[str]:
+    return _get_mod(base_directory).list_changed_files_compared_to_branch(
+        base_directory,
+        compare_with,
+        include_staged,
+    )
diff --git a/src/towncrier/build.py b/src/towncrier/build.py
index 3d18392..e56c367 100644
--- a/src/towncrier/build.py
+++ b/src/towncrier/build.py
@@ -17,7 +17,7 @@ import click
 
 from click import Context, Option, UsageError
 
-from towncrier import _git
+from towncrier import _vcs
 
 from ._builder import find_fragments, render_fragments, split_fragments
 from ._project import get_project_name, get_version
@@ -270,7 +270,7 @@ def __main(
     )
 
     click.echo("Staging newsfile...", err=to_err)
-    _git.stage_newsfile(base_directory, news_file)
+    _vcs.stage_newsfile(base_directory, news_file)
 
     if should_remove_fragment_files(
         fragment_filenames,
@@ -278,7 +278,7 @@ def __main(
         answer_keep,
     ):
         click.echo("Removing news fragments...", err=to_err)
-        _git.remove_files(fragment_filenames)
+        _vcs.remove_files(base_directory, fragment_filenames)
 
     click.echo("Done!", err=to_err)
 
diff --git a/src/towncrier/check.py b/src/towncrier/check.py
index bf3c6e2..17e1a2c 100644
--- a/src/towncrier/check.py
+++ b/src/towncrier/check.py
@@ -8,28 +8,16 @@ import os
 import sys
 
 from subprocess import CalledProcessError
-from typing import Container
-from warnings import warn
 
 import click
 
 from ._builder import find_fragments
-from ._git import get_remote_branches, list_changed_files_compared_to_branch
 from ._settings import config_option_help, load_config_from_options
-
-
-def _get_default_compare_branch(branches: Container[str]) -> str | None:
-    if "origin/main" in branches:
-        return "origin/main"
-    if "origin/master" in branches:
-        warn(
-            'Using "origin/master" as default compare branch is deprecated '
-            "and will be removed in a future version.",
-            DeprecationWarning,
-            stacklevel=2,
-        )
-        return "origin/master"
-    return None
+from ._vcs import (
+    get_default_compare_branch,
+    get_remote_branches,
+    list_changed_files_compared_to_branch,
+)
 
 
 @click.command(name="check")
@@ -82,8 +70,8 @@ def __main(
     base_directory, config = load_config_from_options(directory, config_path)
 
     if comparewith is None:
-        comparewith = _get_default_compare_branch(
-            get_remote_branches(base_directory=base_directory)
+        comparewith = get_default_compare_branch(
+            base_directory, get_remote_branches(base_directory=base_directory)
         )
 
     if comparewith is None:
diff --git a/src/towncrier/newsfragments/394.feature.rst b/src/towncrier/newsfragments/394.feature.rst
new file mode 100644
index 0000000..487857f
--- /dev/null
+++ b/src/towncrier/newsfragments/394.feature.rst
@@ -0,0 +1,1 @@
+Support was added for Mercurial SCM.


Test patch:
diff --git a/src/towncrier/test/test_check.py b/src/towncrier/test/test_check.py
index bfb7619..ed6a32e 100644
--- a/src/towncrier/test/test_check.py
+++ b/src/towncrier/test/test_check.py
@@ -3,7 +3,6 @@
 
 import os
 import os.path
-import warnings
 
 from pathlib import Path
 from subprocess import check_call
@@ -11,7 +10,6 @@ from subprocess import check_call
 from click.testing import CliRunner
 from twisted.trial.unittest import TestCase
 
-from towncrier import check
 from towncrier.build import _main as towncrier_build
 from towncrier.check import _main as towncrier_check
 
@@ -401,24 +399,6 @@ class TestChecker(TestCase):
         self.assertEqual(1, result.exit_code)
         self.assertEqual("Could not detect default branch. Aborting.\n", result.output)
 
-    def test_get_default_compare_branch_main(self):
-        """
-        If there's a remote branch origin/main, prefer it over everything else.
-        """
-        branch = check._get_default_compare_branch(["origin/master", "origin/main"])
-
-        self.assertEqual("origin/main", branch)
-
-    def test_get_default_compare_branch_fallback(self):
-        """
-        If there's origin/master and no main, use it and warn about it.
-        """
-        with warnings.catch_warnings(record=True) as w:
-            branch = check._get_default_compare_branch(["origin/master", "origin/foo"])
-
-        self.assertEqual("origin/master", branch)
-        self.assertTrue(w[0].message.args[0].startswith('Using "origin/master'))
-
     @with_isolated_runner
     def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner):
         """
diff --git a/src/towncrier/test/test_git.py b/src/towncrier/test/test_git.py
index e4f807a..4cd5352 100644
--- a/src/towncrier/test/test_git.py
+++ b/src/towncrier/test/test_git.py
@@ -2,6 +2,8 @@
 # See LICENSE for details.
 
 
+import warnings
+
 from twisted.trial.unittest import TestCase
 
 from towncrier import _git
@@ -13,3 +15,21 @@ class TestGit(TestCase):
         If remove_files gets an empty list, it returns gracefully.
         """
         _git.remove_files([])
+
+    def test_get_default_compare_branch_main(self):
+        """
+        If there's a remote branch origin/main, prefer it over everything else.
+        """
+        branch = _git.get_default_compare_branch(["origin/master", "origin/main"])
+
+        self.assertEqual("origin/main", branch)
+
+    def test_get_default_compare_branch_fallback(self):
+        """
+        If there's origin/master and no main, use it and warn about it.
+        """
+        with warnings.catch_warnings(record=True) as w:
+            branch = _git.get_default_compare_branch(["origin/master", "origin/foo"])
+
+        self.assertEqual("origin/master", branch)
+        self.assertTrue(w[0].message.args[0].startswith('Using "origin/master'))
diff --git a/src/towncrier/test/test_hg.py b/src/towncrier/test/test_hg.py
new file mode 100644
index 0000000..5a72519
--- /dev/null
+++ b/src/towncrier/test/test_hg.py
@@ -0,0 +1,89 @@
+# Copyright (c) towncrier contributors, 2025
+# See LICENSE for details.
+
+import os.path
+
+from pathlib import Path
+from subprocess import check_call
+
+from click.testing import CliRunner
+from twisted.trial.unittest import TestCase
+
+from towncrier import _hg, _vcs
+
+from .helpers import setup_simple_project, write
+
+
+def create_project(
+    pyproject_path="pyproject.toml", main_branch="main", extra_config=""
+):
+    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)
+    check_call(["hg", "branch", "otherbranch"])
+
+
+def initial_commit(branch="main"):
+    """
+    Create a mercurial repo, configure it and make an initial commit
+
+    There must be uncommitted changes
+    """
+    check_call(["hg", "init", "."])
+    check_call(["hg", "branch", branch])
+    commit("Initial Commit")
+
+
+def commit(message):
+    """Commit the repo in the current working directory
+
+    There must be uncommitted changes
+    """
+    check_call(["hg", "addremove", "."])
+    check_call(["hg", "commit", "-m", message])
+
+
+class TestHg(TestCase):
+    def test_get_default_compare_branch(self):
+        """
+        Test the 'get_default_compare_branch' behavior.
+        """
+        assert _hg.get_default_compare_branch(["main", "default"]) == "default"
+        assert _hg.get_default_compare_branch(["main", "a_topic"]) is None
+
+    def test_empty_remove(self):
+        """
+        If remove_files gets an empty list, it returns gracefully.
+        """
+        _hg.remove_files([])
+
+    def test_complete_scenario(self):
+        """
+        Tests all the _hg functions that interact with an actual repository.
+
+        Setting up a project is a little slow, hence the grouping of all the
+        tests in one.
+        """
+        runner = CliRunner()
+        with runner.isolated_filesystem():
+            create_project("pyproject.toml")
+
+            # make sure _vcs._get_mod properly detects a mercurial repo
+            self.assertEqual(_hg, _vcs._get_mod("."))
+
+            write("changes/000.misc.rst", "some change")
+
+            _hg.stage_newsfile(".", "changes/000.misc.rst")
+
+            commit("commit 1")
+
+            branches = sorted(_hg.get_remote_branches("."))
+            self.assertEqual(["main", "otherbranch"], branches)
+
+            self.assertEqual(
+                [os.path.join("changes", "000.misc.rst")],
+                _hg.list_changed_files_compared_to_branch(".", "main", False),
+            )
+
+            write("changes/001.misc.rst", "some change")
+            _hg.remove_files(["changes/000.misc.rst", "changes/001.misc.rst"])
diff --git a/src/towncrier/test/test_novcs.py b/src/towncrier/test/test_novcs.py
new file mode 100644
index 0000000..ef0ab6b
--- /dev/null
+++ b/src/towncrier/test/test_novcs.py
@@ -0,0 +1,31 @@
+from twisted.trial.unittest import TestCase
+
+from towncrier import _novcs
+
+
+class TestNOVCS(TestCase):
+    def test_get_default_compare_branch(self):
+        """
+        Witout a version control system, there is no default branch.
+        """
+        self.assertEqual(None, _novcs.get_default_compare_branch([]))
+
+    def test_empty_remove(self):
+        """
+        If remove_files gets an empty list, it returns gracefully.
+        """
+        _novcs.remove_files([])
+
+    def test_get_remote_branches(self):
+        """
+        There are no remote branches, when we don't have a VCS.
+        """
+        self.assertEqual([], _novcs.get_remote_branches("."))
+
+    def test_list_changed_files_compared_to_branch(self):
+        """
+        No changed files are detected without a VCS.
+        """
+        self.assertEqual(
+            [], _novcs.list_changed_files_compared_to_branch(".", "main", False)
+        )
diff --git a/src/towncrier/test/test_vcs.py b/src/towncrier/test/test_vcs.py
new file mode 100644
index 0000000..75e49ad
--- /dev/null
+++ b/src/towncrier/test/test_vcs.py
@@ -0,0 +1,82 @@
+# Copyright (c) towncrier contributors, 2025
+# See LICENSE for details.
+
+import os.path
+
+from pathlib import Path
+
+from click.testing import CliRunner
+from twisted.trial.unittest import TestCase
+
+from towncrier import _vcs
+
+from . import test_check, test_hg
+from .helpers import setup_simple_project, write
+
+
+def novcs_create_project(
+    pyproject_path="pyproject.toml", main_branch="main", extra_config=""
+):
+    setup_simple_project(pyproject_path=pyproject_path, extra_config=extra_config)
+    Path("foo/newsfragments/123.feature").write_text("Adds levitation")
+
+
+def novcs_commit(message):
+    pass
+
+
+class TestVCS(TestCase):
+    def test_vcs(self):
+        for tt in [
+            {
+                "name": "git",
+                "create_project": test_check.create_project,
+                "commit": test_check.commit,
+            },
+            {
+                "name": "mercurial",
+                "create_project": test_hg.create_project,
+                "commit": test_hg.commit,
+            },
+            {
+                "name": "novcs",
+                "create_project": novcs_create_project,
+                "commit": novcs_commit,
+            },
+        ]:
+            with self.subTest(tt["name"]):
+                create_project = tt["create_project"]
+                commit = tt["commit"]
+
+                runner = CliRunner()
+                with runner.isolated_filesystem():
+                    create_project("pyproject.toml")
+
+                    write("changes/000.misc.rst", "some change")
+
+                    _vcs.stage_newsfile(os.getcwd(), "changes/000.misc.rst")
+
+                    commit("commit 1")
+
+                    branches = sorted(_vcs.get_remote_branches("."))
+                    self.assertIn(branches, [[], ["main", "otherbranch"]])
+
+                    if tt["name"] != "novcs":
+                        self.assertIn(
+                            _vcs.list_changed_files_compared_to_branch(
+                                ".", "main", False
+                            ),
+                            [
+                                ["changes/000.misc.rst"],
+                                [os.path.join("changes", "000.misc.rst")],
+                            ],
+                        )
+
+                    write("changes/001.misc.rst", "some change")
+                    _vcs.remove_files(
+                        os.getcwd(),
+                        [
+                            os.path.abspath(f)
+                            for f in ["changes/000.misc.rst", "changes/001.misc.rst"]
+                        ],
+                    )