Skip to content

Commit 9dc3e16

Browse files
authored
Merge pull request #325 from callowayproject/317-no-commit-on-mercurial-repository
Refactor Mercurial SCM support and improve test coverage
2 parents f60d60b + 64da2d9 commit 9dc3e16

File tree

5 files changed

+348
-33
lines changed

5 files changed

+348
-33
lines changed

bumpversion/scm/hg.py

Lines changed: 121 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
"""Mercurial source control management."""
22

3+
import json
4+
import os
5+
import re
6+
import shlex
37
import subprocess
8+
from pathlib import Path
9+
from tempfile import NamedTemporaryFile
410
from typing import ClassVar, List, MutableMapping, Optional
511

612
from bumpversion.exceptions import DirtyWorkingDirectoryError, SignedTagsError
713
from bumpversion.scm.models import LatestTagInfo, SCMConfig
814
from bumpversion.ui import get_indented_logger
9-
from bumpversion.utils import Pathlike, run_command
15+
from bumpversion.utils import Pathlike, format_and_raise_error, is_subpath, run_command
1016

1117
logger = get_indented_logger(__name__)
1218

@@ -16,7 +22,6 @@ class Mercurial:
1622

1723
_TEST_AVAILABLE_COMMAND: ClassVar[List[str]] = ["hg", "root"]
1824
_COMMIT_COMMAND: ClassVar[List[str]] = ["hg", "commit", "--logfile"]
19-
_ALL_TAGS_COMMAND: ClassVar[List[str]] = ["hg", "log", '--rev="tag()"', '--template="{tags}\n"']
2025

2126
def __init__(self, config: SCMConfig):
2227
self.config = config
@@ -50,14 +55,83 @@ def latest_tag_info(self) -> LatestTagInfo:
5055
self._latest_tag_info = LatestTagInfo(**info)
5156
return self._latest_tag_info
5257

58+
def get_all_tags(self) -> List[str]:
59+
"""Return all tags in a mercurial repository."""
60+
try:
61+
result = run_command(["hg", "tags", "-T", "json"])
62+
tags = json.loads(result.stdout) if result.stdout else []
63+
return [tag["tag"] for tag in tags]
64+
except (
65+
FileNotFoundError,
66+
PermissionError,
67+
NotADirectoryError,
68+
subprocess.CalledProcessError,
69+
) as e:
70+
format_and_raise_error(e)
71+
return []
72+
5373
def add_path(self, path: Pathlike) -> None:
5474
"""Add a path to the Source Control Management repository."""
55-
pass
75+
repository_root = self.latest_tag_info().repository_root
76+
if not (repository_root and is_subpath(repository_root, path)):
77+
return
78+
79+
cwd = Path.cwd()
80+
temp_path = os.path.relpath(path, cwd)
81+
try:
82+
run_command(["hg", "add", str(temp_path)])
83+
except subprocess.CalledProcessError as e: # pragma: no-cover
84+
format_and_raise_error(e)
5685

5786
def commit_and_tag(self, files: List[Pathlike], context: MutableMapping, dry_run: bool = False) -> None:
5887
"""Commit and tag files to the repository using the configuration."""
88+
if dry_run:
89+
return
90+
91+
if self.config.commit:
92+
for path in files:
93+
self.add_path(path)
94+
95+
self.commit(context)
96+
97+
if self.config.tag:
98+
tag_name = self.config.tag_name.format(**context)
99+
tag_message = self.config.tag_message.format(**context)
100+
tag(tag_name, sign=self.config.sign_tags, message=tag_message)
101+
102+
# for m_tag_name in self.config.moveable_tags:
103+
# moveable_tag(m_tag_name)
104+
105+
def commit(self, context: MutableMapping) -> None:
106+
"""Commit the changes."""
107+
extra_args = shlex.split(self.config.commit_args) if self.config.commit_args else []
108+
109+
current_version = context.get("current_version", "")
110+
new_version = context.get("new_version", "")
111+
commit_message = self.config.message.format(**context)
59112

60-
def tag(self, name: str, sign: bool = False, message: Optional[str] = None) -> None:
113+
if not current_version: # pragma: no-coverage
114+
logger.warning("No current version given, using an empty string.")
115+
if not new_version: # pragma: no-coverage
116+
logger.warning("No new version given, using an empty string.")
117+
118+
with NamedTemporaryFile("wb", delete=False) as f:
119+
f.write(commit_message.encode("utf-8"))
120+
121+
env = os.environ.copy()
122+
env["BUMPVERSION_CURRENT_VERSION"] = current_version
123+
env["BUMPVERSION_NEW_VERSION"] = new_version
124+
125+
try:
126+
cmd = [*self._COMMIT_COMMAND, f.name, *extra_args]
127+
run_command(cmd, env=env)
128+
except (subprocess.CalledProcessError, TypeError) as exc: # pragma: no-coverage
129+
format_and_raise_error(exc)
130+
finally:
131+
os.unlink(f.name)
132+
133+
@staticmethod
134+
def tag(name: str, sign: bool = False, message: Optional[str] = None) -> None:
61135
"""
62136
Create a tag of the new_version in VCS.
63137
@@ -72,14 +146,10 @@ def tag(self, name: str, sign: bool = False, message: Optional[str] = None) -> N
72146
Raises:
73147
SignedTagsError: If ``sign`` is ``True``
74148
"""
75-
command = ["hg", "tag", name]
76-
if sign:
77-
raise SignedTagsError("Mercurial does not support signed tags.")
78-
if message:
79-
command += ["--message", message]
80-
run_command(command)
81-
82-
def assert_nondirty(self) -> None:
149+
tag(name, sign=sign, message=message)
150+
151+
@staticmethod
152+
def assert_nondirty() -> None:
83153
"""Assert that the working directory is clean."""
84154
assert_nondirty()
85155

