diff --git a/scripts/build-binary.sh b/scripts/build-binary.sh index d591a099..702d61f9 100755 --- a/scripts/build-binary.sh +++ b/scripts/build-binary.sh @@ -62,6 +62,16 @@ else echo -e "${YELLOW}UPX not found - binary will not be compressed (install with: brew install upx)${NC}" fi +# Inject build SHA into version.py +VERSION_FILE="src/apm_cli/version.py" +BUILD_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "") +if [ -n "$BUILD_SHA" ]; then + echo -e "${YELLOW}Injecting build SHA: $BUILD_SHA${NC}" + sed -i.bak "s/^__BUILD_SHA__ = None$/__BUILD_SHA__ = \"$BUILD_SHA\"/" "$VERSION_FILE" + # Guarantee restore on any exit (success, failure, or signal) + trap 'if [ -f "${VERSION_FILE}.bak" ]; then mv "${VERSION_FILE}.bak" "$VERSION_FILE"; fi' EXIT +fi + # Build binary echo -e "${YELLOW}Building binary with PyInstaller...${NC}" uv run pyinstaller build/apm.spec diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 48dd55e7..7fa71762 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -36,7 +36,7 @@ from apm_cli.utils.github_host import is_valid_fqdn, default_host # APM imports - use absolute imports everywhere for consistency -from apm_cli.version import get_version +from apm_cli.version import get_build_sha, get_version from apm_cli.utils.version_checker import check_for_updates # APM Dependencies - Import for Task 5 integration @@ -230,6 +230,11 @@ def print_version(ctx, param, value): if not value or ctx.resilient_parsing: return + version_str = get_version() + sha = get_build_sha() + if sha: + version_str += f" ({sha})" + console = _get_console() if console: from rich.panel import Panel # type: ignore @@ -237,12 +242,12 @@ def print_version(ctx, param, value): version_text = Text() version_text.append("Agent Package Manager (APM) CLI", style="bold cyan") - version_text.append(f" version {get_version()}", style="white") + version_text.append(f" version {version_str}", style="white") console.print(Panel(version_text, border_style="cyan", padding=(0, 1))) else: # Graceful fallback when Rich isn't available (e.g., stripped automation environment) click.echo( - f"{TITLE}Agent Package Manager (APM) CLI{RESET} version {get_version()}" + f"{TITLE}Agent Package Manager (APM) CLI{RESET} version {version_str}" ) ctx.exit() diff --git a/src/apm_cli/version.py b/src/apm_cli/version.py index 54f98176..cd68a814 100644 --- a/src/apm_cli/version.py +++ b/src/apm_cli/version.py @@ -3,9 +3,10 @@ import sys from pathlib import Path -# Build-time version constant (will be injected during build) +# Build-time constants (will be injected during build) # This avoids TOML parsing overhead during runtime __BUILD_VERSION__ = None +__BUILD_SHA__ = None def get_version() -> str: @@ -65,5 +66,33 @@ def get_version() -> str: return "unknown" +def get_build_sha() -> str: + """Get the short git commit SHA for the current build. + + Uses the build-time constant when available (shipped binaries), + otherwise falls back to querying git at runtime (development). + """ + if __BUILD_SHA__: + return __BUILD_SHA__ + + # Fallback: query git at runtime (development only) + if not getattr(sys, 'frozen', False): + import subprocess + try: + repo_root = Path(__file__).parent.parent.parent + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception: + pass + return "" + + # For backward compatibility __version__ = get_version() diff --git a/tests/unit/test_build_sha.py b/tests/unit/test_build_sha.py new file mode 100644 index 00000000..71c0a374 --- /dev/null +++ b/tests/unit/test_build_sha.py @@ -0,0 +1,56 @@ +"""Tests for get_build_sha() function.""" + +import subprocess +import unittest +from unittest.mock import patch, MagicMock + +from apm_cli.version import get_build_sha + + +class TestGetBuildSha(unittest.TestCase): + """Test build SHA retrieval across all code paths.""" + + @patch("apm_cli.version.__BUILD_SHA__", "abc1234") + def test_returns_build_time_constant_when_set(self): + """Build-time constant takes priority over git.""" + assert get_build_sha() == "abc1234" + + @patch("apm_cli.version.__BUILD_SHA__", None) + def test_returns_empty_when_frozen_and_no_constant(self): + """Frozen binary without build constant returns empty string.""" + with patch.object(__import__("sys"), "frozen", True, create=True): + assert get_build_sha() == "" + + @patch("apm_cli.version.__BUILD_SHA__", None) + @patch("subprocess.run") + def test_falls_back_to_git_in_development(self, mock_run): + """In development, queries git rev-parse.""" + mock_run.return_value = MagicMock(returncode=0, stdout="d1630d1\n") + with patch("sys.frozen", False, create=True): + result = get_build_sha() + assert result == "d1630d1" + mock_run.assert_called_once() + args = mock_run.call_args + assert args[0][0] == ["git", "rev-parse", "--short", "HEAD"] + + @patch("apm_cli.version.__BUILD_SHA__", None) + @patch("subprocess.run", side_effect=FileNotFoundError("git not found")) + def test_returns_empty_when_git_unavailable(self, _mock_run): + """Returns empty string when git is not installed.""" + with patch("sys.frozen", False, create=True): + assert get_build_sha() == "" + + @patch("apm_cli.version.__BUILD_SHA__", None) + @patch("subprocess.run") + def test_returns_empty_when_git_fails(self, mock_run): + """Returns empty string when git command fails (e.g., not a repo).""" + mock_run.return_value = MagicMock(returncode=128, stdout="") + with patch("sys.frozen", False, create=True): + assert get_build_sha() == "" + + @patch("apm_cli.version.__BUILD_SHA__", None) + @patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="git", timeout=5)) + def test_returns_empty_on_timeout(self, _mock_run): + """Returns empty string when git times out.""" + with patch("sys.frozen", False, create=True): + assert get_build_sha() == ""