Coverage for src / rhiza_tools / commands / bump.py: 100%

375 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-28 02:21 +0000

1"""Command to bump version using semver and bump-my-version. 

2 

3This module implements version bumping functionality with support for semantic 

4versioning, interactive selection, and various bump types (patch, minor, major, 

5prerelease variants). Supports multiple languages including Python and Go. 

6 

7Example: 

8 Bump to a specific version:: 

9 

10 from rhiza_tools.commands.bump import bump_command 

11 bump_command("1.2.3") 

12 

13 Bump patch version with commit:: 

14 

15 bump_command("patch", commit=True) 

16 

17 Interactive bump (no version specified):: 

18 

19 bump_command(None) 

20""" 

21 

22from collections.abc import Callable 

23from dataclasses import dataclass 

24from enum import StrEnum 

25from pathlib import Path 

26from typing import Any, cast 

27 

28import questionary as qs 

29import semver 

30import tomlkit 

31import typer 

32from bumpversion.bump import do_bump 

33from bumpversion.config import get_configuration 

34from bumpversion.ui import setup_logging 

35from loguru import logger 

36 

37from rhiza_tools import console 

38from rhiza_tools.commands._shared import COOL_STYLE, NON_INTERACTIVE_ERRORS, get_current_git_branch, run_git_command 

39from rhiza_tools.config import CONFIG_FILENAME 

40 

41 

42def _denormalize_pep440_to_semver(version_str: str) -> str: 

43 """Convert PEP 440 prerelease format to semver format. 

44 

45 Converts PEP 440 format (e.g., 0.1.1a1 or 0.1.1alpha1) back to semver format 

46 (e.g., 0.1.1-alpha.1) for compatibility with the semver library and bump-my-version. 

47 

48 Args: 

49 version_str: Version string, possibly in PEP 440 format. 

50 

51 Returns: 

52 Version string in semver format. 

53 

54 Example: 

55 >>> _denormalize_pep440_to_semver("0.1.1a1") 

56 '0.1.1-alpha.1' 

57 >>> _denormalize_pep440_to_semver("0.1.1alpha1") 

58 '0.1.1-alpha.1' 

59 >>> _denormalize_pep440_to_semver("0.1.1") 

60 '0.1.1' 

61 """ 

62 import re 

63 

64 # Pattern to match PEP 440 prerelease: 0.1.1a1, 0.1.1alpha1, 0.1.1b2, 0.1.1rc3 

65 # Captures: major.minor.patch, release letter(s), and pre_n 

66 pattern = r"^(\d+\.\d+\.\d+)(a|alpha|b|beta|rc|dev)(\d+)$" 

67 match = re.match(pattern, version_str) 

68 

69 if match: 

70 base, release_short, pre_n = match.groups() 

71 # Map PEP 440 forms to full names for semver 

72 release_map = { 

73 "a": "alpha", 

74 "alpha": "alpha", 

75 "b": "beta", 

76 "beta": "beta", 

77 "rc": "rc", 

78 "dev": "dev", 

79 } 

80 release_full = release_map.get(release_short, release_short) 

81 return f"{base}-{release_full}.{pre_n}" 

82 

83 # If not a PEP 440 prerelease, return as-is 

84 return version_str 

85 

86 

87class Language(StrEnum): 

88 """Supported programming languages for version bumping. 

89 

90 Attributes: 

91 PYTHON: Python projects using pyproject.toml 

92 GO: Go projects using VERSION file with go.mod 

93 """ 

94 

95 PYTHON = "python" 

96 GO = "go" 

97 

98 @classmethod 

99 def detect(cls) -> "Language | None": 

100 """Detect the project language based on files present. 

101 

102 Returns: 

103 Language enum if detected, None if no supported language is found. 

104 

105 Example: 

106 >>> lang = Language.detect() # doctest: +SKIP 

107 >>> if lang: 

108 ... print(lang.value) # doctest: +SKIP 

109 python 

110 """ 

111 if Path("pyproject.toml").exists(): 

112 return cls.PYTHON 

113 elif Path("go.mod").exists() and Path("VERSION").exists(): 

114 return cls.GO 

115 return None 

116 

117 def get_version_file(self) -> Path: 

118 """Get the version file path for this language. 

119 

120 Returns: 

121 Path to the version file. 

122 

123 Example: 

124 >>> lang = Language.PYTHON 

125 >>> lang.get_version_file() # doctest: +SKIP 

126 PosixPath('pyproject.toml') 

127 """ 

