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
« 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.
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, 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 """
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
174def get_current_version(language: Language) -> str:
175 """Read current version from project configuration for the specified language.
177 Args:
178 language: The programming language (python or go).
180 Returns:
181 The current version string in semver format (for compatibility with bump logic).
183 Raises:
184 typer.Exit: If version cannot be read or parsed.
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
211 if not version:
212 console.error("VERSION file is empty")
213 raise typer.Exit(code=1)
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)
220 return version
221 else:
222 console.error(f"Unsupported language: {language}")
223 raise typer.Exit(code=1)
226def get_next_prerelease(current_version: semver.Version, token: str) -> semver.Version:
227 """Calculate next prerelease version for a given token.
229 Args:
230 current_version: The current semantic version.
231 token: The prerelease token (e.g., "alpha", "beta", "rc", "dev").
233 Returns:
234 The next prerelease version with the specified token.
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)
252def _determine_bump_type_from_choice(choice: str) -> str:
253 """Extract bump type from interactive choice string.
255 Args:
256 choice: The choice string selected by the user (e.g., "Patch (1.0.0 -> 1.0.1)").
258 Returns:
259 The bump type extracted from the choice prefix (e.g., "patch").
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 ""
272def get_interactive_bump_type(current_version_str: str) -> str:
273 """Get bump type from user through interactive prompt.
275 Displays an interactive menu with all available bump types and their
276 resulting versions. Returns the selected new version string.
278 Args:
279 current_version_str: The current version string (semver-compatible).
281 Returns:
282 The new version string selected by the user.
284 Raises:
285 typer.Exit: If the current version is invalid or user cancels selection.
287 Example:
288 Interactive prompt shows::
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
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()
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")
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
334 if not choice:
335 raise typer.Exit(code=0)
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)
345 new_version: str = choice.split("-> ")[1].rstrip(")")
346 return new_version
349def get_bumped_version_from_type(current_version: semver.Version, version_type: str) -> str:
350 """Get bumped version string from version type keyword.
352 Args:
353 current_version: The current semantic version.
354 version_type: The bump type keyword.
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 }
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))
372 return ""
375def _validate_explicit_version(version: str) -> str:
376 """Validate and clean explicit version string.
378 Args:
379 version: Version string to validate.
381 Returns:
382 Cleaned version string.
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
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
398 return cleaned_version
401def _parse_version_argument(version: str | None, current_version_str: str) -> str:
402 """Parse version argument and return explicit version string.
404 Converts bump type keywords (patch, minor, major, etc.) to explicit version
405 strings, or validates and returns explicit version strings.
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.
412 Returns:
413 The explicit version string to bump to, or empty string if version is None.
415 Raises:
416 typer.Exit: If the version format is invalid.
418 Example:
419 >>> version = _parse_version_argument("patch", "1.0.0")
420 >>> print(version)
421 1.0.1
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 ""
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
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
441 # Otherwise, it's an explicit version - validate and return
442 return _validate_explicit_version(version)
445def _validate_project_exists(language: Language) -> None:
446 """Validate that required project files exist for the specified language.
448 Args:
449 language: The programming language (python or go).
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)
473def _build_configuration(current_version_str: str, allow_dirty: bool, commit: bool) -> tuple[Any, Path]:
474 """Build bumpversion configuration with appropriate overrides.
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.
481 Returns:
482 A tuple of (config object, config_path).
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
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
505def _get_files_to_modify(config: Any) -> list[Path]:
506 """Get list of files that will be modified by bump-my-version.
508 Args:
509 config: The bumpversion configuration object.
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
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.
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
534 try:
535 content = file_path.read_text()
536 lines_with_version = []
538 for i, line in enumerate(content.split("\n"), 1):
539 if current_version in line:
540 lines_with_version.append((i, line))
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}")
553def _preview_file_modifications(config: Any, current_version: str, new_version: str) -> None:
554 """Preview what changes will be made to files.
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)
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
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.
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.
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.
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)
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
610 console.success("Preflight validation passed")
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.
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.
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)
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
647def _log_bump_success(current_version_str: str, config: Any, language: Language) -> None:
648 """Log successful version bump and post-bump instructions.
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)
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
682 console.info("\nDon't forget to run 'uv lock' to update the lockfile if needed.")
685def _handle_branch_checkout(branch: str | None, dry_run: bool) -> str | None:
686 """Handle branch checkout if specified.
688 Args:
689 branch: Branch to checkout, or None.
690 dry_run: If True, only simulate checkout.
692 Returns:
693 Original branch name if we switched, None otherwise.
695 Raises:
696 typer.Exit: If checkout fails.
697 """
698 if not branch:
699 return None
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
706 current_branch = result.stdout.strip()
707 if current_branch == branch:
708 return None
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}")
720 return current_branch
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.
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.
733 Args:
734 current_version_str: Current version.
735 new_version_str: New version.
736 current_git_branch: Current git branch.
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
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}")
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
756 if not proceed:
757 return False, False, False
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
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
775 return True, commit, push
778def _handle_push_to_remote(version: str | None) -> None:
779 """Handle pushing changes to remote.
781 Args:
782 version: Version argument (None means interactive mode).
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
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)
812def _restore_original_branch(original_branch: str | None, dry_run: bool) -> None:
813 """Restore original branch if we switched.
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)
824def bump_command(options: BumpOptions) -> None:
825 """Bump version using bump-my-version.
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.
831 Supports multiple languages:
832 - Python: uses pyproject.toml
833 - Go: uses VERSION file with go.mod
834 - Other: uses VERSION file
836 Args:
837 options: Configuration options for the bump command.
839 Raises:
840 typer.Exit: If project files are missing, configuration is invalid, or
841 bump operation fails.
843 Example:
844 Bump to patch version::
846 bump_command(BumpOptions(version="patch"))
848 Bump with dry run::
850 bump_command(BumpOptions(version="1.2.3", dry_run=True))
852 Interactive bump with commit::
854 bump_command(BumpOptions(commit=True))
856 Bump and push to remote::
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)}")
874 _validate_project_exists(language)
876 # Handle branch checkout if specified
877 original_branch = _handle_branch_checkout(options.branch, options.dry_run)
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
886 current_version_str = get_current_version(language)
887 config, config_path = _build_configuration(current_version_str, options.allow_dirty, commit)
889 # Get current branch for display
890 current_git_branch = get_current_git_branch()
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)}")
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)
901 console.info(f"New version will be: {typer.style(new_version_str, fg=typer.colors.GREEN, bold=True)}")
903 # Show preview of file changes
904 _preview_file_modifications(config, current_version_str, new_version_str)
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)
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)
925 _execute_bump(new_version_str, config, config_path, options.dry_run)
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)
936 # Handle push
937 if push:
938 _handle_push_to_remote(options.version)
940 # Restore original branch if we switched
941 _restore_original_branch(original_branch, options.dry_run)