@@ -95,21 +165,53 @@ def commit_info(config: SCMConfig) -> dict:
95165
A dictionary containing information about the latest commit.
96166
"""
97167
tag_pattern = config.tag_name.replace("{new_version}", ".*")
98-
info = dict.fromkeys(["dirty", "commit_sha", "distance_to_latest_tag", "current_version", "current_tag"])
168+
info = dict.fromkeys(
169+
[
170+
"dirty",
171+
"commit_sha",
172+
"distance_to_latest_tag",
173+
"current_version",
174+
"current_tag",
175+
"branch_name",
176+
"short_branch_name",
177+
"repository_root",
178+
]
179+
)
180+
99181
info["distance_to_latest_tag"] = 0
100-
result = run_command(["hg", "log", "-r", f"tag('re:{tag_pattern}')", "--template", "{latesttag}\n"])
101-
result.check_returncode()
182+
result = run_command(["hg", "log", "-r", f"tag('re:{tag_pattern}')", "-T", "json"])
183+
repo_path = run_command(["hg", "root"]).stdout.strip()
184+
185+
output_info = parse_commit_log(result.stdout, config)
186+
info |= output_info
102187

103-
if result.stdout:
104-
tag_string = result.stdout.splitlines(keepends=False)[-1]
105-
info["current_version"] = config.get_version_from_tag(tag_string)
106-
else:
188+
if not output_info:
107189
logger.debug("No tags found")
108190

191+
info["repository_root"] = Path(repo_path)
109192
info["dirty"] = len(run_command(["hg", "status", "-mard"]).stdout) != 0
110193
return info
111194

112195

196+
def parse_commit_log(log_string: str, config: SCMConfig) -> dict:
197+
"""Parse the commit log string."""
198+
output_info = json.loads(log_string) if log_string else {}
199+
if not output_info:
200+
return {}
201+
first_rev = output_info[0]
202+
branch_name = first_rev["branch"]
203+
short_branch_name = re.sub(r"([^a-zA-Z0-9]*)", "", branch_name).lower()[:20]
204+
205+
return {
206+
"current_version": config.get_version_from_tag(first_rev["tags"][0]),
207+
"current_tag": first_rev["tags"][0],
208+
"commit_sha": first_rev["node"],
209+
"distance_to_latest_tag": 0,
210+
"branch_name": branch_name,
211+
"short_branch_name": short_branch_name,
212+
}
213+
214+
113215
def tag(name: str, sign: bool = False, message: Optional[str] = None) -> None:
114216
"""
115217
Create a tag of the new_version in VCS.
@@ -135,7 +237,6 @@ def tag(name: str, sign: bool = False, message: Optional[str] = None) -> None:
135237

136238
def assert_nondirty() -> None:
137239
"""Assert that the working directory is clean."""
138-
print(run_command(["hg", "status", "-mard"]).stdout.splitlines())
139240
if lines := [
140241
line.strip()
141242
for line in run_command(["hg", "status", "-mard"]).stdout.splitlines()

bumpversion/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def set_nested_value(d: dict, value: Any, path: str) -> None:
116116
current_element = current_element[key]
117117

118118

119-
def format_and_raise_error(exc: Union[TypeError, subprocess.CalledProcessError]) -> None:
119+
def format_and_raise_error(exc: Union[BaseException, subprocess.CalledProcessError]) -> None:
120120
"""Format the error message from an exception and re-raise it as a BumpVersionError."""
121121
if isinstance(exc, subprocess.CalledProcessError):
122122
output = "\n".join([x for x in [exc.stdout, exc.stderr] if x])

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,15 @@ omit = [
106106
"*.tox*",
107107
]
108108
show_missing = true
109-
exclude_lines = [
109+
exclude_also = [
110110
"raise NotImplementedError",
111111
"pragma: no-coverage",
112112
"pragma: no-cov",
113+
"def __str__",
114+
"def __repr__",
113115
]
116+
skip_covered = true
117+
skip_empty = true
114118

115119
[tool.coverage.html]
116120
directory = "test-reports/htmlcov"

tests/test_cli/test_bump.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,6 @@ def test_dirty_work_dir_raises_error(repo: str, scm_command: str, request, runne
183183
)
184184

185185
# Assert
186-
print(f"{result.output=}")
187-
assert result.exit_code != 0
188186
assert "working directory is not clean" in result.output
189187

190188

0 commit comments

Comments
 (0)