128 if self == Language.PYTHON: 

129 return Path("pyproject.toml") 

130 # Language.GO 

131 return Path("VERSION") 

132 

133 

134# Valid bump type keywords 

135_VALID_BUMP_TYPES = ["patch", "minor", "major", "prerelease", "build", "alpha", "beta", "rc", "dev"] 

136 

137# Mapping of choice prefix to bump type for interactive selection 

138_CHOICE_PREFIX_TO_BUMP_TYPE = { 

139 "Patch": "patch", 

140 "Minor": "minor", 

141 "Major": "major", 

142 "Alpha": "alpha", 

143 "Beta": "beta", 

144 "RC": "rc", 

145 "Dev": "dev", 

146 "Prerelease": "prerelease", 

147 "Build": "build", 

148} 

149 

150 

151@dataclass 

152class BumpOptions: 

153 """Configuration options for bump command. 

154 

155 Attributes: 

156 version: The version to bump to. Can be an explicit version, bump type, or None. 

157 dry_run: If True, show what would change without actually changing anything. 

158 commit: If True, automatically commit the version change to git. 

159 push: If True, push changes to remote after commit (implies commit=True). 

160 branch: Branch to perform the bump on (default: current branch). 

161 allow_dirty: If True, allow bumping even with uncommitted changes. 

162 language: The programming language (python or go). If None, auto-detect. 

163 config: Path to the .cfg.toml config file. Defaults to CONFIG_FILENAME. 

164 """ 

165 

166 version: str | None = None 

167 dry_run: bool = False 

168 commit: bool = False 

169 push: bool = False 

170 branch: str | None = None 

171 allow_dirty: bool = False 

172 language: Language | None = None 

173 config: Path | None = None 

174 

175 

176def get_current_version(language: Language) -> str: 

177 """Read current version from project configuration for the specified language. 

178 

179 Args: 

180 language: The programming language (python or go). 

181 

182 Returns: 

183 The current version string in semver format (for compatibility with bump logic). 

184 

185 Raises: 

186 typer.Exit: If version cannot be read or parsed. 

187 

188 Example: 

189 >>> version = get_current_version(Language.PYTHON) # doctest: +SKIP 

190 >>> print(version) # doctest: +SKIP 

191 0.1.0 

192 """ 

193 if language == Language.PYTHON: 

194 try: 

195 with open("pyproject.toml") as f: 

196 data = tomlkit.parse(f.read()) 

197 project = cast(dict[str, Any], data["project"]) 

198 version = str(project["version"]) 

199 # Convert PEP 440 format back to semver format for compatibility 

200 # e.g., 0.1.1a1 -> 0.1.1-alpha.1 

201 return _denormalize_pep440_to_semver(version) 

202 except Exception as e: 

203 console.error(f"Failed to read version from pyproject.toml: {e}") 

204 raise typer.Exit(code=1) from None 

205 elif language == Language.GO: 

206 try: 

207 with open("VERSION") as f: 

208 version = f.read().strip() 

209 except Exception as e: 

210 console.error(f"Failed to read version from VERSION file: {e}") 

211 raise typer.Exit(code=1) from None 

212 

213 if not version: 

214 console.error("VERSION file is empty") 

215 raise typer.Exit(code=1) 

216 

217 # Validate that the version string is not just whitespace and looks valid 

218 if not version or version.isspace(): 

219 console.error("VERSION file contains only whitespace") 

220 raise typer.Exit(code=1) 

221 

222 return version 

223 else: 

224 console.error(f"Unsupported language: {language}") 

225 raise typer.Exit(code=1) 

226 

227 

228def get_next_prerelease(current_version: semver.Version, token: str) -> semver.Version: 

229 """Calculate next prerelease version for a given token. 

230 

231 Args: 

232 current_version: The current semantic version. 

233 token: The prerelease token (e.g., "alpha", "beta", "rc", "dev"). 

234 

235 Returns: 

236 The next prerelease version with the specified token. 

237 

238 Example: 

239 >>> import semver 

240 >>> current = semver.Version.parse("1.0.0") 

241 >>> next_alpha = get_next_prerelease(current, "alpha") 

242 >>> print(next_alpha) 

243 1.0.1-alpha.1 

244 """ 

245 if current_version.prerelease: 

246 if current_version.prerelease.startswith(token): 

