From a70d66b77c2151dc7de4ef63ef84f20a32cad03b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 11 May 2025 06:17:41 -0500 Subject: [PATCH 1/8] _vendor: Add `Version` object from `packaging` --- src/libvcs/_vendor/__init__.py | 0 src/libvcs/_vendor/_structures.py | 63 ++++ src/libvcs/_vendor/version.py | 587 ++++++++++++++++++++++++++++++ 3 files changed, 650 insertions(+) create mode 100644 src/libvcs/_vendor/__init__.py create mode 100644 src/libvcs/_vendor/_structures.py create mode 100644 src/libvcs/_vendor/version.py diff --git a/src/libvcs/_vendor/__init__.py b/src/libvcs/_vendor/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/libvcs/_vendor/_structures.py b/src/libvcs/_vendor/_structures.py new file mode 100644 index 000000000..c2fd421dd --- /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 000000000..f982d1d74 --- /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_

From 8b6c26cbd8c0c81568acdf7561ca3ecd89152b42 Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 11 May 2025 06:26:24 -0500
Subject: [PATCH 2/8] Git(feat[version]): Add structured version info via
 build_options()

- Add GitVersionInfo dataclass to provide structured git version output
- Update version() method to return raw string output
- Add build_options() method that returns a GitVersionInfo instance
- Fix doctests in vendored version module
- Use internal vendor.version module instead of external packaging
---
 src/libvcs/cmd/git.py  | 160 +++++++++++++++++++++++++++++++++++++----
 src/libvcs/sync/git.py |   7 +-
 tests/cmd/test_git.py  | 107 +++++++++++++++++++++++++++
 3 files changed, 256 insertions(+), 18 deletions(-)

diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py
index f32ce9c47..6e124ab4c 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."""
 
@@ -1746,33 +1785,130 @@ 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
+        result = GitVersionInfo(version="")
+
+        # First line is always "git version X.Y.Z"
+        lines = output.strip().split("\n")
+        if not lines or not lines[0].startswith("git version "):
+            raise InvalidBuildOptions(output)
+
+        version_str = lines[0].replace("git version ", "").strip()
+        result.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
+
+        # Parse additional build info
+        for line in lines[1:]:
+            line = line.strip()
+            if not line:
+                continue
+
+            if ":" in line:
+                key, value = line.split(":", 1)
+                key = key.strip()
+                value = value.strip()
+
+                if key == "cpu":
+                    result.cpu = value
+                elif key == "sizeof-long":
+                    result.sizeof_long = value
+                elif key == "sizeof-size_t":
+                    result.sizeof_size_t = value
+                elif key == "shell-path":
+                    result.shell_path = value
+                elif key == "commit":
+                    result.commit = value
+            # Special handling for the "no commit" line which has no colon
+            elif "no commit associated with this build" in line.lower():
+                result.commit = line
+
+        return result
+
     def rev_parse(
         self,
         *,
diff --git a/src/libvcs/sync/git.py b/src/libvcs/sync/git.py
index 15a2d63a5..3d50bcc83 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.
diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py
index 1aa155602..7226bb418 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)

From c6780e21a083f588122f21defbf14ec6484aa68b Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 11 May 2025 06:29:49 -0500
Subject: [PATCH 3/8] docs(CHANGES) Note `version()`

---
 CHANGES | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/CHANGES b/CHANGES
index 286a3a095..dd1bc8372 100644
--- a/CHANGES
+++ b/CHANGES
@@ -15,6 +15,14 @@ $ pip install --user --upgrade --pre libvcs
 
 
 
+### Compatibility
+
+- 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.
+
 ## libvcs 0.35.1 (2025-06-21)
 
 ### Bug fixes

From bec24f204ba01e38b1978e19b9b1ebc4ef522f34 Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 11 May 2025 07:03:40 -0500
Subject: [PATCH 4/8] docs(MIGRATION) Note `version()` returning `Version`

---
 MIGRATION | 21 +++++++++++++++++++++
 1 file changed, 21 insertions(+)

diff --git a/MIGRATION b/MIGRATION
index 99a841bad..bbee139dc 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`

From c1b509a96d2069fb1658a9a95eecb9a98ef5f1c2 Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 1 Jun 2025 07:36:44 -0500
Subject: [PATCH 5/8] py(cmd[git]) Streamline `build_options()`

---
 src/libvcs/cmd/git.py | 52 ++++++++++++++++++++++++-------------------
 1 file changed, 29 insertions(+), 23 deletions(-)

diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py
index 6e124ab4c..cbd59f11b 100644
--- a/src/libvcs/cmd/git.py
+++ b/src/libvcs/cmd/git.py
@@ -1860,15 +1860,14 @@ def build_options(
         )
 
         # Parse the output into a structured format
-        result = GitVersionInfo(version="")
-
-        # First line is always "git version X.Y.Z"
         lines = output.strip().split("\n")
         if not lines or not lines[0].startswith("git version "):
-            raise InvalidBuildOptions(output)
+            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.version = version_str
+        result = GitVersionInfo(version=version_str)
 
         # Parse semantic version components
         try:
@@ -1882,30 +1881,37 @@ def build_options(
             # Fall back to string-only if can't be parsed
             result.version_info = None
 
-        # Parse additional build info
+        # 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
 
-            if ":" in line:
-                key, value = line.split(":", 1)
-                key = key.strip()
-                value = value.strip()
-
-                if key == "cpu":
-                    result.cpu = value
-                elif key == "sizeof-long":
-                    result.sizeof_long = value
-                elif key == "sizeof-size_t":
-                    result.sizeof_size_t = value
-                elif key == "shell-path":
-                    result.shell_path = value
-                elif key == "commit":
-                    result.commit = value
-            # Special handling for the "no commit" line which has no colon
-            elif "no commit associated with this build" in line.lower():
+            # 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
 

From a12f4d4a3768020a9a6353eb0e3105de6c8a9926 Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 1 Jun 2025 08:26:44 -0500
Subject: [PATCH 6/8] pytest_plugin(fix[_create_git_remote_repo]): Use
 Git.init() for version compatibility

why: Fix compatibility with Git 2.43.0+ which has stricter init behavior
what:
- Replace raw run() command with Git.init() method
- Add initial_branch parameter with env var and default fallback
- Try --initial-branch flag first, fall back for older Git versions
- Add DEFAULT_GIT_INITIAL_BRANCH constant configurable via env var

This ensures Git repository creation works across all Git versions by
leveraging libvcs's own Git command abstraction layer.
---
 src/libvcs/pytest_plugin.py | 44 +++++++++++++++++++++++++++++++++----
 1 file changed, 40 insertions(+), 4 deletions(-)

diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py
index 5be7b0b35..42f25c28f 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)

From 2b815f623fd23116408b223f103be275cc395dc7 Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 1 Jun 2025 08:27:20 -0500
Subject: [PATCH 7/8] tests(test[pytest_plugin]): Add comprehensive tests for
 Git init compatibility

why: Ensure Git init compatibility fix works across all scenarios
what:
- Test basic and bare repository creation
- Test Git version compatibility with mocked old/new Git
- Test configuration hierarchy (param > env > default)
- Test error handling for permission errors
- Test post-init callback execution
- Add integration test with real Git command

Tests use monkeypatch to simulate different Git versions and ensure
the fallback mechanism works correctly for older Git versions that
don't support --initial-branch.
---
 tests/test_pytest_plugin.py | 224 ++++++++++++++++++++++++++++++++++++
 1 file changed, 224 insertions(+)

diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py
index 329a6ea60..c7a4b39fb 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)

From 46bad96b9e63f6cf75c2fa591ac98055bf9078b5 Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 1 Jun 2025 08:59:53 -0500
Subject: [PATCH 8/8] fix(tests): Update remaining master branch references to
 main

why: Complete the migration from master to main as default branch
what:
- Update test_get_current_remote_name to checkout 'main' instead of 'master'
- Update Git.rebase doctests to expect 'main' in output
- Update Git.rev_list doctest to expect 'main' in commit output
- All tests now pass with the new default branch configuration
---
 conftest.py            |  1 +
 src/libvcs/cmd/git.py  | 20 ++++++++++----------
 src/libvcs/sync/git.py |  4 ++--
 tests/sync/test_git.py |  4 ++--
 4 files changed, 15 insertions(+), 14 deletions(-)

diff --git a/conftest.py b/conftest.py
index 6a3efb34a..519bb19a9 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/cmd/git.py b/src/libvcs/cmd/git.py
index cbd59f11b..ea845ce28 100644
--- a/src/libvcs/cmd/git.py
+++ b/src/libvcs/cmd/git.py
@@ -655,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
         """
@@ -1399,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] = []
@@ -1517,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()
 
@@ -2057,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)
         '...'
@@ -2243,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/sync/git.py b/src/libvcs/sync/git.py
index 3d50bcc83..4f4308113 100644
--- a/src/libvcs/sync/git.py
+++ b/src/libvcs/sync/git.py
@@ -678,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/sync/test_git.py b/tests/sync/test_git.py
index b31a84e45..5d2090ede 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}"