Coverage for /Users/OORDCOR/Documents/code/bump-my-version/bumpversion/scm.py: 57%

1"""Version control system management."""

2

3 import os

4 import re

5 import subprocess

6 from dataclasses import dataclass

7 from pathlib import Path

8 from tempfile import NamedTemporaryFile

9 from typing import TYPE_CHECKING, ClassVar, List, MutableMapping, Optional, Type, Union

10

11 from bumpversion.ui import get_indented_logger

12 from bumpversion.utils import extract_regex_flags

13

14 if TYPE_CHECKING: # pragma: no-coverage 14 ↛ 15line 14 didn't jump to line 15, because the condition on line 14 was never true

15 from bumpversion.config import Config

16

17 from bumpversion.exceptions import BumpVersionError, DirtyWorkingDirectoryError, SignedTagsError

18

19 logger = get_indented_logger(__name__)

20

21

22@dataclass

23 class SCMInfo:

24 """Information about the current source code manager and state."""

25

26 tool: Optional[Type["SourceCodeManager"]] = None

27 commit_sha: Optional[str] = None

28 distance_to_latest_tag: int = 0

29 current_version: Optional[str] = None

30 branch_name: Optional[str] = None

31 short_branch_name: Optional[str] = None

32 dirty: Optional[bool] = None

33

34 def __str__(self):

35 return self.__repr__()

36

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 )

44

45

46 class SourceCodeManager:

47 """Base class for version control systems."""

48

49 _TEST_USABLE_COMMAND: ClassVar[List[str]] = []

50 _COMMIT_COMMAND: ClassVar[List[str]] = []

51 _ALL_TAGS_COMMAND: ClassVar[List[str]] = []

52

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 []

57 if not current_version:

58 logger.warning("No current version given, using an empty string.")

59 current_version = ""

60 if not new_version:

61 logger.warning("No new version given, using an empty string.")

62 new_version = ""

63

64 with NamedTemporaryFile("wb", delete=False) as f:

65 f.write(message.encode("utf-8"))

66

67 env = os.environ.copy()

68 env["HGENCODING"] = "utf-8"

69 env["BUMPVERSION_CURRENT_VERSION"] = current_version

70 env["BUMPVERSION_NEW_VERSION"] = new_version

71

72 try:

73 cmd = [*cls._COMMIT_COMMAND, f.name, *extra_args]

74 subprocess.run(cmd, env=env, capture_output=True, check=True) # noqa: S603

75 except (subprocess.CalledProcessError, TypeError) as exc: # pragma: no-coverage

76 if isinstance(exc, TypeError):

77 err_msg = f"Failed to run {cls._COMMIT_COMMAND}: {exc}"

78 else:

79 err_msg = f"Failed to run {exc.cmd}: return code {exc.returncode}, output: {exc.output}"

80 logger.exception(err_msg)

81 raise BumpVersionError(err_msg) from exc

82 finally:

83 os.unlink(f.name)

84

85 @classmethod

86 def is_usable(cls) -> bool:

87 """Is the VCS implementation usable."""

88 try:

89 result = subprocess.run(cls._TEST_USABLE_COMMAND, check=True, capture_output=True) # noqa: S603

90 return result.returncode == 0

91 except (FileNotFoundError, PermissionError, NotADirectoryError, subprocess.CalledProcessError):

92 return False

93

94 @classmethod

95 def assert_nondirty(cls) -> None:

96 """Assert that the working directory is not dirty."""

97 raise NotImplementedError()

98

99 @classmethod

100 def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:

101 """Return information about the latest tag."""

102 raise NotImplementedError()

103

104 @classmethod

105 def add_path(cls, path: Union[str, Path]) -> None:

106 """Add a path to the VCS."""

107 raise NotImplementedError()

108

109 @classmethod

110 def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> None:

111 """Create a tag of the new_version in VCS."""

112 raise NotImplementedError

113

114 @classmethod

115 def get_all_tags(cls) -> List[str]:

116 """Return all tags in VCS."""

117 try:

118 result = subprocess.run(cls._ALL_TAGS_COMMAND, text=True, check=True, capture_output=True) # noqa: S603

119 return result.stdout.splitlines()

120 except (FileNotFoundError, PermissionError, NotADirectoryError, subprocess.CalledProcessError):

121 return []

122

123 @classmethod

124 def get_version_from_tag(cls, tag: str, tag_name: str, parse_pattern: str) -> Optional[str]:

125 """Return the version from a tag."""

126 version_pattern = parse_pattern.replace("\\\\", "\\")

127 version_pattern, regex_flags = extract_regex_flags(version_pattern)

128 rep = tag_name.replace("{new_version}", f"(?P<current_version>{version_pattern})")

129 rep = f"{regex_flags}{rep}"

130 tag_regex = re.compile(rep)

131 return match["current_version"] if (match := tag_regex.match(tag)) else None

132

133 @classmethod