247 return current_version.bump_prerelease() 

248 else: 

249 return current_version.replace(prerelease=f"{token}.1") 

250 else: 

251 return current_version.bump_patch().bump_prerelease(token=token) 

252 

253 

254def _determine_bump_type_from_choice(choice: str) -> str: 

255 """Extract bump type from interactive choice string. 

256 

257 Args: 

258 choice: The choice string selected by the user (e.g., "Patch (1.0.0 -> 1.0.1)"). 

259 

260 Returns: 

261 The bump type extracted from the choice prefix (e.g., "patch"). 

262 

263 Example: 

264 >>> bump_type = _determine_bump_type_from_choice("Patch (1.0.0 -> 1.0.1)") 

265 >>> print(bump_type) 

266 patch 

267 """ 

268 for prefix, bump_type in _CHOICE_PREFIX_TO_BUMP_TYPE.items(): 

269 if choice.startswith(prefix): 

270 return bump_type 

271 return "" 

272 

273 

274def get_interactive_bump_type(current_version_str: str) -> str: 

275 """Get bump type from user through interactive prompt. 

276 

277 Displays an interactive menu with all available bump types and their 

278 resulting versions. Returns the selected new version string. 

279 

280 Args: 

281 current_version_str: The current version string (semver-compatible). 

282 

283 Returns: 

284 The new version string selected by the user. 

285 

286 Raises: 

287 typer.Exit: If the current version is invalid or user cancels selection. 

288 

289 Example: 

290 Interactive prompt shows:: 

291 

292 Select bump type (Current: 1.0.0) 

293 > Patch (1.0.0 -> 1.0.1) 

294 Minor (1.0.0 -> 1.1.0) 

295 Major (1.0.0 -> 2.0.0) 

296 ... 

297 """ 

298 try: 

299 current_version = semver.Version.parse(current_version_str) 

300 except ValueError: 

301 console.error(f"Invalid semantic version in configuration: {current_version_str}") 

302 raise typer.Exit(code=1) from None 

303 

304 next_patch = current_version.bump_patch() 

305 next_minor = current_version.bump_minor() 

306 next_major = current_version.bump_major() 

307 next_prerelease = current_version.bump_prerelease() 

308 next_build = current_version.bump_build() 

309 

310 next_alpha = get_next_prerelease(current_version, "alpha") 

311 next_beta = get_next_prerelease(current_version, "beta") 

312 next_rc = get_next_prerelease(current_version, "rc") 

313 next_dev = get_next_prerelease(current_version, "dev") 

314 

315 try: 

316 choice = qs.select( 

317 f"Select bump type (Current: {current_version_str})", 

318 choices=[ 

319 f"Patch ({current_version_str} -> {next_patch})", 

320 f"Minor ({current_version_str} -> {next_minor})", 

321 f"Major ({current_version_str} -> {next_major})", 

322 qs.Separator("-" * 30), 

323 f"Prerelease ({current_version_str} -> {next_prerelease})", 

324 f"Alpha ({current_version_str} -> {next_alpha})", 

325 f"Beta ({current_version_str} -> {next_beta})", 

326 f"RC ({current_version_str} -> {next_rc})", 

327 f"Dev ({current_version_str} -> {next_dev})", 

328 f"Build ({current_version_str} -> {next_build})", 

329 ], 

330 style=COOL_STYLE, 

331 ).ask() 

332 except NON_INTERACTIVE_ERRORS: 

333 console.error("Interactive selection not available in non-interactive environment") 

334 raise typer.Exit(code=1) from None 

335 

336 if not choice: 

337 raise typer.Exit(code=0) 

338 

339 # Extract the new version string from the choice 

340 # Format is "Label (Current -> New)" 

341 # We want "New" 

342 # Check if the choice contains the expected format (skip separators) 

343 if "-> " not in choice: 

344 console.error("Invalid choice selection") 

345 raise typer.Exit(code=1) 

346 

347 new_version: str = choice.split("-> ")[1].rstrip(")") 

348 return new_version 

349 

350 

351def get_bumped_version_from_type(current_version: semver.Version, version_type: str) -> str: 

352 """Get bumped version string from version type keyword. 

353 

354 Args: 

355 current_version: The current semantic version. 

356 version_type: The bump type keyword. 

357 

358 Returns: 

359 The bumped version string. 

360 """ 

