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

373 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-23 01:10 +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, 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 """ 

164 

165 version: str | None = None 

166 dry_run: bool = False 

167 commit: bool = False 

168 push: bool = False 

169 branch: str | None = None 

170 allow_dirty: bool = False 

171 language: Language | None = None 

172 

173 

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

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

176 

177 Args: 

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

179 

180 Returns: 

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

182 

183 Raises: 

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

185 

186 Example: 

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

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

189 0.1.0 

190 """ 

191 if language == Language.PYTHON: 

192 try: 

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

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

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

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

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

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

199 return _denormalize_pep440_to_semver(version) 

200 except Exception as e: 

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

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

203 elif language == Language.GO: 

204 try: 

205 with open("VERSION") as f: 

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

207 except Exception as e: 

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

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

210 

211 if not version: 

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

213 raise typer.Exit(code=1) 

214 

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

216 if not version or version.isspace(): 

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

218 raise typer.Exit(code=1) 

219 

220 return version 

221 else: 

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

223 raise typer.Exit(code=1) 

224 

225 

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

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

228 

229 Args: 

230 current_version: The current semantic version. 

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

232 

233 Returns: 

234 The next prerelease version with the specified token. 

235 

236 Example: 

237 >>> import semver 

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

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

240 >>> print(next_alpha) 

241 1.0.1-alpha.1 

242 """ 

243 if current_version.prerelease: 

244 if current_version.prerelease.startswith(token): 

245 return current_version.bump_prerelease() 

246 else: 

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

248 else: 

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

250 

251 

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

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

254 

255 Args: 

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

257 

258 Returns: 

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

260 

261 Example: 

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

263 >>> print(bump_type) 

264 patch 

265 """ 

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

267 if choice.startswith(prefix): 

268 return bump_type 

269 return "" 

270 

271 

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

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

274 

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

276 resulting versions. Returns the selected new version string. 

277 

278 Args: 

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

280 

281 Returns: 

282 The new version string selected by the user. 

283 

284 Raises: 

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

286 

287 Example: 

288 Interactive prompt shows:: 

289 

290 Select bump type (Current: 1.0.0) 

291 > Patch (1.0.0 -> 1.0.1) 

292 Minor (1.0.0 -> 1.1.0) 

293 Major (1.0.0 -> 2.0.0) 

294 ... 

295 """ 

296 try: 

297 current_version = semver.Version.parse(current_version_str) 

298 except ValueError: 

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

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

301 

302 next_patch = current_version.bump_patch() 

303 next_minor = current_version.bump_minor() 

304 next_major = current_version.bump_major() 

305 next_prerelease = current_version.bump_prerelease() 

306 next_build = current_version.bump_build() 

307 

308 next_alpha = get_next_prerelease(current_version, "alpha") 

309 next_beta = get_next_prerelease(current_version, "beta") 

310 next_rc = get_next_prerelease(current_version, "rc") 

311 next_dev = get_next_prerelease(current_version, "dev") 

312 

313 try: 

314 choice = qs.select( 

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

316 choices=[ 

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

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

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

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

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

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

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

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

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

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

327 ], 

328 style=COOL_STYLE, 

329 ).ask() 

330 except EOFError: 

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

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

333 

334 if not choice: 

335 raise typer.Exit(code=0) 

336 

337 # Extract the new version string from the choice 

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

339 # We want "New" 

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

341 if "-> " not in choice: 

342 console.error("Invalid choice selection") 

343 raise typer.Exit(code=1) 

344 

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

346 return new_version 

347 

348 

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

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

351 

352 Args: 

353 current_version: The current semantic version. 

354 version_type: The bump type keyword. 

355 

356 Returns: 

357 The bumped version string. 

358 """ 

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

360 "patch": current_version.bump_patch, 

361 "minor": current_version.bump_minor, 

362 "major": current_version.bump_major, 

363 "prerelease": current_version.bump_prerelease, 

364 "build": current_version.bump_build, 

365 } 

366 

367 if version_type in bump_mapping: 

368 return str(bump_mapping[version_type]()) 

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

370 return str(get_next_prerelease(current_version, version_type)) 

371 

372 return "" 

373 

374 

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

376 """Validate and clean explicit version string. 

377 

378 Args: 

379 version: Version string to validate. 

380 

381 Returns: 

382 Cleaned version string. 

383 

384 Raises: 

385 typer.Exit: If version format is invalid. 

386 """ 

387 # Strip 'v' prefix 

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

389 

390 # Validate explicit version 

391 try: 

392 semver.Version.parse(cleaned_version) 

393 except ValueError: 

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

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

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

397 

398 return cleaned_version 

399 

400 

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

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

403 

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

405 strings, or validates and returns explicit version strings. 

406 

407 Args: 

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

409 keyword or an explicit version string. 

410 current_version_str: The current version string. 

411 

412 Returns: 

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

414 

415 Raises: 

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

417 

418 Example: 

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

420 >>> print(version) 

421 1.0.1 

422 

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

