From ef23e14ae4ac84ca70102671eb90188ba46012fd Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Mon, 8 Dec 2025 13:36:40 +0100 Subject: [PATCH] Add Git utilities, semantic version helpers, and unit tests Ref: https://chatgpt.com/share/6936c64b-ae80-800f-ae7e-ed0a692ec3fc --- pkgmgr/git_utils.py | 92 +++++++++++++++++ pkgmgr/versioning.py | 146 +++++++++++++++++++++++++++ tests/unit/pkgmgr/test_git_utils.py | 124 +++++++++++++++++++++++ tests/unit/pkgmgr/test_versioning.py | 120 ++++++++++++++++++++++ 4 files changed, 482 insertions(+) create mode 100644 pkgmgr/git_utils.py create mode 100644 pkgmgr/versioning.py create mode 100644 tests/unit/pkgmgr/test_git_utils.py create mode 100644 tests/unit/pkgmgr/test_versioning.py diff --git a/pkgmgr/git_utils.py b/pkgmgr/git_utils.py new file mode 100644 index 0000000..2915cd3 --- /dev/null +++ b/pkgmgr/git_utils.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Lightweight helper functions around Git commands. + +These helpers are intentionally small wrappers so that higher-level +logic (release, version, changelog) does not have to deal with the +details of subprocess handling. +""" + +from __future__ import annotations + +import subprocess +from typing import List, Optional + + +class GitError(RuntimeError): + """Raised when a Git command fails in an unexpected way.""" + + +def run_git(args: List[str], cwd: str = ".") -> str: + """ + Run a Git command and return its stdout as a stripped string. + + Raises GitError if the command fails. + """ + cmd = ["git"] + args + try: + result = subprocess.run( + cmd, + cwd=cwd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except subprocess.CalledProcessError as exc: + raise GitError( + f"Git command failed in {cwd!r}: {' '.join(cmd)}\n" + f"Exit code: {exc.returncode}\n" + f"STDOUT:\n{exc.stdout}\n" + f"STDERR:\n{exc.stderr}" + ) from exc + + return result.stdout.strip() + + +def get_tags(cwd: str = ".") -> List[str]: + """ + Return a list of all tags in the repository in `cwd`. + + If there are no tags, an empty list is returned. + """ + try: + output = run_git(["tag"], cwd=cwd) + except GitError as exc: + # If the repo has no tags or is not a git repo, surface a clear error. + # You can decide later if you want to treat this differently. + if "not a git repository" in str(exc): + raise + # No tags: stdout may just be empty; treat this as empty list. + return [] + + if not output: + return [] + + return [line.strip() for line in output.splitlines() if line.strip()] + + +def get_head_commit(cwd: str = ".") -> Optional[str]: + """ + Return the current HEAD commit hash, or None if it cannot be determined. + """ + try: + output = run_git(["rev-parse", "HEAD"], cwd=cwd) + except GitError: + return None + return output or None + + +def get_current_branch(cwd: str = ".") -> Optional[str]: + """ + Return the current branch name, or None if it cannot be determined. + + Note: In detached HEAD state this will return 'HEAD'. + """ + try: + output = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd) + except GitError: + return None + return output or None diff --git a/pkgmgr/versioning.py b/pkgmgr/versioning.py new file mode 100644 index 0000000..d55a1b9 --- /dev/null +++ b/pkgmgr/versioning.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Utilities for working with semantic versions (SemVer). + +This module is intentionally small and self-contained so it can be +used by release/version/changelog commands without pulling in any +heavy dependencies. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, List, Optional, Tuple + + +@dataclass(frozen=True, order=True) +class SemVer: + """Simple semantic version representation (MAJOR.MINOR.PATCH).""" + + major: int + minor: int + patch: int + + @classmethod + def parse(cls, value: str) -> "SemVer": + """ + Parse a version string like '1.2.3' or 'v1.2.3' into a SemVer. + + Raises ValueError if the format is invalid. + """ + text = value.strip() + if text.startswith("v"): + text = text[1:] + + parts = text.split(".") + if len(parts) != 3: + raise ValueError(f"Not a valid semantic version: {value!r}") + + try: + major = int(parts[0]) + minor = int(parts[1]) + patch = int(parts[2]) + except ValueError as exc: + raise ValueError(f"Semantic version components must be integers: {value!r}") from exc + + if major < 0 or minor < 0 or patch < 0: + raise ValueError(f"Semantic version components must be non-negative: {value!r}") + + return cls(major=major, minor=minor, patch=patch) + + def to_tag(self, with_prefix: bool = True) -> str: + """ + Convert the version into a tag string: 'v1.2.3' (default) or '1.2.3'. + """ + core = f"{self.major}.{self.minor}.{self.patch}" + return f"v{core}" if with_prefix else core + + def __str__(self) -> str: + return self.to_tag(with_prefix=False) + + +def is_semver_tag(tag: str) -> bool: + """ + Return True if the given tag string looks like a SemVer tag. + + Accepts both '1.2.3' and 'v1.2.3' formats. + """ + try: + SemVer.parse(tag) + return True + except ValueError: + return False + + +def extract_semver_from_tags( + tags: Iterable[str], + major: Optional[int] = None, + minor: Optional[int] = None, +) -> List[Tuple[str, SemVer]]: + """ + Filter and parse tags that match SemVer, optionally restricted + to a specific MAJOR or MAJOR.MINOR line. + + Returns a list of (tag_string, SemVer) pairs. + """ + result: List[Tuple[str, SemVer]] = [] + for tag in tags: + try: + ver = SemVer.parse(tag) + except ValueError: + # Ignore non-SemVer tags + continue + + if major is not None and ver.major != major: + continue + if minor is not None and ver.minor != minor: + continue + + result.append((tag, ver)) + + return result + + +def find_latest_version( + tags: Iterable[str], + major: Optional[int] = None, + minor: Optional[int] = None, +) -> Optional[Tuple[str, SemVer]]: + """ + Find the latest SemVer tag from the given tags. + + If `major` is given, only consider that MAJOR line. + If `minor` is given as well, only consider that MAJOR.MINOR line. + + Returns a tuple (tag_string, SemVer) or None if no SemVer tag matches. + """ + candidates = extract_semver_from_tags(tags, major=major, minor=minor) + if not candidates: + return None + + # SemVer is orderable thanks to dataclass(order=True) + tag, ver = max(candidates, key=lambda item: item[1]) + return tag, ver + + +def bump_major(version: SemVer) -> SemVer: + """ + Bump MAJOR: MAJOR+1.0.0 + """ + return SemVer(major=version.major + 1, minor=0, patch=0) + + +def bump_minor(version: SemVer) -> SemVer: + """ + Bump MINOR: MAJOR.MINOR+1.0 + """ + return SemVer(major=version.major, minor=version.minor + 1, patch=0) + + +def bump_patch(version: SemVer) -> SemVer: + """ + Bump PATCH: MAJOR.MINOR.PATCH+1 + """ + return SemVer(major=version.major, minor=version.minor, patch=version.patch + 1) diff --git a/tests/unit/pkgmgr/test_git_utils.py b/tests/unit/pkgmgr/test_git_utils.py new file mode 100644 index 0000000..a9c33ac --- /dev/null +++ b/tests/unit/pkgmgr/test_git_utils.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import subprocess +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from pkgmgr.git_utils import ( + GitError, + run_git, + get_tags, + get_head_commit, + get_current_branch, +) + + +class TestGitUtils(unittest.TestCase): + @patch("pkgmgr.git_utils.subprocess.run") + def test_run_git_success(self, mock_run): + mock_run.return_value = SimpleNamespace( + stdout="ok\n", + stderr="", + returncode=0, + ) + + output = run_git(["status"], cwd="/tmp/repo") + + self.assertEqual(output, "ok") + mock_run.assert_called_once() + # basic sanity: command prefix should be 'git' + args, kwargs = mock_run.call_args + self.assertEqual(args[0][0], "git") + self.assertEqual(kwargs.get("cwd"), "/tmp/repo") + + @patch("pkgmgr.git_utils.subprocess.run") + def test_run_git_failure_raises_giterror(self, mock_run): + mock_run.side_effect = subprocess.CalledProcessError( + returncode=1, + cmd=["git", "status"], + output="bad\n", + stderr="error\n", + ) + + with self.assertRaises(GitError) as ctx: + run_git(["status"], cwd="/tmp/repo") + + msg = str(ctx.exception) + self.assertIn("Git command failed", msg) + self.assertIn("Exit code: 1", msg) + self.assertIn("bad", msg) + self.assertIn("error", msg) + + @patch("pkgmgr.git_utils.subprocess.run") + def test_get_tags_empty(self, mock_run): + mock_run.return_value = SimpleNamespace( + stdout="", + stderr="", + returncode=0, + ) + + tags = get_tags(cwd="/tmp/repo") + self.assertEqual(tags, []) + + @patch("pkgmgr.git_utils.subprocess.run") + def test_get_tags_non_empty(self, mock_run): + mock_run.return_value = SimpleNamespace( + stdout="v1.0.0\nv1.1.0\n", + stderr="", + returncode=0, + ) + + tags = get_tags(cwd="/tmp/repo") + self.assertEqual(tags, ["v1.0.0", "v1.1.0"]) + + @patch("pkgmgr.git_utils.subprocess.run") + def test_get_head_commit_success(self, mock_run): + mock_run.return_value = SimpleNamespace( + stdout="abc123\n", + stderr="", + returncode=0, + ) + + commit = get_head_commit(cwd="/tmp/repo") + self.assertEqual(commit, "abc123") + + @patch("pkgmgr.git_utils.subprocess.run") + def test_get_head_commit_failure_returns_none(self, mock_run): + mock_run.side_effect = subprocess.CalledProcessError( + returncode=1, + cmd=["git", "rev-parse", "HEAD"], + output="", + stderr="error\n", + ) + + commit = get_head_commit(cwd="/tmp/repo") + self.assertIsNone(commit) + + @patch("pkgmgr.git_utils.subprocess.run") + def test_get_current_branch_success(self, mock_run): + mock_run.return_value = SimpleNamespace( + stdout="main\n", + stderr="", + returncode=0, + ) + + branch = get_current_branch(cwd="/tmp/repo") + self.assertEqual(branch, "main") + + @patch("pkgmgr.git_utils.subprocess.run") + def test_get_current_branch_failure_returns_none(self, mock_run): + mock_run.side_effect = subprocess.CalledProcessError( + returncode=1, + cmd=["git", "rev-parse", "--abbrev-ref", "HEAD"], + output="", + stderr="error\n", + ) + + branch = get_current_branch(cwd="/tmp/repo") + self.assertIsNone(branch) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/test_versioning.py b/tests/unit/pkgmgr/test_versioning.py new file mode 100644 index 0000000..1744902 --- /dev/null +++ b/tests/unit/pkgmgr/test_versioning.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import unittest + +from pkgmgr.versioning import ( + SemVer, + is_semver_tag, + extract_semver_from_tags, + find_latest_version, + bump_major, + bump_minor, + bump_patch, +) + + +class TestSemVer(unittest.TestCase): + def test_semver_parse_basic(self): + ver = SemVer.parse("1.2.3") + self.assertEqual(ver.major, 1) + self.assertEqual(ver.minor, 2) + self.assertEqual(ver.patch, 3) + + def test_semver_parse_with_v_prefix(self): + ver = SemVer.parse("v10.20.30") + self.assertEqual(ver.major, 10) + self.assertEqual(ver.minor, 20) + self.assertEqual(ver.patch, 30) + + def test_semver_parse_invalid(self): + invalid_values = ["", "1", "1.2", "1.2.3.4", "a.b.c", "v1.2.x"] + for value in invalid_values: + with self.subTest(value=value): + with self.assertRaises(ValueError): + SemVer.parse(value) + + def test_semver_to_tag_and_str(self): + ver = SemVer(1, 2, 3) + self.assertEqual(ver.to_tag(), "v1.2.3") + self.assertEqual(ver.to_tag(with_prefix=False), "1.2.3") + self.assertEqual(str(ver), "1.2.3") + + def test_is_semver_tag(self): + cases = [ + ("1.2.3", True), + ("v1.2.3", True), + ("v0.0.0", True), + ("1.2", False), + ("foo", False), + ("v1.2.x", False), + ] + for tag, expected in cases: + with self.subTest(tag=tag): + self.assertEqual(is_semver_tag(tag), expected) + + def test_extract_semver_from_tags_all(self): + tags = ["v1.2.3", "1.0.0", "not-a-tag", "v2.0.1"] + result = extract_semver_from_tags(tags) + tag_strings = [t for (t, _v) in result] + + self.assertIn("v1.2.3", tag_strings) + self.assertIn("1.0.0", tag_strings) + self.assertIn("v2.0.1", tag_strings) + self.assertNotIn("not-a-tag", tag_strings) + + def test_extract_semver_from_tags_filter_major(self): + tags = ["v1.2.3", "v1.3.0", "v2.0.0", "v2.1.0"] + result = extract_semver_from_tags(tags, major=1) + tag_strings = {t for (t, _v) in result} + self.assertEqual(tag_strings, {"v1.2.3", "v1.3.0"}) + + def test_extract_semver_from_tags_filter_major_and_minor(self): + tags = ["v1.2.3", "v1.2.4", "v1.3.0", "v2.2.0"] + result = extract_semver_from_tags(tags, major=1, minor=2) + tag_strings = {t for (t, _v) in result} + self.assertEqual(tag_strings, {"v1.2.3", "v1.2.4"}) + + def test_find_latest_version_simple(self): + tags = ["v1.2.3", "v1.2.4", "v2.0.0"] + latest = find_latest_version(tags) + self.assertIsNotNone(latest) + tag, ver = latest + self.assertEqual(tag, "v2.0.0") + self.assertEqual((ver.major, ver.minor, ver.patch), (2, 0, 0)) + + def test_find_latest_version_filtered_major(self): + tags = ["v1.2.3", "v1.4.0", "v2.0.0", "v2.1.0"] + tag, ver = find_latest_version(tags, major=1) + self.assertEqual(tag, "v1.4.0") + self.assertEqual((ver.major, ver.minor, ver.patch), (1, 4, 0)) + + def test_find_latest_version_filtered_major_minor(self): + tags = ["v1.2.3", "v1.2.4", "v1.3.0"] + tag, ver = find_latest_version(tags, major=1, minor=2) + self.assertEqual(tag, "v1.2.4") + self.assertEqual((ver.major, ver.minor, ver.patch), (1, 2, 4)) + + def test_find_latest_version_no_match_returns_none(self): + tags = ["not-semver", "also-bad"] + latest = find_latest_version(tags) + self.assertIsNone(latest) + + def test_bump_major(self): + ver = SemVer(1, 2, 3) + bumped = bump_major(ver) + self.assertEqual((bumped.major, bumped.minor, bumped.patch), (2, 0, 0)) + + def test_bump_minor(self): + ver = SemVer(1, 2, 3) + bumped = bump_minor(ver) + self.assertEqual((bumped.major, bumped.minor, bumped.patch), (1, 3, 0)) + + def test_bump_patch(self): + ver = SemVer(1, 2, 3) + bumped = bump_patch(ver) + self.assertEqual((bumped.major, bumped.minor, bumped.patch), (1, 2, 4)) + + +if __name__ == "__main__": + unittest.main()