361 bump_mapping: dict[str, Callable[[], semver.Version]] = { 

362 "patch": current_version.bump_patch, 

363 "minor": current_version.bump_minor, 

364 "major": current_version.bump_major, 

365 "prerelease": current_version.bump_prerelease, 

366 "build": current_version.bump_build, 

367 } 

368 

369 if version_type in bump_mapping: 

370 return str(bump_mapping[version_type]()) 

371 elif version_type in ["alpha", "beta", "rc", "dev"]: 

372 return str(get_next_prerelease(current_version, version_type)) 

373 

374 return "" 

375 

376 

377def _validate_explicit_version(version: str) -> str: 

378 """Validate and clean explicit version string. 

379 

380 Args: 

381 version: Version string to validate. 

382 

383 Returns: 

384 Cleaned version string. 

385 

386 Raises: 

387 typer.Exit: If version format is invalid. 

388 """ 

389 # Strip 'v' prefix 

390 cleaned_version = version[1:] if version.startswith("v") else version 

391 

392 # Validate explicit version 

393 try: 

394 semver.Version.parse(cleaned_version) 

395 except ValueError: 

396 console.error(f"Invalid version format: {version}") 

397 console.error("Please use a valid semantic version.") 

398 raise typer.Exit(code=1) from None 

399 

400 return cleaned_version 

401 

402 

403def _parse_version_argument(version: str | None, current_version_str: str) -> str: 

404 """Parse version argument and return explicit version string. 

405 

406 Converts bump type keywords (patch, minor, major, etc.) to explicit version 

407 strings, or validates and returns explicit version strings. 

408 

409 Args: 

410 version: The version argument provided by the user. Can be a bump type 

411 keyword or an explicit version string. 

412 current_version_str: The current version string. 

413 

414 Returns: 

415 The explicit version string to bump to, or empty string if version is None. 

416 

417 Raises: 

418 typer.Exit: If the version format is invalid. 

419 

420 Example: 

421 >>> version = _parse_version_argument("patch", "1.0.0") 

422 >>> print(version) 

423 1.0.1 

424 

425 >>> version = _parse_version_argument("2.0.0", "1.0.0") 

426 >>> print(version) 

427 2.0.0 

428 """ 

429 if not version: 

430 return "" 

431 

432 try: 

433 current_version = semver.Version.parse(current_version_str) 

434 except ValueError: 

435 console.error(f"Invalid semantic version: {current_version_str}") 

436 raise typer.Exit(code=1) from None 

437 

438 # Try to get bumped version from type keyword 

439 bumped_version = get_bumped_version_from_type(current_version, version) 

440 if bumped_version: 

441 return bumped_version 

442 

443 # Otherwise, it's an explicit version - validate and return 

444 return _validate_explicit_version(version) 

445 

446 

447def _validate_project_exists(language: Language) -> None: 

448 """Validate that required project files exist for the specified language. 

449 

450 Args: 

451 language: The programming language (python or go). 

452 

453 Raises: 

454 typer.Exit: If required project files are not found. 

455 """ 

456 if language == Language.PYTHON: 

457 if not Path("pyproject.toml").exists(): 

458 console.error("Python project detected but pyproject.toml not found.") 

459 console.error("Please create a pyproject.toml file with the current version.") 

460 raise typer.Exit(code=1) 

461 elif language == Language.GO: 

462 if not Path("go.mod").exists(): 

463 console.error("Go language specified but go.mod not found.") 

464 console.error("Please create a go.mod file for your Go project.") 

465 raise typer.Exit(code=1) 

466 if not Path("VERSION").exists(): 

467 console.error("Go project detected but VERSION file not found.") 

468 console.error("Please create a VERSION file with the current version.") 

469 raise typer.Exit(code=1) 

470 else: 

471 console.error(f"Unsupported language: {language}") 

472 raise typer.Exit(code=1) 

473 

474 

475def _build_configuration( 

476 current_version_str: str, 

477 allow_dirty: bool, 

478 commit: bool, 

479 config_path: Path | None = None, 

480) -> tuple[Any, Path]: 

481 """Build bumpversion configuration with appropriate overrides. 

482 

483 Args: 

484 current_version_str: The current version string. 

485 allow_dirty: If True, allow bumping even with uncommitted changes. 

486 commit: If True, automatically commit the version change to git. 

487 config_path: Path to the .cfg.toml config file. Defaults to CONFIG_FILENAME. 

488 

489 Returns: 

490 A tuple of (config object, config_path). 

491 

492 Raises: 

493 typer.Exit: If configuration loading fails. 

494 """ 