134 def commit_to_scm(

135 cls,

136 files: List[Union[str, Path]],

137 config: "Config",

138 context: MutableMapping,

139 extra_args: Optional[List[str]] = None,

140 dry_run: bool = False,

141 ) -> None:

142 """Commit the files to the source code management system."""

143 if not cls.is_usable(): 143 ↛ 144line 143 didn't jump to line 144, because the condition on line 143 was never true

144 logger.error("SCM tool '%s' is unusable, unable to commit.", cls.__name__)

145 return

146

147 if not config.commit: 147 ↛ 148line 147 didn't jump to line 148, because the condition on line 147 was never true

148 logger.info("Would not commit")

149 return

150

151 do_commit = not dry_run

152 logger.info(

153 "%s %s commit",

154 "Preparing" if do_commit else "Would prepare",

155 cls.__name__,

156 )

157 logger.indent()

158 for path in files:

159 logger.info(

160 "%s changes in file '%s' to %s",

161 "Adding" if do_commit else "Would add",

162 path,

163 cls.__name__,

164 )

165

166 if do_commit: 166 ↛ 167line 166 didn't jump to line 167, because the condition on line 166 was never true

167 cls.add_path(path)

168

169 commit_message = config.message.format(**context)

170

171 logger.info(

172 "%s to %s with message '%s'",

173 "Committing" if do_commit else "Would commit",

174 cls.__name__,

175 commit_message,

176 )

177 if do_commit: 177 ↛ 178line 177 didn't jump to line 178, because the condition on line 177 was never true

178 cls.commit(

179 message=commit_message,

180 current_version=context["current_version"],

181 new_version=context["new_version"],

182 extra_args=extra_args,

183 )

184 logger.dedent()

185

186 @classmethod

187 def tag_in_scm(cls, config: "Config", context: MutableMapping, dry_run: bool = False) -> None:

188 """Tag the current commit in the source code management system."""

189 if not config.tag: 189 ↛ 192line 189 didn't jump to line 192, because the condition on line 189 was never false

190 logger.info("Would not tag")

191 return

192 sign_tags = config.sign_tags

193 tag_name = config.tag_name.format(**context)

194 tag_message = config.tag_message.format(**context)

195 existing_tags = cls.get_all_tags()

196 do_tag = not dry_run

197

198 if tag_name in existing_tags:

199 logger.warning("Tag '%s' already exists. Will not tag.", tag_name)

200 return

201

202 logger.info(

203 "%s '%s' %s in %s and %s",

204 "Tagging" if do_tag else "Would tag",

205 tag_name,

206 f"with message '{tag_message}'" if tag_message else "without message",

207 cls.__name__,

208 "signing" if sign_tags else "not signing",

209 )

210 if do_tag:

211 cls.tag(tag_name, sign_tags, tag_message)

212

213 def __str__(self):

214 return self.__repr__()

215

216 def __repr__(self):

217 return f"{self.__class__.__name__}"

218

219

220 class Git(SourceCodeManager):

221 """Git implementation."""

222

223 _TEST_USABLE_COMMAND: ClassVar[List[str]] = ["git", "rev-parse", "--git-dir"]

224 _COMMIT_COMMAND: ClassVar[List[str]] = ["git", "commit", "-F"]

225 _ALL_TAGS_COMMAND: ClassVar[List[str]] = ["git", "tag", "--list"]

226

227 @classmethod

228 def assert_nondirty(cls) -> None:

229 """Assert that the working directory is not dirty."""

230 lines = [

231 line.strip()

232 for line in subprocess.check_output(["git", "status", "--porcelain"]).splitlines() # noqa: S603, S607

233 if not line.strip().startswith(b"??")

234 ]

235

236 if lines:

237 joined_lines = b"\n".join(lines).decode()

238 raise DirtyWorkingDirectoryError(f"Git working directory is not clean:\n\n{joined_lines}")

239

240 @classmethod

241 def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:

242 """Return information about the latest tag."""

243 try:

244 # git-describe doesn't update the git-index, so we do that

245 subprocess.run(["git", "update-index", "--refresh", "-q"], capture_output=True) # noqa: S603, S607

246 except subprocess.CalledProcessError as e:

247 logger.debug("Error when running git update-index: %s", e.stderr)

248 return SCMInfo(tool=cls)

249 tag_pattern = tag_name.replace("{new_version}", "*")

250 try:

251 # get info about the latest tag in git

252 git_cmd = [

253 "git",

254 "describe",

255 "--dirty",

256 "--tags",

257 "--long",

258 "--abbrev=40",

259 f"--match={tag_pattern}",

260 ]

261 result = subprocess.run(git_cmd, text=True, check=True, capture_output=True) # noqa: S603

262 describe_out = result.stdout.strip().split("-")

263

264 git_cmd = ["git", "rev-parse", "--abbrev-ref", "HEAD"]

265 result = subprocess.run(git_cmd, text=True, check=True, capture_output=True) # noqa: S603

