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
« 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.
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.
7Example:
8 Bump to a specific version::
10 from rhiza_tools.commands.bump import bump_command
11 bump_command("1.2.3")
13 Bump patch version with commit::
15 bump_command("patch", commit=True)
17 Interactive bump (no version specified)::
19 bump_command(None)
20"""
22from collections.abc import Callable
23from dataclasses import dataclass
24from enum import StrEnum
25from pathlib import Path
26from typing import Any, cast
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
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
42def _denormalize_pep440_to_semver(version_str: str) -> str:
43 """Convert PEP 440 prerelease format to semver format.
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.
48 Args:
49 version_str: Version string, possibly in PEP 440 format.
51 Returns:
52 Version string in semver format.
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
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)
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}"
83 # If not a PEP 440 prerelease, return as-is
84 return version_str
87class Language(StrEnum):
88 """Supported programming languages for version bumping.
90 Attributes:
91 PYTHON: Python projects using pyproject.toml
92 GO: Go projects using VERSION file with go.mod
93 """
95 PYTHON = "python"
96 GO = "go"
98 @classmethod
99 def detect(cls) -> "Language | None":
100 """Detect the project language based on files present.
102 Returns:
103 Language enum if detected, None if no supported language is found.
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
117 def get_version_file(self) -> Path:
118 """Get the version file path for this language.
120 Returns:
121 Path to the version file.
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")
134# Valid bump type keywords
135_VALID_BUMP_TYPES = ["patch", "minor", "major", "prerelease", "build", "alpha", "beta", "rc", "dev"]
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}
151@dataclass
152class BumpOptions:
153 """Configuration options for bump command.
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 """
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
176def get_current_version(language: Language) -> str:
177 """Read current version from project configuration for the specified language.
179 Args:
180 language: The programming language (python or go).
182 Returns:
183 The current version string in semver format (for compatibility with bump logic).
185 Raises:
186 typer.Exit: If version cannot be read or parsed.
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
213 if not version:
214 console.error("VERSION file is empty")
215 raise typer.Exit(code=1)
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)
222 return version
223 else:
224 console.error(f"Unsupported language: {language}")
225 raise typer.Exit(code=1)
228def get_next_prerelease(current_version: semver.Version, token: str) -> semver.Version:
229 """Calculate next prerelease version for a given token.
231 Args:
232 current_version: The current semantic version.
233 token: The prerelease token (e.g., "alpha", "beta", "rc", "dev").
235 Returns:
236 The next prerelease version with the specified token.
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)
254def _determine_bump_type_from_choice(choice: str) -> str:
255 """Extract bump type from interactive choice string.
257 Args:
258 choice: The choice string selected by the user (e.g., "Patch (1.0.0 -> 1.0.1)").
260 Returns:
261 The bump type extracted from the choice prefix (e.g., "patch").
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 ""
274def get_interactive_bump_type(current_version_str: str) -> str:
275 """Get bump type from user through interactive prompt.
277 Displays an interactive menu with all available bump types and their
278 resulting versions. Returns the selected new version string.
280 Args:
281 current_version_str: The current version string (semver-compatible).
283 Returns:
284 The new version string selected by the user.
286 Raises:
287 typer.Exit: If the current version is invalid or user cancels selection.
289 Example:
290 Interactive prompt shows::
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
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()
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")
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
336 if not choice:
337 raise typer.Exit(code=0)
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)
347 new_version: str = choice.split("-> ")[1].rstrip(")")
348 return new_version
351def get_bumped_version_from_type(current_version: semver.Version, version_type: str) -> str:
352 """Get bumped version string from version type keyword.
354 Args:
355 current_version: The current semantic version.
356 version_type: The bump type keyword.
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 }
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))
374 return ""
377def _validate_explicit_version(version: str) -> str:
378 """Validate and clean explicit version string.
380 Args:
381 version: Version string to validate.
383 Returns:
384 Cleaned version string.
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
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
400 return cleaned_version
403def _parse_version_argument(version: str | None, current_version_str: str) -> str:
404 """Parse version argument and return explicit version string.
406 Converts bump type keywords (patch, minor, major, etc.) to explicit version
407 strings, or validates and returns explicit version strings.
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.
414 Returns:
415 The explicit version string to bump to, or empty string if version is None.
417 Raises:
418 typer.Exit: If the version format is invalid.
420 Example:
421 >>> version = _parse_version_argument("patch", "1.0.0")
422 >>> print(version)
423 1.0.1
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 ""
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
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
443 # Otherwise, it's an explicit version - validate and return
444 return _validate_explicit_version(version)
447def _validate_project_exists(language: Language) -> None:
448 """Validate that required project files exist for the specified language.
450 Args:
451 language: The programming language (python or go).
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)
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.
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.
489 Returns:
490 A tuple of (config object, config_path).
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
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
514def _get_files_to_modify(config: Any) -> list[Path]:
515 """Get list of files that will be modified by bump-my-version.
517 Args:
518 config: The bumpversion configuration object.
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
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.
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
543 try:
544 content = file_path.read_text()
545 lines_with_version = []
547 for i, line in enumerate(content.split("\n"), 1):
548 if current_version in line:
549 lines_with_version.append((i, line))
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}")
562def _preview_file_modifications(config: Any, current_version: str, new_version: str) -> None:
563 """Preview what changes will be made to files.
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)
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
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.
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.
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.
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)
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
619 console.success("Preflight validation passed")
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.
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.
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)
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
656def _log_bump_success(current_version_str: str, config: Any, language: Language) -> None:
657 """Log successful version bump and post-bump instructions.
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)
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
691 console.info("\nDon't forget to run 'uv lock' to update the lockfile if needed.")
694def _handle_branch_checkout(branch: str | None, dry_run: bool) -> str | None:
695 """Handle branch checkout if specified.
697 Args:
698 branch: Branch to checkout, or None.
699 dry_run: If True, only simulate checkout.
701 Returns:
702 Original branch name if we switched, None otherwise.
704 Raises:
705 typer.Exit: If checkout fails.
706 """
707 if not branch:
708 return None
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
715 current_branch = result.stdout.strip()
716 if current_branch == branch:
717 return None
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}")
729 return current_branch
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.
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.
742 Args:
743 current_version_str: Current version.
744 new_version_str: New version.
745 current_git_branch: Current git branch.
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
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}")
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
765 if not proceed:
766 return False, False, False
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
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
784 return True, commit, push
787def _handle_push_to_remote(version: str | None) -> None:
788 """Handle pushing changes to remote.
790 Args:
791 version: Version argument (None means interactive mode).
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
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)
821def _restore_original_branch(original_branch: str | None, dry_run: bool) -> None:
822 """Restore original branch if we switched.
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)
833def bump_command(options: BumpOptions) -> None:
834 """Bump version using bump-my-version.
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.
840 Supports multiple languages:
841 - Python: uses pyproject.toml
842 - Go: uses VERSION file with go.mod
843 - Other: uses VERSION file
845 Args:
846 options: Configuration options for the bump command.
848 Raises:
849 typer.Exit: If project files are missing, configuration is invalid, or
850 bump operation fails.
852 Example:
853 Bump to patch version::
855 bump_command(BumpOptions(version="patch"))
857 Bump with dry run::
859 bump_command(BumpOptions(version="1.2.3", dry_run=True))
861 Interactive bump with commit::
863 bump_command(BumpOptions(commit=True))
865 Bump and push to remote::
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)}")
883 _validate_project_exists(language)
885 # Handle branch checkout if specified
886 original_branch = _handle_branch_checkout(options.branch, options.dry_run)
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
895 current_version_str = get_current_version(language)
896 config, config_path = _build_configuration(current_version_str, options.allow_dirty, commit, options.config)
898 # Get current branch for display
899 current_git_branch = get_current_git_branch()
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)}")
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)
910 console.info(f"New version will be: {typer.style(new_version_str, fg=typer.colors.GREEN, bold=True)}")
912 # Show preview of file changes
913 _preview_file_modifications(config, current_version_str, new_version_str)
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)
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)
934 _execute_bump(new_version_str, config, config_path, options.dry_run)
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)
945 # Handle push
946 if push:
947 _handle_push_to_remote(options.version)
949 # Restore original branch if we switched
950 _restore_original_branch(original_branch, options.dry_run)