495 if config_path is None: 

496 config_path = Path(CONFIG_FILENAME) 

497 overrides: dict[str, Any] = {"current_version": current_version_str} 

498 if allow_dirty: 

499 overrides["allow_dirty"] = True 

500 if commit: 

501 overrides["commit"] = True 

502 

503 try: 

504 config = get_configuration(config_file=config_path, **overrides) 

505 except Exception as e: 

506 console.error(f"Failed to load bumpversion configuration: {e}") 

507 console.error(f"Check your bumpversion config at: {config_path}") 

508 console.error("Ensure the [tool.bumpversion] section is valid TOML with correct version patterns.") 

509 raise typer.Exit(code=1) from None 

510 else: 

511 return config, config_path 

512 

513 

514def _get_files_to_modify(config: Any) -> list[Path]: 

515 """Get list of files that will be modified by bump-my-version. 

516 

517 Args: 

518 config: The bumpversion configuration object. 

519 

520 Returns: 

521 List of file paths that will be modified. 

522 """ 

523 files = [] 

524 if hasattr(config, "files_to_modify"): 

525 for file_config in config.files_to_modify: 

526 if hasattr(file_config, "filename"): 

527 files.append(Path(file_config.filename)) 

528 return files 

529 

530 

531def _show_file_changes(file_path: Path, current_version: str, new_version: str) -> None: 

532 """Show the changes that will be made to a file. 

533 

534 Args: 

535 file_path: Path to the file to preview. 

536 current_version: The current version string. 

537 new_version: The new version string. 

538 """ 

539 if not file_path.exists(): 

540 console.warning(f"File not found: {file_path}") 

541 return 

542 

543 try: 

544 content = file_path.read_text() 

545 lines_with_version = [] 

546 

547 for i, line in enumerate(content.split("\n"), 1): 

548 if current_version in line: 

549 lines_with_version.append((i, line)) 

550 

551 if lines_with_version: 

552 console.info(f" Changes in {typer.style(str(file_path), fg=typer.colors.CYAN, bold=True)}:") 

553 for line_num, old_line in lines_with_version: 

554 new_line = old_line.replace(current_version, new_version) 

555 console.info(f" Line {line_num}:") 

556 console.info(f" {typer.style('-', fg=typer.colors.RED)} {old_line.strip()}") 

557 console.info(f" {typer.style('+', fg=typer.colors.GREEN)} {new_line.strip()}") 

558 except Exception as e: 

559 logger.debug(f"Could not preview changes for {file_path}: {e}") 

560 

561 

562def _preview_file_modifications(config: Any, current_version: str, new_version: str) -> None: 

563 """Preview what changes will be made to files. 

564 

565 Args: 

566 config: The bumpversion configuration object. 

567 current_version: The current version string. 

568 new_version: The new version string. 

569 """ 

570 files = _get_files_to_modify(config) 

571 

572 if files: 

573 console.info(f"\n{typer.style('Files to be modified:', fg=typer.colors.YELLOW, bold=True)}") 

574 for file_path in files: 

575 _show_file_changes(file_path, current_version, new_version) 

576 console.info("") # Empty line for spacing 

577 else: 

578 # Fallback: check common files 

579 common_files = [Path("pyproject.toml"), Path("VERSION"), Path("setup.py"), Path("setup.cfg")] 

580 console.info(f"\n{typer.style('Files to be modified:', fg=typer.colors.YELLOW, bold=True)}") 

581 for file_path in common_files: 

582 if file_path.exists(): 

583 _show_file_changes(file_path, current_version, new_version) 

584 console.info("") # Empty line for spacing 

585 

586 

587def _preflight_bump(new_version_str: str, config: Any, config_path: Path) -> None: 

588 """Run a dry-run bump to validate the operation would succeed. 

589 

590 This preflight check ensures the bump operation will succeed before making 

591 any actual changes. It catches configuration errors, file access issues, 

592 and version format problems early, preventing partial failures that would 

593 leave the repository in a state requiring manual recovery. 

594 

595 Args: 

596 new_version_str: The new version string to validate. 

597 config: The bumpversion configuration object. 

598 config_path: Path to the bumpversion configuration file. 

599 

600 Raises: 

601 typer.Exit: If the preflight validation fails. 

602 """ 