424 >>> print(version) 

425 2.0.0 

426 """ 

427 if not version: 

428 return "" 

429 

430 try: 

431 current_version = semver.Version.parse(current_version_str) 

432 except ValueError: 

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

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

435 

436 # Try to get bumped version from type keyword 

437 bumped_version = get_bumped_version_from_type(current_version, version) 

438 if bumped_version: 

439 return bumped_version 

440 

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

442 return _validate_explicit_version(version) 

443 

444 

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

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

447 

448 Args: 

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

450 

451 Raises: 

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

453 """ 

454 if language == Language.PYTHON: 

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

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

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

458 raise typer.Exit(code=1) 

459 elif language == Language.GO: 

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

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

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

463 raise typer.Exit(code=1) 

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

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

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

467 raise typer.Exit(code=1) 

468 else: 

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

470 raise typer.Exit(code=1) 

471 

472 

473def _build_configuration(current_version_str: str, allow_dirty: bool, commit: bool) -> tuple[Any, Path]: 

474 """Build bumpversion configuration with appropriate overrides. 

475 

476 Args: 

477 current_version_str: The current version string. 

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

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

480 

481 Returns: 

482 A tuple of (config object, config_path). 

483 

484 Raises: 

485 typer.Exit: If configuration loading fails. 

486 """ 

487 config_path = Path(CONFIG_FILENAME) 

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

489 if allow_dirty: 

490 overrides["allow_dirty"] = True 

491 if commit: 

492 overrides["commit"] = True 

493 

494 try: 

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

496 except Exception as e: 

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

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

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

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

501 else: 

502 return config, config_path 

503 

504 

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

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

507 

508 Args: 

509 config: The bumpversion configuration object. 

510 

511 Returns: 

512 List of file paths that will be modified. 

513 """ 

514 files = [] 

515 if hasattr(config, "files_to_modify"): 

516 for file_config in config.files_to_modify: 

517 if hasattr(file_config, "filename"): 

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

519 return files 

520 

521 

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

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

524 

525 Args: 

526 file_path: Path to the file to preview. 

527 current_version: The current version string. 

528 new_version: The new version string. 

529 """ 

530 if not file_path.exists(): 

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

532 return 

533 

534 try: 

535 content = file_path.read_text() 

536 lines_with_version = [] 

537 

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

539 if current_version in line: 

540 lines_with_version.append((i, line)) 

541 

542 if lines_with_version: 

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

544 for line_num, old_line in lines_with_version: 

545 new_line = old_line.replace(current_version, new_version) 

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

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

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

549 except Exception as e: 

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

551 

552 

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

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

555 

556 Args: 

557 config: The bumpversion configuration object. 

558 current_version: The current version string. 

559 new_version: The new version string. 

560 """ 

561 files = _get_files_to_modify(config) 

562 

563 if files: 

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

565 for file_path in files: 

566 _show_file_changes(file_path, current_version, new_version) 

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

568 else: 

569 # Fallback: check common files 

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

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

572 for file_path in common_files: 

573 if file_path.exists(): 

574 _show_file_changes(file_path, current_version, new_version) 

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

576 

577 

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

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

580 

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

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

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

584 leave the repository in a state requiring manual recovery. 

585 

586 Args: 

587 new_version_str: The new version string to validate. 

588 config: The bumpversion configuration object. 

589 config_path: Path to the bumpversion configuration file. 

590 

591 Raises: 

592 typer.Exit: If the preflight validation fails. 

593 """ 

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

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

596 

597 try: 

598 do_bump( 

599 version_part=None, 

600 new_version=new_version_str, 

601 config=config, 

602 config_file=config_path, 

603 dry_run=True, 

604 ) 

605 except Exception as e: 

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

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

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

609 

610 console.success("Preflight validation passed") 

611 

612 

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

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

615 

616 Args: 

617 new_version_str: The new version string to bump to. 

618 config: The bumpversion configuration object. 

619 config_path: Path to the bumpversion configuration file. 

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

621 

622 Raises: 

623 typer.Exit: If the bump operation fails. 

624 """ 

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

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

627 

628 try: 

629 do_bump( 

630 version_part=None, 

631 new_version=new_version_str, 

632 config=config, 

633 config_file=config_path, 

634 dry_run=dry_run, 

635 ) 

636 except Exception as e: 

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

638 if not dry_run: 

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

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

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

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

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

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

645 

646 

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

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

649 

650 Args: 

651 current_version_str: The original version string before the bump. 

652 config: The bumpversion configuration object. 

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

654 """ 

655 updated_version = get_current_version(language) 

656 success_msg = ( 

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

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

659 ) 

660 console.success(success_msg) 

661 

662 # Show which files were actually modified 

663 files = _get_files_to_modify(config) 

664 if files: 

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

666 for file_path in files: 

667 if file_path.exists(): 

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

669 else: 

670 # Show common files that typically get modified 

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

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

673 if file_path.exists(): 

674 # Check if file was actually modified by checking content 

675 try: 

