Skip to content

Commit f080db6

Browse files
authored
Implement Mercurial support (#699)
2 parents 657c838 + a706bcf commit f080db6

File tree

13 files changed

+425
-50
lines changed

13 files changed

+425
-50
lines changed

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ skip_covered = true
164164
exclude_lines = [
165165
"pragma: no cover",
166166
"if TYPE_CHECKING:",
167+
# Empty functions of a Protocol definition (used in _vcs.py) can never be
168+
# executed. Ignoring them.
169+
": \\.\\.\\.$",
167170
]
168171
omit = [
169172
"src/towncrier/__main__.py",

src/towncrier/_git.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,33 @@
55

66
import os
77

8-
from subprocess import STDOUT, CalledProcessError, call, check_output
8+
from subprocess import STDOUT, call, check_output
9+
from typing import Container
10+
from warnings import warn
11+
12+
13+
def get_default_compare_branch(branches: Container[str]) -> str | None:
14+
if "origin/main" in branches:
15+
return "origin/main"
16+
if "origin/master" in branches:
17+
warn(
18+
'Using "origin/master" as default compare branch is deprecated '
19+
"and will be removed in a future version.",
20+
DeprecationWarning,
21+
stacklevel=2,
22+
)
23+
return "origin/master"
24+
return None
925

1026

1127
def remove_files(fragment_filenames: list[str]) -> None:
1228
if not fragment_filenames:
1329
return
1430

1531
# Filter out files that are unknown to git
16-
try:
17-
git_fragments = check_output(
18-
["git", "ls-files"] + fragment_filenames, encoding="utf-8"
19-
).split("\n")
20-
except CalledProcessError:
21-
# we may not be in a git repository
22-
git_fragments = []
32+
git_fragments = check_output(
33+
["git", "ls-files"] + fragment_filenames, encoding="utf-8"
34+
).split("\n")
2335

2436
git_fragments = [os.path.abspath(f) for f in git_fragments if os.path.isfile(f)]
2537
call(["git", "rm", "--quiet", "--force"] + git_fragments)

src/towncrier/_hg.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Copyright (c) towncrier contributors, 2025
2+
# See LICENSE for details.
3+
4+
from __future__ import annotations
5+
6+
import os
7+
8+
from subprocess import STDOUT, call, check_output
9+
from typing import Container
10+
11+
12+
def get_default_compare_branch(branches: Container[str]) -> str | None:
13+
if "default" in branches:
14+
return "default"
15+
return None
16+
17+
18+
def remove_files(fragment_filenames: list[str]) -> None:
19+
if not fragment_filenames:
20+
return
21+
22+
# Filter out files that are unknown to mercurial
23+
hg_fragments = (
24+
check_output(["hg", "files"] + fragment_filenames, encoding="utf-8")
25+
.strip()
26+
.split("\n")
27+
)
28+
29+
hg_fragments = [os.path.abspath(f) for f in hg_fragments if os.path.isfile(f)]
30+
fragment_filenames = [
31+
os.path.abspath(f) for f in fragment_filenames if os.path.isfile(f)
32+
]
33+
call(["hg", "rm", "--force"] + hg_fragments, encoding="utf-8")
34+
unknown_fragments = set(fragment_filenames) - set(hg_fragments)
35+
for unknown_fragment in unknown_fragments:
36+
os.remove(unknown_fragment)
37+
38+
39+
def stage_newsfile(directory: str, filename: str) -> None:
40+
call(["hg", "add", os.path.join(directory, filename)])
41+
42+
43+
def get_remote_branches(base_directory: str) -> list[str]:
44+
branches = check_output(
45+
["hg", "branches", "--template", "{branch}\n"],
46+
cwd=base_directory,
47+
encoding="utf-8",
48+
).splitlines()
49+
50+
return branches
51+
52+
53+
def list_changed_files_compared_to_branch(
54+
base_directory: str, compare_with: str, include_staged: bool
55+
) -> list[str]:
56+
output = check_output(
57+
["hg", "diff", "--stat", "-r", compare_with],
58+
cwd=base_directory,
59+
encoding="utf-8",
60+
stderr=STDOUT,
61+
).splitlines()
62+
63+
return [line.split("|")[0].strip() for line in output if "|" in line]

src/towncrier/_novcs.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Copyright (c) towncrier contributors, 2025
2+
# See LICENSE for details.
3+
4+
from __future__ import annotations
5+
6+
import os
7+
8+
from typing import Container
9+
10+
11+
def get_default_compare_branch(branches: Container[str]) -> str | None:
12+
return None
13+
14+
15+
def remove_files(fragment_filenames: list[str]) -> None:
16+
if not fragment_filenames:
17+
return
18+
19+
for fragment in fragment_filenames:
20+
os.remove(fragment)
21+
22+
23+
def stage_newsfile(directory: str, filename: str) -> None:
24+
return
25+
26+
27+
def get_remote_branches(base_directory: str) -> list[str]:
28+
return []
29+
30+
31+
def list_changed_files_compared_to_branch(
32+
base_directory: str, compare_with: str, include_staged: bool
33+
) -> list[str]:
34+
return []

src/towncrier/_vcs.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Copyright (c) towncrier contributors, 2025
2+
# See LICENSE for details.
3+
4+
from __future__ import annotations
5+
6+
import os
7+
8+
from typing import Container, Protocol
9+
10+
11+
class VCSMod(Protocol):
12+
def get_default_compare_branch(self, branches: Container[str]) -> str | None: ...
13+
def remove_files(self, fragment_filenames: list[str]) -> None: ...
14+
def stage_newsfile(self, directory: str, filename: str) -> None: ...
15+
def get_remote_branches(self, base_directory: str) -> list[str]: ...
16+
17+
def list_changed_files_compared_to_branch(
18+
self, base_directory: str, compare_with: str, include_staged: bool
19+
) -> list[str]: ...
20+
21+
22+
def _get_mod(base_directory: str) -> VCSMod:
23+
base_directory = os.path.abspath(base_directory)
24+
if os.path.exists(os.path.join(base_directory, ".git")):
25+
from . import _git
26+
27+
return _git
28+
elif os.path.exists(os.path.join(base_directory, ".hg")):
29+
from . import _hg
30+
31+
hg: VCSMod = _hg
32+
33+
return hg
34+
else:
35+
# No VCS was found in the current directory
36+
# We will try our luck in the parent directory.
37+
parent = os.path.dirname(base_directory)
38+
if parent == base_directory:
39+
# We reached the fs root, abandoning
40+
from . import _novcs
41+
42+
return _novcs
43+
44+
return _get_mod(parent)
45+
46+
47+
def get_default_compare_branch(
48+
base_directory: str, branches: Container[str]
49+
) -> str | None:
50+
return _get_mod(base_directory).get_default_compare_branch(branches)
51+
52+
53+
def remove_files(base_directory: str, fragment_filenames: list[str]) -> None:
54+
return _get_mod(base_directory).remove_files(fragment_filenames)
55+
56+
57+
def stage_newsfile(directory: str, filename: str) -> None:
58+
return _get_mod(directory).stage_newsfile(directory, filename)
59+
60+
61+
def get_remote_branches(base_directory: str) -> list[str]:
62+
return _get_mod(base_directory).get_remote_branches(base_directory)
63+
64+
65+
def list_changed_files_compared_to_branch(
66+
base_directory: str, compare_with: str, include_staged: bool
67+
) -> list[str]:
68+
return _get_mod(base_directory).list_changed_files_compared_to_branch(
69+
base_directory,
70+
compare_with,
71+
include_staged,
72+
)

src/towncrier/build.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from click import Context, Option, UsageError
1919

20-
from towncrier import _git
20+
from towncrier import _vcs
2121

2222
from ._builder import find_fragments, render_fragments, split_fragments
2323
from ._project import get_project_name, get_version
@@ -270,15 +270,15 @@ def __main(
270270
)
271271

272272
click.echo("Staging newsfile...", err=to_err)
273-
_git.stage_newsfile(base_directory, news_file)
273+
_vcs.stage_newsfile(base_directory, news_file)
274274

275275
if should_remove_fragment_files(
276276
fragment_filenames,
277277
answer_yes,
278278
answer_keep,
279279
):
280280
click.echo("Removing news fragments...", err=to_err)
281-
_git.remove_files(fragment_filenames)
281+
_vcs.remove_files(base_directory, fragment_filenames)
282282

283283
click.echo("Done!", err=to_err)
284284

src/towncrier/check.py

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,16 @@
88
import sys
99

1010
from subprocess import CalledProcessError
11-
from typing import Container
12-
from warnings import warn
1311

1412
import click
1513

1614
from ._builder import find_fragments
17-
from ._git import get_remote_branches, list_changed_files_compared_to_branch
1815
from ._settings import config_option_help, load_config_from_options
19-
20-
21-
def _get_default_compare_branch(branches: Container[str]) -> str | None:
22-
if "origin/main" in branches:
23-
return "origin/main"
24-
if "origin/master" in branches:
25-
warn(
26-
'Using "origin/master" as default compare branch is deprecated '
27-
"and will be removed in a future version.",
28-
DeprecationWarning,
29-
stacklevel=2,
30-
)
31-
return "origin/master"
32-
return None
16+
from ._vcs import (
17+
get_default_compare_branch,
18+
get_remote_branches,
19+
list_changed_files_compared_to_branch,
20+
)
3321

3422

3523
@click.command(name="check")
@@ -82,8 +70,8 @@ def __main(
8270
base_directory, config = load_config_from_options(directory, config_path)
8371

8472
if comparewith is None:
85-
comparewith = _get_default_compare_branch(
86-
get_remote_branches(base_directory=base_directory)
73+
comparewith = get_default_compare_branch(
74+
base_directory, get_remote_branches(base_directory=base_directory)
8775
)
8876

8977
if comparewith is None:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support was added for Mercurial SCM.

src/towncrier/test/test_check.py

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,13 @@
33

44
import os
55
import os.path
6-
import warnings
76

87
from pathlib import Path
98
from subprocess import check_call
109

1110
from click.testing import CliRunner
1211
from twisted.trial.unittest import TestCase
1312

14-
from towncrier import check
1513
from towncrier.build import _main as towncrier_build
1614
from towncrier.check import _main as towncrier_check
1715

@@ -401,24 +399,6 @@ def test_get_default_compare_branch_missing(self):
401399
self.assertEqual(1, result.exit_code)
402400
self.assertEqual("Could not detect default branch. Aborting.\n", result.output)
403401

404-
def test_get_default_compare_branch_main(self):
405-
"""
406-
If there's a remote branch origin/main, prefer it over everything else.
407-
"""
408-
branch = check._get_default_compare_branch(["origin/master", "origin/main"])
409-
410-
self.assertEqual("origin/main", branch)
411-
412-
def test_get_default_compare_branch_fallback(self):
413-
"""
414-
If there's origin/master and no main, use it and warn about it.
415-
"""
416-
with warnings.catch_warnings(record=True) as w:
417-
branch = check._get_default_compare_branch(["origin/master", "origin/foo"])
418-
419-
self.assertEqual("origin/master", branch)
420-
self.assertTrue(w[0].message.args[0].startswith('Using "origin/master'))
421-
422402
@with_isolated_runner
423403
def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner):
424404
"""

src/towncrier/test/test_git.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# See LICENSE for details.
33

44

5+
import warnings
6+
57
from twisted.trial.unittest import TestCase
68

79
from towncrier import _git
@@ -13,3 +15,21 @@ def test_empty_remove(self):
1315
If remove_files gets an empty list, it returns gracefully.
1416
"""
1517
_git.remove_files([])
18+
19+
def test_get_default_compare_branch_main(self):
20+
"""
21+
If there's a remote branch origin/main, prefer it over everything else.
22+
"""
23+
branch = _git.get_default_compare_branch(["origin/master", "origin/main"])
24+
25+
self.assertEqual("origin/main", branch)
26+
27+
def test_get_default_compare_branch_fallback(self):
28+
"""
29+
If there's origin/master and no main, use it and warn about it.
30+
"""
31+
with warnings.catch_warnings(record=True) as w:
32+
branch = _git.get_default_compare_branch(["origin/master", "origin/foo"])
33+
34+
self.assertEqual("origin/master", branch)
35+
self.assertTrue(w[0].message.args[0].startswith('Using "origin/master'))

0 commit comments

Comments
 (0)