603 console.info("Running preflight validation (dry-run)...") 

604 setup_logging(verbose=1 if console.is_verbose() else 0) 

605 

606 try: 

607 do_bump( 

608 version_part=None, 

609 new_version=new_version_str, 

610 config=config, 

611 config_file=config_path, 

612 dry_run=True, 

613 ) 

614 except Exception as e: 

615 console.error(f"Preflight validation failed: {e}") 

616 console.error("No changes were made.") 

617 raise typer.Exit(code=1) from None 

618 

619 console.success("Preflight validation passed") 

620 

621 

622def _execute_bump(new_version_str: str, config: Any, config_path: Path, dry_run: bool) -> None: 

623 """Execute the bump operation using bump-my-version. 

624 

625 Args: 

626 new_version_str: The new version string to bump to. 

627 config: The bumpversion configuration object. 

628 config_path: Path to the bumpversion configuration file. 

629 dry_run: If True, show what would change without actually changing anything. 

630 

631 Raises: 

632 typer.Exit: If the bump operation fails. 

633 """ 

634 console.info("Running bump-my-version...") 

635 setup_logging(verbose=1 if console.is_verbose() else 0) 

636 

637 try: 

638 do_bump( 

639 version_part=None, 

640 new_version=new_version_str, 

641 config=config, 

642 config_file=config_path, 

643 dry_run=dry_run, 

644 ) 

645 except Exception as e: 

646 console.error(f"bump-my-version failed: {e}") 

647 if not dry_run: 

648 console.error("Files may have been partially modified. To recover:") 

649 console.error(" 1. Check modified files: git diff") 

650 console.error(" 2. Restore all changes: git checkout -- .") 

651 console.error(" 3. Remove untracked: git clean -fd") 

652 console.error("Or to keep changes, fix the issue and retry.") 

653 raise typer.Exit(code=1) from None 

654 

655 

656def _log_bump_success(current_version_str: str, config: Any, language: Language) -> None: 

657 """Log successful version bump and post-bump instructions. 

658 

659 Args: 

660 current_version_str: The original version string before the bump. 

661 config: The bumpversion configuration object. 

662 language: The programming language (python or go). 

663 """ 

664 updated_version = get_current_version(language) 

665 success_msg = ( 

666 f"\n{typer.style('✓', fg=typer.colors.GREEN, bold=True)} " 

667 f"Version bumped: {current_version_str} -> {updated_version}" 

668 ) 

669 console.success(success_msg) 

670 

671 # Show which files were actually modified 

672 files = _get_files_to_modify(config) 

673 if files: 

674 console.info(f"\n{typer.style('Modified files:', fg=typer.colors.CYAN, bold=True)}") 

675 for file_path in files: 

676 if file_path.exists(): 

677 console.info(f"{file_path}") 

678 else: 

679 # Show common files that typically get modified 

680 console.info(f"\n{typer.style('Modified files:', fg=typer.colors.CYAN, bold=True)}") 

681 for file_path in [Path("pyproject.toml"), Path("VERSION"), Path("setup.py"), Path("setup.cfg")]: 

682 if file_path.exists(): 

683 # Check if file was actually modified by checking content 

684 try: 

685 content = file_path.read_text() 

686 if updated_version in content: 

687 console.info(f"{file_path}") 

688 except Exception: # nosec B110 - safe to ignore file read errors # noqa: S110 

689 pass 

690 

691 console.info("\nDon't forget to run 'uv lock' to update the lockfile if needed.") 

692 

693 

694def _handle_branch_checkout(branch: str | None, dry_run: bool) -> str | None: 

695 """Handle branch checkout if specified. 

696 

697 Args: 

698 branch: Branch to checkout, or None. 

699 dry_run: If True, only simulate checkout. 

700 

701 Returns: 

702 Original branch name if we switched, None otherwise. 

703 

704 Raises: 

705 typer.Exit: If checkout fails. 

706 """ 

707 if not branch: 

708 return None 

709 

710 # Get current branch 

711 result = run_git_command(["git", "rev-parse", "--abbrev-ref", "HEAD"], check=False) 

712 if result.returncode != 0: 

713 return None 

714 

715 current_branch = result.stdout.strip() 

716 if current_branch == branch: 

717 return None 

718 

719 console.info(f"Switching from {current_branch} to {branch}") 

720 if not dry_run: 

721 result = run_git_command(["git", "checkout", branch], check=False) 

