Coverage for /Users/OORDCOR/Documents/code/bump-my-version/bumpversion/scm.py: 0%
214 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-15 08:51 -0600
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-15 08:51 -0600
1"""Version control system management."""
3import os
4import re
5import subprocess
6from dataclasses import dataclass
7from pathlib import Path
8from tempfile import NamedTemporaryFile
9from typing import TYPE_CHECKING, ClassVar, List, MutableMapping, Optional, Type, Union
11from bumpversion.ui import get_indented_logger
12from bumpversion.utils import extract_regex_flags
14if TYPE_CHECKING: # pragma: no-coverage
15 from bumpversion.config import Config
17from bumpversion.exceptions import DirtyWorkingDirectoryError, SignedTagsError
19logger = get_indented_logger(__name__)
22@dataclass
23class SCMInfo:
24 """Information about the current source code manager and state."""
26 tool: Optional[Type["SourceCodeManager"]] = None
27 commit_sha: Optional[str] = None
28 distance_to_latest_tag: Optional[int] = None
29 current_version: Optional[str] = None
30 branch_name: Optional[str] = None
31 short_branch_name: Optional[str] = None
32 dirty: Optional[bool] = None
34 def __str__(self):
35 return self.__repr__()
37 def __repr__(self):
38 tool_name = self.tool.__name__ if self.tool else "No SCM tool"
39 return (
40 f"SCMInfo(tool={tool_name}, commit_sha={self.commit_sha}, "
41 f"distance_to_latest_tag={self.distance_to_latest_tag}, current_version={self.current_version}, "
42 f"dirty={self.dirty})"
43 )
46class SourceCodeManager:
47 """Base class for version control systems."""
49 _TEST_USABLE_COMMAND: ClassVar[List[str]] = []
50 _COMMIT_COMMAND: ClassVar[List[str]] = []
51 _ALL_TAGS_COMMAND: ClassVar[List[str]] = []
53 @classmethod
54 def commit(cls, message: str, current_version: str, new_version: str, extra_args: Optional[list] = None) -> None:
55 """Commit the changes."""
56 extra_args = extra_args or []
58 with NamedTemporaryFile("wb", delete=False) as f:
59 f.write(message.encode("utf-8"))
61 env = os.environ.copy()
62 env["HGENCODING"] = "utf-8"
63 env["BUMPVERSION_CURRENT_VERSION"] = current_version
64 env["BUMPVERSION_NEW_VERSION"] = new_version
66 try:
67 cmd = [*cls._COMMIT_COMMAND, f.name, *extra_args]
68 subprocess.run(cmd, env=env, capture_output=True, check=True) # noqa: S603
69 except subprocess.CalledProcessError as exc: # pragma: no-coverage
70 err_msg = f"Failed to run {exc.cmd}: return code {exc.returncode}, output: {exc.output}"
71 logger.exception(err_msg)
72 raise exc
73 finally:
74 os.unlink(f.name)
76 @classmethod
77 def is_usable(cls) -> bool:
78 """Is the VCS implementation usable."""
79 try:
80 result = subprocess.run(cls._TEST_USABLE_COMMAND, check=True, capture_output=True) # noqa: S603
81 return result.returncode == 0
82 except (FileNotFoundError, PermissionError, NotADirectoryError, subprocess.CalledProcessError):
83 return False
85 @classmethod
86 def assert_nondirty(cls) -> None:
87 """Assert that the working directory is not dirty."""
88 raise NotImplementedError()
90 @classmethod
91 def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:
92 """Return information about the latest tag."""
93 raise NotImplementedError()
95 @classmethod
96 def add_path(cls, path: Union[str, Path]) -> None:
97 """Add a path to the VCS."""
98 raise NotImplementedError()
100 @classmethod
101 def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> None:
102 """Create a tag of the new_version in VCS."""
103 raise NotImplementedError
105 @classmethod
106 def get_all_tags(cls) -> List[str]:
107 """Return all tags in VCS."""
108 try:
109 result = subprocess.run(cls._ALL_TAGS_COMMAND, text=True, check=True, capture_output=True) # noqa: S603
110 return result.stdout.splitlines()
111 except (FileNotFoundError, PermissionError, NotADirectoryError, subprocess.CalledProcessError):
112 return []
114 @classmethod
115 def get_version_from_tag(cls, tag: str, tag_name: str, parse_pattern: str) -> Optional[str]:
116 """Return the version from a tag."""
117 version_pattern = parse_pattern.replace("\\\\", "\\")
118 version_pattern, regex_flags = extract_regex_flags(version_pattern)
119 rep = tag_name.replace("{new_version}", f"(?P<current_version>{version_pattern})")
120 rep = f"{regex_flags}{rep}"
121 tag_regex = re.compile(rep)
122 return match["current_version"] if (match := tag_regex.match(tag)) else None
124 @classmethod
125 def commit_to_scm(
126 cls,
127 files: List[Union[str, Path]],
128 config: "Config",
129 context: MutableMapping,
130 extra_args: Optional[List[str]] = None,
131 dry_run: bool = False,
132 ) -> None:
133 """Commit the files to the source code management system."""
134 if not cls.is_usable():
135 logger.error("SCM tool '%s' is unusable, unable to commit.", cls.__name__)
136 return
138 if not config.commit:
139 logger.info("Would not commit")
140 return
142 do_commit = not dry_run
143 logger.info(
144 "%s %s commit",
145 "Preparing" if do_commit else "Would prepare",
146 cls.__name__,
147 )
148 logger.indent()
149 for path in files:
150 logger.info(
151 "%s changes in file '%s' to %s",
152 "Adding" if do_commit else "Would add",
153 path,
154 cls.__name__,
155 )
157 if do_commit:
158 cls.add_path(path)
160 commit_message = config.message.format(**context)
162 logger.info(
163 "%s to %s with message '%s'",
164 "Committing" if do_commit else "Would commit",
165 cls.__name__,
166 commit_message,
167 )
168 if do_commit:
169 cls.commit(
170 message=commit_message,
171 current_version=context["current_version"],
172 new_version=context["new_version"],
173 extra_args=extra_args,
174 )
175 logger.dedent()
177 @classmethod
178 def tag_in_scm(cls, config: "Config", context: MutableMapping, dry_run: bool = False) -> None:
179 """Tag the current commit in the source code management system."""
180 if not config.commit:
181 logger.info("Would not tag since we are not committing")
182 return
183 if not config.tag:
184 logger.info("Would not tag")
185 return
186 sign_tags = config.sign_tags
187 tag_name = config.tag_name.format(**context)
188 tag_message = config.tag_message.format(**context)
189 existing_tags = cls.get_all_tags()
190 do_tag = not dry_run
192 if tag_name in existing_tags:
193 logger.warning("Tag '%s' already exists. Will not tag.", tag_name)
194 return
196 logger.info(
197 "%s '%s' %s in %s and %s",
198 "Tagging" if do_tag else "Would tag",
199 tag_name,
200 f"with message '{tag_message}'" if tag_message else "without message",
201 cls.__name__,
202 "signing" if sign_tags else "not signing",
203 )
204 if do_tag:
205 cls.tag(tag_name, sign_tags, tag_message)
207 def __str__(self):
208 return self.__repr__()
210 def __repr__(self):
211 return f"{self.__class__.__name__}"
214class Git(SourceCodeManager):
215 """Git implementation."""
217 _TEST_USABLE_COMMAND: ClassVar[List[str]] = ["git", "rev-parse", "--git-dir"]
218 _COMMIT_COMMAND: ClassVar[List[str]] = ["git", "commit", "-F"]
219 _ALL_TAGS_COMMAND: ClassVar[List[str]] = ["git", "tag", "--list"]
221 @classmethod
222 def assert_nondirty(cls) -> None:
223 """Assert that the working directory is not dirty."""
224 lines = [
225 line.strip()
226 for line in subprocess.check_output(["git", "status", "--porcelain"]).splitlines() # noqa: S603, S607
227 if not line.strip().startswith(b"??")
228 ]
230 if lines:
231 joined_lines = b"\n".join(lines).decode()
232 raise DirtyWorkingDirectoryError(f"Git working directory is not clean:\n\n{joined_lines}")
234 @classmethod
235 def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:
236 """Return information about the latest tag."""
237 try:
238 # git-describe doesn't update the git-index, so we do that
239 subprocess.run(["git", "update-index", "--refresh", "-q"], capture_output=True) # noqa: S603, S607
240 except subprocess.CalledProcessError as e:
241 logger.debug("Error when running git update-index: %s", e.stderr)
242 return SCMInfo(tool=cls)
243 tag_pattern = tag_name.replace("{new_version}", "*")
244 try:
245 # get info about the latest tag in git
246 git_cmd = [
247 "git",
248 "describe",
249 "--dirty",
250 "--tags",
251 "--long",
252 "--abbrev=40",
253 f"--match={tag_pattern}",
254 ]
255 result = subprocess.run(git_cmd, text=True, check=True, capture_output=True) # noqa: S603
256 describe_out = result.stdout.strip().split("-")
258 git_cmd = ["git", "rev-parse", "--abbrev-ref", "HEAD"]
259 result = subprocess.run(git_cmd, text=True, check=True, capture_output=True) # noqa: S603
260 branch_name = result.stdout.strip()
261 short_branch_name = re.sub(r"([^a-zA-Z0-9]*)", "", branch_name).lower()[:20]
262 except subprocess.CalledProcessError as e:
263 logger.debug("Error when running git describe: %s", e.stderr)
264 return SCMInfo(tool=cls)
266 info = SCMInfo(tool=cls, branch_name=branch_name, short_branch_name=short_branch_name)
268 if describe_out[-1].strip() == "dirty":
269 info.dirty = True
270 describe_out.pop()
271 else:
272 info.dirty = False
274 info.commit_sha = describe_out.pop().lstrip("g")
275 info.distance_to_latest_tag = int(describe_out.pop())
276 version = cls.get_version_from_tag(describe_out[-1], tag_name, parse_pattern)
277 info.current_version = version or "-".join(describe_out).lstrip("v")
279 return info
281 @classmethod
282 def add_path(cls, path: Union[str, Path]) -> None:
283 """Add a path to the VCS."""
284 subprocess.check_output(["git", "add", "--update", str(path)]) # noqa: S603, S607
286 @classmethod
287 def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> None:
288 """
289 Create a tag of the new_version in VCS.
291 If only name is given, bumpversion uses a lightweight tag.
292 Otherwise, it utilizes an annotated tag.
294 Args:
295 name: The name of the tag
296 sign: True to sign the tag
297 message: An optional message to annotate the tag.
298 """
299 command = ["git", "tag", name]
300 if sign:
301 command += ["--sign"]
302 if message:
303 command += ["--message", message]
304 subprocess.check_output(command) # noqa: S603
307class Mercurial(SourceCodeManager):
308 """Mercurial implementation."""
310 _TEST_USABLE_COMMAND: ClassVar[List[str]] = ["hg", "root"]
311 _COMMIT_COMMAND: ClassVar[List[str]] = ["hg", "commit", "--logfile"]
312 _ALL_TAGS_COMMAND: ClassVar[List[str]] = ["hg", "log", '--rev="tag()"', '--template="{tags}\n"']
314 @classmethod
315 def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:
316 """Return information about the latest tag."""
317 current_version = None
318 re_pattern = tag_name.replace("{new_version}", ".*")
319 result = subprocess.run(
320 ["hg", "log", "-r", f"tag('re:{re_pattern}')", "--template", "{latesttag}\n"], # noqa: S603, S607
321 text=True,
322 check=True,
323 capture_output=True,
324 )
325 result.check_returncode()
326 if result.stdout:
327 tag_string = result.stdout.splitlines(keepends=False)[-1]
328 current_version = cls.get_version_from_tag(tag_string, tag_name, parse_pattern)
329 else:
330 logger.debug("No tags found")
331 is_dirty = len(subprocess.check_output(["hg", "status", "-mard"])) != 0 # noqa: S603, S607
332 return SCMInfo(tool=cls, current_version=current_version, dirty=is_dirty)
334 @classmethod
335 def assert_nondirty(cls) -> None:
336 """Assert that the working directory is clean."""
337 lines = [
338 line.strip()
339 for line in subprocess.check_output(["hg", "status", "-mard"]).splitlines() # noqa: S603, S607
340 if not line.strip().startswith(b"??")
341 ]
343 if lines:
344 joined_lines = b"\n".join(lines).decode()
345 raise DirtyWorkingDirectoryError(f"Mercurial working directory is not clean:\n{joined_lines}")
347 @classmethod
348 def add_path(cls, path: Union[str, Path]) -> None:
349 """Add a path to the VCS."""
350 pass
352 @classmethod
353 def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> None:
354 """
355 Create a tag of the new_version in VCS.
357 If only name is given, bumpversion uses a lightweight tag.
358 Otherwise, it utilizes an annotated tag.
360 Args:
361 name: The name of the tag
362 sign: True to sign the tag
363 message: A optional message to annotate the tag.
365 Raises:
366 SignedTagsError: If ``sign`` is ``True``
367 """
368 command = ["hg", "tag", name]
369 if sign:
370 raise SignedTagsError("Mercurial does not support signed tags.")
371 if message:
372 command += ["--message", message]
373 subprocess.check_output(command) # noqa: S603
376def get_scm_info(tag_name: str, parse_pattern: str) -> SCMInfo:
377 """Return a dict with the latest source code management info."""
378 if Git.is_usable():
379 return Git.latest_tag_info(tag_name, parse_pattern)
380 elif Mercurial.is_usable():
381 return Mercurial.latest_tag_info(tag_name, parse_pattern)
382 else:
383 return SCMInfo()