diff --git a/CHANGES b/CHANGES index 64c7c3cd..e920a48c 100644 --- a/CHANGES +++ b/CHANGES @@ -15,6 +15,12 @@ $ pip install --user --upgrade --pre libvcs +- Add `GitVersionInfo` dataclass and `build_options()` method to `Git` class to + provide structured access to git version information, making version handling more homogeneous + and type-safe (#491). The `version()` method now returns a `Version` object instead of a string. + This allows for more reliable version parsing and comparison, while `GitSync.get_git_version()` + continues to return a string for backward compatibility. + ### Development - Cursor rules for development loop and git commit messages (#488) diff --git a/MIGRATION b/MIGRATION index 99a841ba..bbee139d 100644 --- a/MIGRATION +++ b/MIGRATION @@ -24,6 +24,27 @@ _Notes on the upcoming release will be added here_ +#### Git version handling API changes (#491) + +- `Git.version()` now returns a `Version` object instead of a string + + Before: + + ```python + git = Git(path=path) + version_str = git.version() # returns a string like "2.43.0" + ``` + + After: + + ```python + git = Git(path=path) + version_obj = git.version() # returns a Version object + version_str = ".".join([str(x) for x in (version_obj.major, version_obj.minor, version_obj.micro)]) + ``` + +- `GitSync.get_git_version()` continues to return a string for backward compatibility + #### pytest fixtures: `git_local_clone` renamed to `example_git_repo` (#468) - pytest: `git_local_clone` renamed to `example_git_repo` diff --git a/conftest.py b/conftest.py index 6a3efb34..519bb19a 100644 --- a/conftest.py +++ b/conftest.py @@ -24,6 +24,7 @@ def add_doctest_fixtures( request: pytest.FixtureRequest, doctest_namespace: dict[str, t.Any], + monkeypatch: pytest.MonkeyPatch, ) -> None: """Configure doctest fixtures for pytest-doctest.""" from _pytest.doctest import DoctestItem diff --git a/src/libvcs/_vendor/__init__.py b/src/libvcs/_vendor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/libvcs/_vendor/_structures.py b/src/libvcs/_vendor/_structures.py new file mode 100644 index 00000000..c2fd421d --- /dev/null +++ b/src/libvcs/_vendor/_structures.py @@ -0,0 +1,63 @@ +# via https://github.com/pypa/packaging/blob/22.0/packaging/_structures.py +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. +from __future__ import annotations + + +class InfinityType: + def __repr__(self) -> str: + return "Infinity" + + def __hash__(self) -> int: + return hash(repr(self)) + + def __lt__(self, other: object) -> bool: + return False + + def __le__(self, other: object) -> bool: + return False + + def __eq__(self, other: object) -> bool: + return isinstance(other, self.__class__) + + def __gt__(self, other: object) -> bool: + return True + + def __ge__(self, other: object) -> bool: + return True + + def __neg__(self: object) -> NegativeInfinityType: + return NegativeInfinity + + +Infinity = InfinityType() + + +class NegativeInfinityType: + def __repr__(self) -> str: + return "-Infinity" + + def __hash__(self) -> int: + return hash(repr(self)) + + def __lt__(self, other: object) -> bool: + return True + + def __le__(self, other: object) -> bool: + return True + + def __eq__(self, other: object) -> bool: + return isinstance(other, self.__class__) + + def __gt__(self, other: object) -> bool: + return False + + def __ge__(self, other: object) -> bool: + return False + + def __neg__(self: object) -> InfinityType: + return Infinity + + +NegativeInfinity = NegativeInfinityType() diff --git a/src/libvcs/_vendor/version.py b/src/libvcs/_vendor/version.py new file mode 100644 index 00000000..f982d1d7 --- /dev/null +++ b/src/libvcs/_vendor/version.py @@ -0,0 +1,587 @@ +# via https://github.com/pypa/packaging/blob/22.0/packaging/version.py +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. +"""Backport of the ``packaging.version`` module from Python 3.8. + +.. testsetup:: + + from packaging.version import parse, Version +""" + +from __future__ import annotations + +import collections +import itertools +import re +import typing as t +from collections.abc import Callable + +from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType + +__all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "parse"] + +InfiniteTypes = t.Union[InfinityType, NegativeInfinityType] +PrePostDevType = t.Union[InfiniteTypes, tuple[str, int]] +SubLocalType = t.Union[InfiniteTypes, int, str] +LocalType = t.Union[ + NegativeInfinityType, + tuple[ + t.Union[ + SubLocalType, + tuple[SubLocalType, str], + tuple[NegativeInfinityType, SubLocalType], + ], + ..., + ], +] +CmpKey = tuple[ + int, + tuple[int, ...], + PrePostDevType, + PrePostDevType, + PrePostDevType, + LocalType, +] +VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool] + +_Version = collections.namedtuple( + "_Version", + ["epoch", "release", "dev", "pre", "post", "local"], +) + + +def parse(version: str) -> Version: + """Parse the given version string. + + Examples + -------- + >>> parse('1.0.dev1') + + + Parameters + ---------- + version : + The version string to parse. + + Raises + ------ + InvalidVersion + When the version string is not a valid version. + """ + return Version(version) + + +class InvalidVersion(ValueError): + """Raised when a version string is not a valid version. + + >>> Version("invalid") + Traceback (most recent call last): + ... + libvcs._vendor.version.InvalidVersion: Invalid version: 'invalid' + """ + + def __init__(self, version: str, *args: object) -> None: + return super().__init__(f"Invalid version: '{version}'") + + +class _BaseVersion: + _key: CmpKey + + def __hash__(self) -> int: + return hash(self._key) + + # Please keep the duplicated `isinstance` check + # in the six comparisons hereunder + # unless you find a way to avoid adding overhead function calls. + def __lt__(self, other: _BaseVersion) -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key < other._key + + def __le__(self, other: _BaseVersion) -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key <= other._key + + def __eq__(self, other: object) -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key == other._key + + def __ge__(self, other: _BaseVersion) -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key >= other._key + + def __gt__(self, other: _BaseVersion) -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key > other._key + + def __ne__(self, other: object) -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key != other._key + + +# Deliberately not anchored to the start and end of the string, to make it +# easier for 3rd party code to reuse +_VERSION_PATTERN = r""" + v? + (?: + (?:(?P[0-9]+)!)? # epoch + (?P[0-9]+(?:\.[0-9]+)*) # release segment + (?P
                                          # pre-release
+            [-_\.]?
+            (?P(a|b|c|rc|alpha|beta|pre|preview))
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?P                                         # post release
+            (?:-(?P[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?Ppost|rev|r)
+                [-_\.]?
+                (?P[0-9]+)?
+            )
+        )?
+        (?P                                          # dev release
+            [-_\.]?
+            (?Pdev)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+    )
+    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+"""
+
+VERSION_PATTERN = _VERSION_PATTERN
+"""
+A string containing the regular expression used to match a valid version.
+
+The pattern is not anchored at either end, and is intended for embedding in larger
+expressions (for example, matching a version number as part of a file name). The
+regular expression should be compiled with the ``re.VERBOSE`` and ``re.IGNORECASE``
+flags set.
+
+:meta hide-value:
+"""
+
+
+class Version(_BaseVersion):
+    """Class abstracts handling of a project's versions.
+
+    A :class:`Version` instance is comparison aware and can be compared and
+    sorted using the standard Python interfaces.
+
+    >>> v1 = Version("1.0a5")
+    >>> v2 = Version("1.0")
+    >>> v1
+    
+    >>> v2
+    
+    >>> v1 < v2
+    True
+    >>> v1 == v2
+    False
+    >>> v1 > v2
+    False
+    >>> v1 >= v2
+    False
+    >>> v1 <= v2
+    True
+    """
+
+    _regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)
+
+    def __init__(self, version: str) -> None:
+        """Initialize a Version object.
+
+        Parameters
+        ----------
+        version : str
+            The string representation of a version which will be parsed and normalized
+            before use.
+
+        Raises
+        ------
+        InvalidVersion
+            If the ``version`` does not conform to PEP 440 in any way then this
+            exception will be raised.
+        """
+        # Validate the version and parse it into pieces
+        match = self._regex.search(version)
+        if not match:
+            raise InvalidVersion(version=version)
+
+        # Store the parsed out pieces of the version
+        self._version = _Version(
+            epoch=int(match.group("epoch")) if match.group("epoch") else 0,
+            release=tuple(int(i) for i in match.group("release").split(".")),
+            pre=_parse_letter_version(match.group("pre_l"), match.group("pre_n")),
+            post=_parse_letter_version(
+                match.group("post_l"),
+                match.group("post_n1") or match.group("post_n2"),
+            ),
+            dev=_parse_letter_version(match.group("dev_l"), match.group("dev_n")),
+            local=_parse_local_version(match.group("local")),
+        )
+
+        # Generate a key which will be used for sorting
+        self._key = _cmpkey(
+            self._version.epoch,
+            self._version.release,
+            self._version.pre,
+            self._version.post,
+            self._version.dev,
+            self._version.local,
+        )
+
+    def __repr__(self) -> str:
+        """Return representation of the Version that shows all internal state.
+
+        >>> Version('1.0.0')
+        
+        """
+        return f""
+
+    def __str__(self) -> str:
+        """Return string representation of the version that can be rounded-tripped.
+
+        >>> str(Version("1.0a5"))
+        '1.0a5'
+        """
+        parts = []
+
+        # Epoch
+        if self.epoch != 0:
+            parts.append(f"{self.epoch}!")
+
+        # Release segment
+        parts.append(".".join(str(x) for x in self.release))
+
+        # Pre-release
+        if self.pre is not None:
+            parts.append("".join(str(x) for x in self.pre))
+
+        # Post-release
+        if self.post is not None:
+            parts.append(f".post{self.post}")
+
+        # Development release
+        if self.dev is not None:
+            parts.append(f".dev{self.dev}")
+
+        # Local version segment
+        if self.local is not None:
+            parts.append(f"+{self.local}")
+
+        return "".join(parts)
+
+    @property
+    def epoch(self) -> int:
+        """The epoch of the version.
+
+        >>> Version("2.0.0").epoch
+        0
+        >>> Version("1!2.0.0").epoch
+        1
+        """
+        epoch: int = self._version.epoch
+        return epoch
+
+    @property
+    def release(self) -> tuple[int, ...]:
+        """The components of the "release" segment of the version.
+
+        >>> Version("1.2.3").release
+        (1, 2, 3)
+        >>> Version("2.0.0").release
+        (2, 0, 0)
+        >>> Version("1!2.0.0.post0").release
+        (2, 0, 0)
+
+        Includes trailing zeroes but not the epoch or any pre-release / development /
+        post-release suffixes.
+        """
+        release: tuple[int, ...] = self._version.release
+        return release
+
+    @property
+    def pre(self) -> tuple[str, int] | None:
+        """The pre-release segment of the version.
+
+        >>> print(Version("1.2.3").pre)
+        None
+        >>> Version("1.2.3a1").pre
+        ('a', 1)
+        >>> Version("1.2.3b1").pre
+        ('b', 1)
+        >>> Version("1.2.3rc1").pre
+        ('rc', 1)
+        """
+        pre: tuple[str, int] | None = self._version.pre
+        return pre
+
+    @property
+    def post(self) -> int | None:
+        """The post-release number of the version.
+
+        >>> print(Version("1.2.3").post)
+        None
+        >>> Version("1.2.3.post1").post
+        1
+        """
+        return self._version.post[1] if self._version.post else None
+
+    @property
+    def dev(self) -> int | None:
+        """The development number of the version.
+
+        >>> print(Version("1.2.3").dev)
+        None
+        >>> Version("1.2.3.dev1").dev
+        1
+        """
+        return self._version.dev[1] if self._version.dev else None
+
+    @property
+    def local(self) -> str | None:
+        """The local version segment of the version.
+
+        >>> print(Version("1.2.3").local)
+        None
+        >>> Version("1.2.3+abc").local
+        'abc'
+        """
+        if self._version.local:
+            return ".".join(str(x) for x in self._version.local)
+        return None
+
+    @property
+    def public(self) -> str:
+        """The public portion of the version.
+
+        >>> Version("1.2.3").public
+        '1.2.3'
+        >>> Version("1.2.3+abc").public
+        '1.2.3'
+        >>> Version("1.2.3+abc.dev1").public
+        '1.2.3'
+        """
+        return str(self).split("+", 1)[0]
+
+    @property
+    def base_version(self) -> str:
+        """The "base version" of the version.
+
+        >>> Version("1.2.3").base_version
+        '1.2.3'
+        >>> Version("1.2.3+abc").base_version
+        '1.2.3'
+        >>> Version("1!1.2.3+abc.dev1").base_version
+        '1!1.2.3'
+
+        The "base version" is the public version of the project without any pre or post
+        release markers.
+        """
+        parts = []
+
+        # Epoch
+        if self.epoch != 0:
+            parts.append(f"{self.epoch}!")
+
+        # Release segment
+        parts.append(".".join(str(x) for x in self.release))
+
+        return "".join(parts)
+
+    @property
+    def is_prerelease(self) -> bool:
+        """Whether this version is a pre-release.
+
+        >>> Version("1.2.3").is_prerelease
+        False
+        >>> Version("1.2.3a1").is_prerelease
+        True
+        >>> Version("1.2.3b1").is_prerelease
+        True
+        >>> Version("1.2.3rc1").is_prerelease
+        True
+        >>> Version("1.2.3dev1").is_prerelease
+        True
+        """
+        return self.dev is not None or self.pre is not None
+
+    @property
+    def is_postrelease(self) -> bool:
+        """Whether this version is a post-release.
+
+        >>> Version("1.2.3").is_postrelease
+        False
+        >>> Version("1.2.3.post1").is_postrelease
+        True
+        """
+        return self.post is not None
+
+    @property
+    def is_devrelease(self) -> bool:
+        """Whether this version is a development release.
+
+        >>> Version("1.2.3").is_devrelease
+        False
+        >>> Version("1.2.3.dev1").is_devrelease
+        True
+        """
+        return self.dev is not None
+
+    @property
+    def major(self) -> int:
+        """The first item of :attr:`release` or ``0`` if unavailable.
+
+        >>> Version("1.2.3").major
+        1
+        """
+        return self.release[0] if len(self.release) >= 1 else 0
+
+    @property
+    def minor(self) -> int:
+        """The second item of :attr:`release` or ``0`` if unavailable.
+
+        >>> Version("1.2.3").minor
+        2
+        >>> Version("1").minor
+        0
+        """
+        return self.release[1] if len(self.release) >= 2 else 0
+
+    @property
+    def micro(self) -> int:
+        """The third item of :attr:`release` or ``0`` if unavailable.
+
+        >>> Version("1.2.3").micro
+        3
+        >>> Version("1").micro
+        0
+        """
+        return self.release[2] if len(self.release) >= 3 else 0
+
+
+def _parse_letter_version(
+    letter: str,
+    number: str | bytes | t.SupportsInt,
+) -> tuple[str, int] | None:
+    if letter:
+        # We consider there to be an implicit 0 in a pre-release if there is
+        # not a numeral associated with it.
+        if number is None:
+            number = 0
+
+        # We normalize any letters to their lower case form
+        letter = letter.lower()
+
+        # We consider some words to be alternate spellings of other words and
+        # in those cases we want to normalize the spellings to our preferred
+        # spelling.
+        if letter == "alpha":
+            letter = "a"
+        elif letter == "beta":
+            letter = "b"
+        elif letter in {"c", "pre", "preview"}:
+            letter = "rc"
+        elif letter in {"rev", "r"}:
+            letter = "post"
+
+        return letter, int(number)
+    if not letter and number:
+        # We assume if we are given a number, but we are not given a letter
+        # then this is using the implicit post release syntax (e.g. 1.0-1)
+        letter = "post"
+
+        return letter, int(number)
+
+    return None
+
+
+_local_version_separators = re.compile(r"[\._-]")
+
+
+def _parse_local_version(local: str) -> LocalType | None:
+    """Take a string like abc.1.twelve and turns it into ("abc", 1, "twelve")."""
+    if local is not None:
+        return tuple(
+            part.lower() if not part.isdigit() else int(part)
+            for part in _local_version_separators.split(local)
+        )
+    return None
+
+
+def _cmpkey(
+    epoch: int,
+    release: tuple[int, ...],
+    pre: tuple[str, int] | None,
+    post: tuple[str, int] | None,
+    dev: tuple[str, int] | None,
+    local: tuple[SubLocalType] | None,
+) -> CmpKey:
+    # When we compare a release version, we want to compare it with all of the
+    # trailing zeros removed. So we'll use a reverse the list, drop all the now
+    # leading zeros until we come to something non zero, then take the rest
+    # re-reverse it back into the correct order and make it a tuple and use
+    # that for our sorting key.
+    release_ = tuple(
+        reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release)))),
+    )
+
+    # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
+    # We'll do this by abusing the pre segment, but we _only_ want to do this
+    # if there is not a pre or a post segment. If we have one of those then
+    # the normal sorting rules will handle this case correctly.
+    if pre is None and post is None and dev is not None:
+        pre_: PrePostDevType = NegativeInfinity
+    # Versions without a pre-release (except as noted above) should sort after
+    # those with one.
+    elif pre is None:
+        pre_ = Infinity
+    else:
+        pre_ = pre
+
+    # Versions without a post segment should sort before those with one.
+    if post is None:
+        post_: PrePostDevType = NegativeInfinity
+
+    else:
+        post_ = post
+
+    # Versions without a development segment should sort after those with one.
+    if dev is None:
+        dev_: PrePostDevType = Infinity
+
+    else:
+        dev_ = dev
+
+    if local is None:
+        # Versions without a local segment should sort before those with one.
+        local_: LocalType = NegativeInfinity
+    else:
+        # Versions with a local segment need that segment parsed to implement
+        # the sorting rules in PEP440.
+        # - Alpha numeric segments sort before numeric segments
+        # - Alpha numeric segments sort lexicographically
+        # - Numeric segments sort numerically
+        # - Shorter versions sort before longer versions when the prefixes
+        #   match exactly
+        local_ = tuple(
+            (i, "") if isinstance(i, int) else (NegativeInfinity, i) for i in local
+        )
+
+    return epoch, release_, pre_, post_, dev_, local_
diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py
index f32ce9c4..ea845ce2 100644
--- a/src/libvcs/cmd/git.py
+++ b/src/libvcs/cmd/git.py
@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import dataclasses
 import datetime
 import pathlib
 import shlex
@@ -10,10 +11,48 @@
 
 from libvcs._internal.run import ProgressCallbackProtocol, run
 from libvcs._internal.types import StrOrBytesPath, StrPath
+from libvcs._vendor.version import InvalidVersion, Version, parse as parse_version
 
 _CMD = t.Union[StrOrBytesPath, Sequence[StrOrBytesPath]]
 
 
+class InvalidBuildOptions(ValueError):
+    """Raised when a git version output is in an unexpected format.
+
+    >>> InvalidBuildOptions("...")
+    InvalidBuildOptions('Unexpected git version output format: ...')
+    """
+
+    def __init__(self, version: str, *args: object) -> None:
+        return super().__init__(f"Unexpected git version output format: {version}")
+
+
+@dataclasses.dataclass
+class GitVersionInfo:
+    """Information about the git version."""
+
+    version: str
+    """Git version string (e.g. '2.43.0')"""
+
+    version_info: tuple[int, int, int] | None = None
+    """Tuple of (major, minor, micro) version numbers, or None if version invalid"""
+
+    cpu: str | None = None
+    """CPU architecture information"""
+
+    commit: str | None = None
+    """Commit associated with this build"""
+
+    sizeof_long: str | None = None
+    """Size of long in the compiled binary"""
+
+    sizeof_size_t: str | None = None
+    """Size of size_t in the compiled binary"""
+
+    shell_path: str | None = None
+    """Shell path configured in git"""
+
+
 class Git:
     """Run commands directly on a git repository."""
 
@@ -616,14 +655,14 @@ def rebase(
         >>> git = Git(path=example_git_repo.path)
         >>> git_remote_repo = create_git_remote_repo()
         >>> git.rebase()
-        'Current branch master is up to date.'
+        'Current branch main is up to date.'
 
         Declare upstream:
 
         >>> git = Git(path=example_git_repo.path)
         >>> git_remote_repo = create_git_remote_repo()
         >>> git.rebase(upstream='origin')
-        'Current branch master is up to date.'
+        'Current branch main is up to date.'
         >>> git.path.exists()
         True
         """
@@ -1360,9 +1399,9 @@ def checkout(
         >>> git = Git(path=example_git_repo.path)
 
         >>> git.checkout()
-        "Your branch is up to date with 'origin/master'."
+        "Your branch is up to date with 'origin/main'."
 
-        >>> git.checkout(branch='origin/master', pathspec='.')
+        >>> git.checkout(branch='origin/main', pathspec='.')
         ''
         """
         local_flags: list[str] = []
@@ -1478,7 +1517,7 @@ def status(
         >>> git = Git(path=example_git_repo.path)
 
         >>> git.status()
-        "On branch master..."
+        "On branch main..."
 
         >>> pathlib.Path(example_git_repo.path / 'new_file.txt').touch()
 
@@ -1746,33 +1785,136 @@ def config(
     def version(
         self,
         *,
-        build_options: bool | None = None,
         # libvcs special behavior
         check_returncode: bool | None = None,
         **kwargs: t.Any,
-    ) -> str:
-        """Version. Wraps `git version `_.
+    ) -> Version:
+        """Get git version. Wraps `git version `_.
+
+        Returns
+        -------
+        Version
+            Parsed semantic version object from git version output
+
+        Raises
+        ------
+        InvalidVersion
+            If the git version output is in an unexpected format
 
         Examples
         --------
         >>> git = Git(path=example_git_repo.path)
 
-        >>> git.version()
-        'git version ...'
-
-        >>> git.version(build_options=True)
-        'git version ...'
+        >>> version = git.version()
+        >>> isinstance(version.major, int)
+        True
         """
         local_flags: list[str] = []
 
-        if build_options is True:
-            local_flags.append("--build-options")
-
-        return self.run(
+        output = self.run(
             ["version", *local_flags],
             check_returncode=check_returncode,
         )
 
+        # Extract version string and parse it
+        if output.startswith("git version "):
+            version_str = output.split("\n", 1)[0].replace("git version ", "").strip()
+            return parse_version(version_str)
+
+        # Raise exception if output format is unexpected
+        raise InvalidVersion(output)
+
+    def build_options(
+        self,
+        *,
+        check_returncode: bool | None = None,
+        **kwargs: t.Any,
+    ) -> GitVersionInfo:
+        """Get detailed Git version information as a structured dataclass.
+
+        Runs ``git --version --build-options`` and parses the output.
+
+        Returns
+        -------
+        GitVersionInfo
+            Dataclass containing structured information about the git version and build
+
+        Raises
+        ------
+        InvalidBuildOptions
+            If the git build options output is in an unexpected format
+
+        Examples
+        --------
+        >>> git = Git(path=example_git_repo.path)
+        >>> version_info = git.build_options()
+        >>> isinstance(version_info, GitVersionInfo)
+        True
+        >>> isinstance(version_info.version, str)
+        True
+        """
+        # Get raw output directly using run() instead of version()
+        output = self.run(
+            ["version", "--build-options"],
+            check_returncode=check_returncode,
+        )
+
+        # Parse the output into a structured format
+        lines = output.strip().split("\n")
+        if not lines or not lines[0].startswith("git version "):
+            first_line = lines[0] if lines else "(empty)"
+            msg = f"Expected 'git version' in first line, got: {first_line}"
+            raise InvalidBuildOptions(msg)
+
+        version_str = lines[0].replace("git version ", "").strip()
+        result = GitVersionInfo(version=version_str)
+
+        # Parse semantic version components
+        try:
+            parsed_version = parse_version(version_str)
+            result.version_info = (
+                parsed_version.major,
+                parsed_version.minor,
+                parsed_version.micro,
+            )
+        except InvalidVersion:
+            # Fall back to string-only if can't be parsed
+            result.version_info = None
+
+        # Field mapping with type annotations for clarity
+        field_mapping: dict[str, str] = {
+            "cpu": "cpu",
+            "sizeof-long": "sizeof_long",
+            "sizeof-size_t": "sizeof_size_t",
+            "shell-path": "shell_path",
+            "commit": "commit",
+        }
+
+        # Parse build options
+        for line in lines[1:]:
+            line = line.strip()
+            if not line:
+                continue
+
+            # Special case for "no commit" message
+            if "no commit associated with this build" in line.lower():
+                result.commit = line
+                continue
+
+            # Parse key:value pairs
+            if ":" not in line:
+                # Log unexpected format but don't fail
+                continue
+
+            key, _, value = line.partition(":")
+            key = key.strip()
+            value = value.strip()
+
+            if key in field_mapping:
+                setattr(result, field_mapping[key], value)
+
+        return result
+
     def rev_parse(
         self,
         *,
@@ -1915,7 +2057,7 @@ def rev_list(
         '...'
 
         >>> git.run(['commit', '--allow-empty', '--message=Moo'])
-        '[master ...] Moo'
+        '[main ...] Moo'
 
         >>> git.rev_list(commit="HEAD", max_count=1)
         '...'
@@ -2101,17 +2243,17 @@ def show_ref(
         >>> git.show_ref()
         '...'
 
-        >>> git.show_ref(pattern='master')
+        >>> git.show_ref(pattern='main')
         '...'
 
-        >>> git.show_ref(pattern='master', head=True)
+        >>> git.show_ref(pattern='main', head=True)
         '...'
 
         >>> git.show_ref(pattern='HEAD', verify=True)
         '... HEAD'
 
-        >>> git.show_ref(pattern='master', dereference=True)
-        '... refs/heads/master\n... refs/remotes/origin/master'
+        >>> git.show_ref(pattern='main', dereference=True)
+        '... refs/heads/main\n... refs/remotes/origin/main'
 
         >>> git.show_ref(pattern='HEAD', tags=True)
         ''
diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py
index 5be7b0b3..42f25c28 100644
--- a/src/libvcs/pytest_plugin.py
+++ b/src/libvcs/pytest_plugin.py
@@ -4,6 +4,7 @@
 
 import functools
 import getpass
+import os
 import pathlib
 import random
 import shutil
@@ -300,6 +301,7 @@ def __call__(
 
 
 DEFAULT_GIT_REMOTE_REPO_CMD_ARGS = ["--bare"]
+DEFAULT_GIT_INITIAL_BRANCH = os.environ.get("LIBVCS_GIT_DEFAULT_INITIAL_BRANCH", "main")
 
 
 def _create_git_remote_repo(
@@ -307,13 +309,47 @@ def _create_git_remote_repo(
     remote_repo_post_init: CreateRepoPostInitFn | None = None,
     init_cmd_args: InitCmdArgs = DEFAULT_GIT_REMOTE_REPO_CMD_ARGS,
     env: _ENV | None = None,
+    initial_branch: str | None = None,
 ) -> pathlib.Path:
+    """Create a git repository with version-aware initialization.
+
+    Parameters
+    ----------
+    remote_repo_path : pathlib.Path
+        Path where the repository will be created
+    remote_repo_post_init : CreateRepoPostInitFn | None
+        Optional callback to run after repository creation
+    init_cmd_args : InitCmdArgs
+        Additional arguments for git init (e.g., ["--bare"])
+    env : _ENV | None
+        Environment variables to use
+    initial_branch : str | None
+        Name of the initial branch. If None, uses LIBVCS_GIT_DEFAULT_INITIAL_BRANCH
+        environment variable or "main" as default.
+    """
+    from libvcs.cmd.git import Git
+
+    if initial_branch is None:
+        initial_branch = DEFAULT_GIT_INITIAL_BRANCH
+
     if init_cmd_args is None:
         init_cmd_args = []
-    run(
-        ["git", "init", remote_repo_path.stem, *init_cmd_args],
-        cwd=remote_repo_path.parent,
-    )
+
+    # Parse init_cmd_args to determine if --bare is requested
+    bare = "--bare" in init_cmd_args
+
+    # Create the directory
+    remote_repo_path.mkdir(parents=True, exist_ok=True)
+
+    # Create Git instance for the new repository
+    git = Git(path=remote_repo_path)
+
+    try:
+        # Try with --initial-branch (Git 2.30.0+)
+        git.init(initial_branch=initial_branch, bare=bare, check_returncode=True)
+    except exc.CommandError:
+        # Fall back to plain init for older Git versions
+        git.init(bare=bare, check_returncode=True)
 
     if remote_repo_post_init is not None and callable(remote_repo_post_init):
         remote_repo_post_init(remote_repo_path=remote_repo_path, env=env)
diff --git a/src/libvcs/sync/git.py b/src/libvcs/sync/git.py
index 15a2d63a..4f430811 100644
--- a/src/libvcs/sync/git.py
+++ b/src/libvcs/sync/git.py
@@ -657,13 +657,8 @@ def get_git_version(self) -> str:
         -------
         git version
         """
-        VERSION_PFX = "git version "
         version = self.cmd.version()
-        if version.startswith(VERSION_PFX):
-            version = version[len(VERSION_PFX) :].split()[0]
-        else:
-            version = ""
-        return ".".join(version.split(".")[:3])
+        return ".".join([str(x) for x in (version.major, version.minor, version.micro)])
 
     def status(self) -> GitStatus:
         """Retrieve status of project in dict format.
@@ -683,8 +678,8 @@ def status(self) -> GitStatus:
         >>> git_repo.obtain()
         >>> git_repo.status()
         GitStatus(\
-branch_oid='...', branch_head='master', \
-branch_upstream='origin/master', \
+branch_oid='...', branch_head='main', \
+branch_upstream='origin/main', \
 branch_ab='+0 -0', \
 branch_ahead='0', \
 branch_behind='0'\
diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py
index 1aa15560..7226bb41 100644
--- a/tests/cmd/test_git.py
+++ b/tests/cmd/test_git.py
@@ -7,6 +7,7 @@
 
 import pytest
 
+from libvcs._vendor.version import InvalidVersion, Version
 from libvcs.cmd import git
 
 
@@ -19,3 +20,109 @@ def test_git_constructor(
     repo = git.Git(path=path_type(tmp_path))
 
     assert repo.path == tmp_path
+
+
+def test_version_basic(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
+    """Test basic git version output."""
+    git_cmd = git.Git(path=tmp_path)
+
+    monkeypatch.setattr(git_cmd, "run", lambda *args, **kwargs: "git version 2.43.0")
+
+    result = git_cmd.version()
+    assert isinstance(result, Version)
+    assert result.major == 2
+    assert result.minor == 43
+    assert result.micro == 0
+    assert str(result) == "2.43.0"
+
+
+def test_build_options(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
+    """Test build_options() method."""
+    git_cmd = git.Git(path=tmp_path)
+
+    sample_output = """git version 2.43.0
+cpu: x86_64
+no commit associated with this build
+sizeof-long: 8
+sizeof-size_t: 8
+shell-path: /bin/sh"""
+
+    # Mock run() directly instead of version()
+    def mock_run(cmd_args: list[str], **kwargs: t.Any) -> str:
+        assert cmd_args == ["version", "--build-options"]
+        return sample_output
+
+    monkeypatch.setattr(git_cmd, "run", mock_run)
+
+    result = git_cmd.build_options()
+
+    assert isinstance(result, git.GitVersionInfo)
+    assert result.version == "2.43.0"
+    assert result.version_info == (2, 43, 0)
+    assert result.cpu == "x86_64"
+    assert result.commit == "no commit associated with this build"
+    assert result.sizeof_long == "8"
+    assert result.sizeof_size_t == "8"
+    assert result.shell_path == "/bin/sh"
+
+
+def test_build_options_invalid_version(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: pathlib.Path,
+) -> None:
+    """Test build_options() with invalid version string."""
+    git_cmd = git.Git(path=tmp_path)
+
+    sample_output = """git version development
+cpu: x86_64
+commit: abcdef123456
+sizeof-long: 8
+sizeof-size_t: 8
+shell-path: /bin/sh"""
+
+    def mock_run(cmd_args: list[str], **kwargs: t.Any) -> str:
+        assert cmd_args == ["version", "--build-options"]
+        return sample_output
+
+    monkeypatch.setattr(git_cmd, "run", mock_run)
+
+    result = git_cmd.build_options()
+
+    assert isinstance(result, git.GitVersionInfo)
+    assert result.version == "development"
+    assert result.version_info is None
+    assert result.commit == "abcdef123456"
+
+
+def test_version_invalid_format(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: pathlib.Path,
+) -> None:
+    """Test version() with invalid output format."""
+    git_cmd = git.Git(path=tmp_path)
+
+    invalid_output = "not a git version format"
+
+    monkeypatch.setattr(git_cmd, "run", lambda *args, **kwargs: invalid_output)
+
+    with pytest.raises(InvalidVersion) as excinfo:
+        git_cmd.version()
+
+    assert f"Invalid version: '{invalid_output}'" in str(excinfo.value)
+
+
+def test_build_options_invalid_format(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: pathlib.Path,
+) -> None:
+    """Test build_options() with invalid output format."""
+    git_cmd = git.Git(path=tmp_path)
+
+    invalid_output = "not a git version format"
+
+    monkeypatch.setattr(git_cmd, "run", lambda *args, **kwargs: invalid_output)
+
+    with pytest.raises(git.InvalidBuildOptions) as excinfo:
+        git_cmd.build_options()
+
+    assert "Unexpected git version output format" in str(excinfo.value)
diff --git a/tests/sync/test_git.py b/tests/sync/test_git.py
index b31a84e4..5d2090ed 100644
--- a/tests/sync/test_git.py
+++ b/tests/sync/test_git.py
@@ -757,14 +757,14 @@ def test_get_current_remote_name(git_repo: GitSync) -> None:
         "Should reflect new upstream branch (different remote)"
     )
 
-    upstream = "{}/{}".format(new_remote_name, "master")
+    upstream = "{}/{}".format(new_remote_name, "main")
 
     git_repo.run(["branch", "--set-upstream-to", upstream])
     assert git_repo.get_current_remote_name() == upstream, (
         "Should reflect upstream branch (different remote+branch)"
     )
 
-    git_repo.run(["checkout", "master"])
+    git_repo.run(["checkout", "main"])
 
     # Different remote, different branch
     remote = f"{new_remote_name}/{new_branch}"
diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py
index 329a6ea6..c7a4b39f 100644
--- a/tests/test_pytest_plugin.py
+++ b/tests/test_pytest_plugin.py
@@ -9,6 +9,12 @@
 import pytest
 
 from libvcs._internal.run import run
+from libvcs.cmd.git import Git
+from libvcs.exc import CommandError
+from libvcs.pytest_plugin import (
+    DEFAULT_GIT_INITIAL_BRANCH,
+    _create_git_remote_repo,
+)
 
 if t.TYPE_CHECKING:
     import pathlib
@@ -176,3 +182,221 @@ def test_git_bare_repo_sync_and_commit(
     # Test
     result = pytester.runpytest(str(first_test_filename))
     result.assert_outcomes(passed=2)
+
+
+def test_create_git_remote_repo_basic(tmp_path: pathlib.Path) -> None:
+    """Test basic git repository creation."""
+    repo_path = tmp_path / "test-repo"
+
+    result = _create_git_remote_repo(repo_path, init_cmd_args=[])
+
+    assert result == repo_path
+    assert repo_path.exists()
+    assert (repo_path / ".git").exists()
+
+
+def test_create_git_remote_repo_bare(tmp_path: pathlib.Path) -> None:
+    """Test bare git repository creation."""
+    repo_path = tmp_path / "test-repo.git"
+
+    result = _create_git_remote_repo(repo_path, init_cmd_args=["--bare"])
+
+    assert result == repo_path
+    assert repo_path.exists()
+    assert (repo_path / "HEAD").exists()
+    assert not (repo_path / ".git").exists()
+
+
+def test_create_git_remote_repo_with_initial_branch(
+    tmp_path: pathlib.Path,
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """Test repository creation with custom initial branch.
+
+    This test checks both modern Git (2.30.0+) and fallback behavior.
+    """
+    repo_path = tmp_path / "test-repo"
+
+    # Track Git.init calls
+    init_calls: list[dict[str, t.Any]] = []
+
+    def mock_init(self: Git, *args: t.Any, **kwargs: t.Any) -> str:
+        init_calls.append({"args": args, "kwargs": kwargs})
+
+        # Simulate old Git that doesn't support --initial-branch
+        if kwargs.get("initial_branch"):
+            msg = "error: unknown option `initial-branch'"
+            raise CommandError(
+                msg,
+                returncode=1,
+                cmd=["git", "init", "--initial-branch=main"],
+            )
+
+        # Create the repo directory to simulate successful init
+        self.path.mkdir(exist_ok=True)
+        (self.path / ".git").mkdir(exist_ok=True)
+        return "Initialized empty Git repository"
+
+    monkeypatch.setattr(Git, "init", mock_init)
+
+    result = _create_git_remote_repo(repo_path, initial_branch="develop")
+
+    # Should have tried twice: once with initial_branch, once without
+    assert len(init_calls) == 2
+    assert init_calls[0]["kwargs"].get("initial_branch") == "develop"
+    assert "initial_branch" not in init_calls[1]["kwargs"]
+    assert result == repo_path
+
+
+def test_create_git_remote_repo_modern_git(
+    tmp_path: pathlib.Path,
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """Test repository creation with Git 2.30.0+ that supports --initial-branch."""
+    repo_path = tmp_path / "test-repo"
+
+    init_calls: list[dict[str, t.Any]] = []
+
+    def mock_init(self: Git, *args: t.Any, **kwargs: t.Any) -> str:
+        init_calls.append({"args": args, "kwargs": kwargs})
+        # Simulate successful init with --initial-branch support
+        self.path.mkdir(exist_ok=True)
+        (self.path / ".git").mkdir(exist_ok=True)
+        branch = kwargs.get("initial_branch", "master")
+        return f"Initialized empty Git repository with initial branch '{branch}'"
+
+    monkeypatch.setattr(Git, "init", mock_init)
+
+    result = _create_git_remote_repo(repo_path, initial_branch="main")
+
+    # Should only call init once since it succeeded
+    assert len(init_calls) == 1
+    assert init_calls[0]["kwargs"].get("initial_branch") == "main"
+    assert result == repo_path
+
+
+@pytest.mark.parametrize(
+    ("env_var", "param", "expected_branch"),
+    [
+        ("custom-env", None, "custom-env"),  # Use env var
+        ("custom-env", "param-override", "param-override"),  # Param overrides env
+        (None, "explicit-param", "explicit-param"),  # Use param
+        (None, None, DEFAULT_GIT_INITIAL_BRANCH),  # Use default
+    ],
+)
+def test_create_git_remote_repo_branch_configuration(
+    tmp_path: pathlib.Path,
+    monkeypatch: pytest.MonkeyPatch,
+    env_var: str | None,
+    param: str | None,
+    expected_branch: str,
+) -> None:
+    """Test initial branch configuration hierarchy."""
+    # Always reload the module to ensure fresh state
+    import sys
+
+    if "libvcs.pytest_plugin" in sys.modules:
+        del sys.modules["libvcs.pytest_plugin"]
+
+    if env_var:
+        monkeypatch.setenv("LIBVCS_GIT_DEFAULT_INITIAL_BRANCH", env_var)
+
+    # Import after setting env var
+    from libvcs.pytest_plugin import _create_git_remote_repo
+
+    repo_path = tmp_path / "test-repo"
+
+    # Track what branch was used
+    used_branch = None
+
+    def mock_init(self: Git, *args: t.Any, **kwargs: t.Any) -> str:
+        nonlocal used_branch
+        used_branch = kwargs.get("initial_branch")
+        self.path.mkdir(exist_ok=True)
+        (self.path / ".git").mkdir(exist_ok=True)
+        return "Initialized"
+
+    monkeypatch.setattr(Git, "init", mock_init)
+
+    _create_git_remote_repo(repo_path, initial_branch=param)
+
+    assert used_branch == expected_branch
+
+
+def test_create_git_remote_repo_post_init_callback(tmp_path: pathlib.Path) -> None:
+    """Test that post-init callback is executed."""
+    repo_path = tmp_path / "test-repo"
+    callback_executed = False
+    callback_path = None
+
+    def post_init_callback(
+        remote_repo_path: pathlib.Path,
+        env: t.Any = None,
+    ) -> None:
+        nonlocal callback_executed, callback_path
+        callback_executed = True
+        callback_path = remote_repo_path
+        (remote_repo_path / "callback-marker.txt").write_text("executed")
+
+    _create_git_remote_repo(
+        repo_path,
+        remote_repo_post_init=post_init_callback,
+        init_cmd_args=[],  # Create non-bare repo for easier testing
+    )
+
+    assert callback_executed
+    assert callback_path == repo_path
+    assert (repo_path / "callback-marker.txt").exists()
+    assert (repo_path / "callback-marker.txt").read_text() == "executed"
+
+
+def test_create_git_remote_repo_permission_error(
+    tmp_path: pathlib.Path,
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """Test handling of permission errors."""
+    repo_path = tmp_path / "test-repo"
+
+    def mock_init(self: Git, *args: t.Any, **kwargs: t.Any) -> str:
+        msg = "fatal: cannot mkdir .git: Permission denied"
+        raise CommandError(
+            msg,
+            returncode=128,
+            cmd=["git", "init"],
+        )
+
+    monkeypatch.setattr(Git, "init", mock_init)
+
+    with pytest.raises(CommandError) as exc_info:
+        _create_git_remote_repo(repo_path)
+
+    assert "Permission denied" in str(exc_info.value)
+
+
+@pytest.mark.skipif(
+    not shutil.which("git"),
+    reason="git is not available",
+)
+def test_create_git_remote_repo_integration(tmp_path: pathlib.Path) -> None:
+    """Integration test with real git command."""
+    repo_path = tmp_path / "integration-repo"
+
+    result = _create_git_remote_repo(repo_path, initial_branch="development")
+
+    assert result == repo_path
+    assert repo_path.exists()
+
+    # Check actual git status
+    git = Git(path=repo_path)
+
+    # Get git version to determine what to check
+    try:
+        version = git.version()
+        if version.major > 2 or (version.major == 2 and version.minor >= 30):
+            # Can check branch name on modern Git
+            branch_output = git.run(["symbolic-ref", "HEAD"])
+            assert "refs/heads/development" in branch_output
+    except Exception:
+        # Just verify it's a valid repo
+        status = git.run(["status", "--porcelain"])
+        assert isinstance(status, str)