676 content = file_path.read_text() 

677 if updated_version in content: 

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

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

680 pass 

681 

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

683 

684 

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

686 """Handle branch checkout if specified. 

687 

688 Args: 

689 branch: Branch to checkout, or None. 

690 dry_run: If True, only simulate checkout. 

691 

692 Returns: 

693 Original branch name if we switched, None otherwise. 

694 

695 Raises: 

696 typer.Exit: If checkout fails. 

697 """ 

698 if not branch: 

699 return None 

700 

701 # Get current branch 

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

703 if result.returncode != 0: 

704 return None 

705 

706 current_branch = result.stdout.strip() 

707 if current_branch == branch: 

708 return None 

709 

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

711 if not dry_run: 

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

713 if result.returncode != 0: 

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

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

716 raise typer.Exit(code=1) 

717 else: 

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

719 

720 return current_branch 

721 

722 

723def _show_interactive_preview( 

724 current_version_str: str, 

725 new_version_str: str, 

726 current_git_branch: str, 

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

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

729 

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

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

732 

733 Args: 

734 current_version_str: Current version. 

735 new_version_str: New version. 

736 current_git_branch: Current git branch. 

737 

738 Returns: 

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

740 cancels the bump entirely. 

741 """ 

742 import questionary as qs 

743 

744 # Show preview 

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

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

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

748 

749 # Confirm bump 

750 try: 

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

752 except EOFError: 

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

754 proceed = True 

755 

756 if not proceed: 

757 return False, False, False 

758 

759 # Ask about commit 

760 try: 

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

762 except EOFError: 

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

764 commit = True 

765 

766 # Ask about push (only if committing) 

767 push = False 

768 if commit: 

769 try: 

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

771 except EOFError: 

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

773 push = False 

774 

775 return True, commit, push 

776 

777 

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

779 """Handle pushing changes to remote. 

780 

781 Args: 

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

783 

784 Raises: 

785 typer.Exit: If push fails. 

786 """ 

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

788 if not version: 

789 try: 

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

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

792 return 

793 except EOFError: 

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

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

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

797 return 

798 

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

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

801 if result.returncode == 0: 

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

803 else: 

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

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

806 console.error("To recover:") 

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

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

809 raise typer.Exit(code=1) 

810 

811 

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

813 """Restore original branch if we switched. 

814 

815 Args: 

816 original_branch: Original branch to restore, or None. 

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

818 """ 

819 if original_branch and not dry_run: 

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

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

822 

823 

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

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

826 

827 This function handles the complete version bumping workflow including 

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

829 and executing the bump operation. 

830 

831 Supports multiple languages: 

832 - Python: uses pyproject.toml 

833 - Go: uses VERSION file with go.mod 

834 - Other: uses VERSION file 

835 

836 Args: 

837 options: Configuration options for the bump command. 

838 

839 Raises: 

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

841 bump operation fails. 

842 

843 Example: 

844 Bump to patch version:: 

845 

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

847 

848 Bump with dry run:: 

849 

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

851 

852 Interactive bump with commit:: 

853 

854 bump_command(BumpOptions(commit=True)) 

855 

856 Bump and push to remote:: 

857 

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

859 """ 

860 # Detect or use provided language 

861 if options.language is None: 

862 detected_language = Language.detect() 

863 if detected_language is None: 

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

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

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

867 raise typer.Exit(code=1) 

868 language = detected_language 

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

870 else: 

871 language = options.language 

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

873 

874 _validate_project_exists(language) 

875 

876 # Handle branch checkout if specified 

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

878 

879 # Determine commit/push settings 

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

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

882 is_interactive = not options.version 

883 commit = options.commit or options.push 

884 push = options.push 

885 

886 current_version_str = get_current_version(language) 

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

888 

889 # Get current branch for display 

890 current_git_branch = get_current_git_branch() 

891 

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

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

894 

895 # Determine new version string 

896 if options.version: 

897 new_version_str = _parse_version_argument(options.version, current_version_str) 

898 else: 

899 new_version_str = get_interactive_bump_type(current_version_str) 

900 

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

902 

903 # Show preview of file changes 

904 _preview_file_modifications(config, current_version_str, new_version_str) 

905 

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

907 if is_interactive: 

908 proceed, commit, push = _show_interactive_preview( 

909 current_version_str, 

910 new_version_str, 

911 current_git_branch, 

912 ) 

913 if not proceed: 

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

915 raise typer.Exit(code=0) 

916 # Rebuild configuration with the user's commit decision 

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

918 

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

920 if not options.dry_run: 

921 _preflight_bump(new_version_str, config, config_path) 

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

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

924 

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

926 

927 if options.dry_run: 

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

929 if commit: 

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

931 if push: 

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

933 else: 

934 _log_bump_success(current_version_str, config, language) 

935 

936 # Handle push 

937 if push: 

938 _handle_push_to_remote(options.version) 

939 

940 # Restore original branch if we switched 

941 _restore_original_branch(original_branch, options.dry_run)