266 branch_name = result.stdout.strip()

267 short_branch_name = re.sub(r"([^a-zA-Z0-9]*)", "", branch_name).lower()[:20]

268 except subprocess.CalledProcessError as e:

269 logger.debug("Error when running git describe: %s", e.stderr)

270 return SCMInfo(tool=cls)

271

272 info = SCMInfo(tool=cls, branch_name=branch_name, short_branch_name=short_branch_name)

273

274 if describe_out[-1].strip() == "dirty": 274 ↛ 275line 274 didn't jump to line 275, because the condition on line 274 was never true

275 info.dirty = True

276 describe_out.pop()

277 else:

278 info.dirty = False

279

280 info.commit_sha = describe_out.pop().lstrip("g")

281 info.distance_to_latest_tag = int(describe_out.pop())

282 version = cls.get_version_from_tag(describe_out[-1], tag_name, parse_pattern)

283 info.current_version = version or "-".join(describe_out).lstrip("v")

284

285 return info

286

287 @classmethod

288 def add_path(cls, path: Union[str, Path]) -> None:

289 """Add a path to the VCS."""

290 subprocess.check_output(["git", "add", "--update", str(path)]) # noqa: S603, S607

291

292 @classmethod

293 def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> None:

294 """

295 Create a tag of the new_version in VCS.

296

297 If only name is given, bumpversion uses a lightweight tag.

298 Otherwise, it utilizes an annotated tag.

299

300 Args:

301 name: The name of the tag

302 sign: True to sign the tag

303 message: An optional message to annotate the tag.

304 """

305 command = ["git", "tag", name]

306 if sign:

307 command += ["--sign"]

308 if message:

309 command += ["--message", message]

310 subprocess.check_output(command) # noqa: S603

311

312

313 class Mercurial(SourceCodeManager):

314 """Mercurial implementation."""

315

316 _TEST_USABLE_COMMAND: ClassVar[List[str]] = ["hg", "root"]

317 _COMMIT_COMMAND: ClassVar[List[str]] = ["hg", "commit", "--logfile"]

318 _ALL_TAGS_COMMAND: ClassVar[List[str]] = ["hg", "log", '--rev="tag()"', '--template="{tags}\n"']

319

320 @classmethod

321 def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:

322 """Return information about the latest tag."""

323 current_version = None

324 re_pattern = tag_name.replace("{new_version}", ".*")

325 result = subprocess.run(

326 ["hg", "log", "-r", f"tag('re:{re_pattern}')", "--template", "{latesttag}\n"], # noqa: S603, S607

327 text=True,

328 check=True,

329 capture_output=True,

330 )

331 result.check_returncode()

332 if result.stdout:

333 tag_string = result.stdout.splitlines(keepends=False)[-1]

334 current_version = cls.get_version_from_tag(tag_string, tag_name, parse_pattern)

335 else:

336 logger.debug("No tags found")

337 is_dirty = len(subprocess.check_output(["hg", "status", "-mard"])) != 0 # noqa: S603, S607

338 return SCMInfo(tool=cls, current_version=current_version, dirty=is_dirty)

339

340 @classmethod

341 def assert_nondirty(cls) -> None:

342 """Assert that the working directory is clean."""

343 lines = [

344 line.strip()

345 for line in subprocess.check_output(["hg", "status", "-mard"]).splitlines() # noqa: S603, S607

346 if not line.strip().startswith(b"??")

347 ]

348

349 if lines:

350 joined_lines = b"\n".join(lines).decode()

351 raise DirtyWorkingDirectoryError(f"Mercurial working directory is not clean:\n{joined_lines}")

352

353 @classmethod

354 def add_path(cls, path: Union[str, Path]) -> None:

355 """Add a path to the VCS."""

356 pass

357

358 @classmethod

359 def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> None:

360 """

361 Create a tag of the new_version in VCS.

362

363 If only name is given, bumpversion uses a lightweight tag.

364 Otherwise, it utilizes an annotated tag.

365

366 Args:

367 name: The name of the tag

368 sign: True to sign the tag

369 message: A optional message to annotate the tag.

370

371 Raises:

372 SignedTagsError: If ``sign`` is ``True``

373 """

374 command = ["hg", "tag", name]

375 if sign:

376 raise SignedTagsError("Mercurial does not support signed tags.")

377 if message:

378 command += ["--message", message]

379 subprocess.check_output(command) # noqa: S603

380

381

382 def get_scm_info(tag_name: str, parse_pattern: str) -> SCMInfo:

383 """Return a dict with the latest source code management info."""

384 if Git.is_usable():

385 return Git.latest_tag_info(tag_name, parse_pattern)

386 elif Mercurial.is_usable(): 386 ↛ 387line 386 didn't jump to line 387, because the condition on line 386 was never true

387 return Mercurial.latest_tag_info(tag_name, parse_pattern)

388 else:

389 return SCMInfo()