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

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

15 from bumpversion.config import Config

16

17 from bumpversion.exceptions import 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: 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

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

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

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

60

61 env = os.environ.copy()

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

63 env["BUMPVERSION_CURRENT_VERSION"] = current_version

64 env["BUMPVERSION_NEW_VERSION"] = new_version

65

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)

75

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

84

85 @classmethod

86 def assert_nondirty(cls) -> None:

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

88 raise NotImplementedError()

89

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()

94

95 @classmethod

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

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

98 raise NotImplementedError()

99

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

104

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

113

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

123

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

137

138 if not config.commit:

139 logger.info("Would not commit")

140 return

141

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 )

156

157 if do_commit:

158 cls.add_path(path)

159

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

161

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()

176

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

191

192 if tag_name in existing_tags:

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

194 return

195

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)

206

207 def __str__(self):

208 return self.__repr__()

209

210 def __repr__(self):

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

212

213

214 class Git(SourceCodeManager):

215 """Git implementation."""

216

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

220

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 ]

229

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}")

233

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("-")

257

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)

265

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

267

268 if describe_out[-1].strip() == "dirty":

269 info.dirty = True

270 describe_out.pop()

271 else:

272 info.dirty = False

273

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")

278

279 return info

280

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

285

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.

290

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

292 Otherwise, it utilizes an annotated tag.

293

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

305

306

307 class Mercurial(SourceCodeManager):

308 """Mercurial implementation."""

309

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"']

313

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)

333

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 ]

342

343 if lines:

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

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

346

347 @classmethod

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

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

350 pass

351

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.

356

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

358 Otherwise, it utilizes an annotated tag.

359

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.

364

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

374

375

376 def 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()