722 if result.returncode != 0: 

723 console.error(f"Failed to checkout branch {branch}: {result.stderr}") 

724 console.error(f"Ensure the branch '{branch}' exists: git branch -a") 

725 raise typer.Exit(code=1) 

726 else: 

727 console.info(f"[DRY-RUN] Would checkout branch {branch}") 

728 

729 return current_branch 

730 

731 

732def _show_interactive_preview( 

733 current_version_str: str, 

734 new_version_str: str, 

735 current_git_branch: str, 

736) -> tuple[bool, bool, bool]: 

737 """Show interactive preview and prompt for commit/push decisions. 

738 

739 In interactive mode, the user is asked step-by-step whether to proceed 

740 with the bump, whether to commit the changes, and whether to push. 

741 

742 Args: 

743 current_version_str: Current version. 

744 new_version_str: New version. 

745 current_git_branch: Current git branch. 

746 

747 Returns: 

748 Tuple of (proceed, commit, push). ``proceed`` is False if the user 

749 cancels the bump entirely. 

750 """ 

751 import questionary as qs 

752 

753 # Show preview 

754 console.info("\nPreview of changes:") 

755 console.info(f" Version: {current_version_str}{new_version_str}") 

756 console.info(f" Branch: {current_git_branch}") 

757 

758 # Confirm bump 

759 try: 

760 proceed = cast(bool, qs.confirm("Proceed with version bump?", default=True, style=COOL_STYLE).ask()) 

761 except NON_INTERACTIVE_ERRORS: 

762 logger.debug("Running in non-interactive environment, proceeding automatically") 

763 proceed = True 

764 

765 if not proceed: 

766 return False, False, False 

767 

768 # Ask about commit 

769 try: 

770 commit = cast(bool, qs.confirm("Commit the changes?", default=True, style=COOL_STYLE).ask()) 

771 except NON_INTERACTIVE_ERRORS: 

772 logger.debug("Running in non-interactive environment, committing automatically") 

773 commit = True 

774 

775 # Ask about push (only if committing) 

776 push = False 

777 if commit: 

778 try: 

779 push = cast(bool, qs.confirm("Push changes to remote?", default=False, style=COOL_STYLE).ask()) 

780 except NON_INTERACTIVE_ERRORS: 

781 logger.debug("Running in non-interactive environment, skipping push") 

782 push = False 

783 

784 return True, commit, push 

785 

786 

787def _handle_push_to_remote(version: str | None) -> None: 

788 """Handle pushing changes to remote. 

789 

790 Args: 

791 version: Version argument (None means interactive mode). 

792 

793 Raises: 

794 typer.Exit: If push fails. 

795 """ 

796 # Interactive prompt if not in non-interactive mode and version was not specified 

797 if not version: 

798 try: 

799 if not qs.confirm("Push changes to remote?", default=False, style=COOL_STYLE).ask(): 

800 console.info("Push cancelled by user") 

801 return 

802 except NON_INTERACTIVE_ERRORS: 

803 # In testing or non-interactive environment, do not proceed with push 

804 console.info("Push cancelled - non-interactive environment detected") 

805 logger.debug("Running in non-interactive environment, skipping push") 

806 return 

807 

808 console.info("Pushing changes to remote...") 

809 result = run_git_command(["git", "push"], check=False) 

810 if result.returncode == 0: 

811 console.success("Changes pushed to remote successfully!") 

812 else: 

813 console.error(f"Failed to push changes: {result.stderr}") 

814 console.error("The version bump has been applied locally but could not be pushed.") 

815 console.error("To recover:") 

816 console.error(" Push manually: git push") 

817 console.error(" Or undo bump: git reset --hard HEAD~1") 

818 raise typer.Exit(code=1) 

819 

820 

821def _restore_original_branch(original_branch: str | None, dry_run: bool) -> None: 

822 """Restore original branch if we switched. 

823 

824 Args: 

825 original_branch: Original branch to restore, or None. 

826 dry_run: If True, don't actually restore (since we didn't actually switch). 

827 """ 

828 if original_branch and not dry_run: 

829 console.info(f"Returning to original branch {original_branch}") 

830 run_git_command(["git", "checkout", original_branch], check=False) 

831 

832 

833def bump_command(options: BumpOptions) -> None: 

834 """Bump version using bump-my-version. 

835 

836 This function handles the complete version bumping workflow including 

837 configuration loading, version parsing, interactive selection (if needed), 

838 and executing the bump operation. 

839 

840 Supports multiple languages: 

841 - Python: uses pyproject.toml 

842 - Go: uses VERSION file with go.mod 

843 - Other: uses VERSION file 

844 

845 Args: 

846 options: Configuration options for the bump command. 

847 

848 Raises: 

849 typer.Exit: If project files are missing, configuration is invalid, or 

850 bump operation fails. 

851 

852 Example: 

853 Bump to patch version:: 

854 

855 bump_command(BumpOptions(version="patch")) 

856 

857 Bump with dry run:: 

858 

859 bump_command(BumpOptions(version="1.2.3", dry_run=True)) 

860 

861 Interactive bump with commit:: 

862 

863 bump_command(BumpOptions(commit=True)) 

864 

865 Bump and push to remote:: 

866 

867 bump_command(BumpOptions(version="minor", push=True)) 

868 """ 

869 # Detect or use provided language 

870 if options.language is None: 

871 detected_language = Language.detect() 

872 if detected_language is None: 

873 console.error("Unable to detect project language.") 

874 console.error("Please specify language explicitly with --language option.") 

875 console.error("Supported languages: python, go") 

876 raise typer.Exit(code=1) 

877 language = detected_language 

878 console.info(f"Detected language: {typer.style(language.value, fg=typer.colors.CYAN, bold=True)}") 

879 else: 

880 language = options.language 

881 console.info(f"Using language: {typer.style(language.value, fg=typer.colors.CYAN, bold=True)}") 

882 

883 _validate_project_exists(language) 

884 

885 # Handle branch checkout if specified 

886 original_branch = _handle_branch_checkout(options.branch, options.dry_run) 

887 

888 # Determine commit/push settings 

889 # In non-interactive mode (version specified), flags control behaviour directly. 

890 # In interactive mode (no version), the user is prompted for each step. 

891 is_interactive = not options.version 

892 commit = options.commit or options.push 

893 push = options.push 

894 

895 current_version_str = get_current_version(language) 

896 config, config_path = _build_configuration(current_version_str, options.allow_dirty, commit, options.config) 

897 

898 # Get current branch for display 

899 current_git_branch = get_current_git_branch() 

900 

901 console.info(f"Current branch: {typer.style(current_git_branch, fg=typer.colors.CYAN, bold=True)}") 

902 console.info(f"Current version: {typer.style(current_version_str, fg=typer.colors.CYAN, bold=True)}") 

903 

904 # Determine new version string 

905 if options.version: 

906 new_version_str = _parse_version_argument(options.version, current_version_str) 

907 else: 

908 new_version_str = get_interactive_bump_type(current_version_str) 

909 

910 console.info(f"New version will be: {typer.style(new_version_str, fg=typer.colors.GREEN, bold=True)}") 

911 

912 # Show preview of file changes 

913 _preview_file_modifications(config, current_version_str, new_version_str) 

914 

915 # Interactive preview and confirmation (only in true interactive mode) 

916 if is_interactive: 

917 proceed, commit, push = _show_interactive_preview( 

918 current_version_str, 

919 new_version_str, 

920 current_git_branch, 

921 ) 

922 if not proceed: 

923 console.info("Version bump cancelled by user") 

924 raise typer.Exit(code=0) 

925 # Rebuild configuration with the user's commit decision 

926 config, config_path = _build_configuration(current_version_str, options.allow_dirty, commit, options.config) 

927 

928 # Preflight: validate bump would succeed before making any changes 

929 if not options.dry_run: 

930 _preflight_bump(new_version_str, config, config_path) 

931 # Rebuild configuration to avoid stale state from dry-run 

932 config, config_path = _build_configuration(current_version_str, options.allow_dirty, commit, options.config) 

933 

934 _execute_bump(new_version_str, config, config_path, options.dry_run) 

935 

936 if options.dry_run: 

937 console.info("[DRY-RUN] Bump completed (no changes made)") 

938 if commit: 

939 console.info("[DRY-RUN] Would commit the changes") 

940 if push: 

941 console.info("[DRY-RUN] Would push changes to remote") 

942 else: 

943 _log_bump_success(current_version_str, config, language) 

944 

945 # Handle push 

946 if push: 

947 _handle_push_to_remote(options.version) 

948 

949 # Restore original branch if we switched 

950 _restore_original_branch(original_branch, options.dry_run)