Skip to content

Commit fb5c900

Browse files
committed
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
1 parent b56f280 commit fb5c900

File tree

3 files changed

+253
-18
lines changed

3 files changed

+253
-18
lines changed

src/libvcs/cmd/git.py

Lines changed: 148 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import dataclasses
56
import datetime
67
import pathlib
78
import shlex
@@ -10,10 +11,48 @@
1011

1112
from libvcs._internal.run import ProgressCallbackProtocol, run
1213
from libvcs._internal.types import StrOrBytesPath, StrPath
14+
from libvcs._vendor.version import InvalidVersion, Version, parse as parse_version
1315

1416
_CMD = t.Union[StrOrBytesPath, Sequence[StrOrBytesPath]]
1517

1618

19+
class InvalidBuildOptions(ValueError):
20+
"""Raised when a git version output is in an unexpected format.
21+
22+
>>> InvalidBuildOptions("...")
23+
InvalidBuildOptions('Unexpected git version output format: ...')
24+
"""
25+
26+
def __init__(self, version: str, *args: object) -> None:
27+
return super().__init__(f"Unexpected git version output format: {version}")
28+
29+
30+
@dataclasses.dataclass
31+
class GitVersionInfo:
32+
"""Information about the git version."""
33+
34+
version: str
35+
"""Git version string (e.g. '2.43.0')"""
36+
37+
version_info: tuple[int, int, int] | None = None
38+
"""Tuple of (major, minor, micro) version numbers, or None if version invalid"""
39+
40+
cpu: str | None = None
41+
"""CPU architecture information"""
42+
43+
commit: str | None = None
44+
"""Commit associated with this build"""
45+
46+
sizeof_long: str | None = None
47+
"""Size of long in the compiled binary"""
48+
49+
sizeof_size_t: str | None = None
50+
"""Size of size_t in the compiled binary"""
51+
52+
shell_path: str | None = None
53+
"""Shell path configured in git"""
54+
55+
1756
class Git:
1857
"""Run commands directly on a git repository."""
1958

@@ -1746,33 +1785,130 @@ def config(
17461785
def version(
17471786
self,
17481787
*,
1749-
build_options: bool | None = None,
17501788
# libvcs special behavior
17511789
check_returncode: bool | None = None,
17521790
**kwargs: t.Any,
1753-
) -> str:
1754-
"""Version. Wraps `git version <https://git-scm.com/docs/git-version>`_.
1791+
) -> Version:
1792+
"""Get git version. Wraps `git version <https://git-scm.com/docs/git-version>`_.
1793+
1794+
Returns
1795+
-------
1796+
Version
1797+
Parsed semantic version object from git version output
1798+
1799+
Raises
1800+
------
1801+
InvalidVersion
1802+
If the git version output is in an unexpected format
17551803
17561804
Examples
17571805
--------
17581806
>>> git = Git(path=example_git_repo.path)
17591807
1760-
>>> git.version()
1761-
'git version ...'
1762-
1763-
>>> git.version(build_options=True)
1764-
'git version ...'
1808+
>>> version = git.version()
1809+
>>> isinstance(version.major, int)
1810+
True
17651811
"""
17661812
local_flags: list[str] = []
17671813

1768-
if build_options is True:
1769-
local_flags.append("--build-options")
1770-
1771-
return self.run(
1814+
output = self.run(
17721815
["version", *local_flags],
17731816
check_returncode=check_returncode,
17741817
)
17751818

1819+
# Extract version string and parse it
1820+
if output.startswith("git version "):
1821+
version_str = output.split("\n", 1)[0].replace("git version ", "").strip()
1822+
return parse_version(version_str)
1823+
1824+
# Raise exception if output format is unexpected
1825+
raise InvalidVersion(output)
1826+
1827+
def build_options(
1828+
self,
1829+
*,
1830+
check_returncode: bool | None = None,
1831+
**kwargs: t.Any,
1832+
) -> GitVersionInfo:
1833+
"""Get detailed Git version information as a structured dataclass.
1834+
1835+
Runs ``git --version --build-options`` and parses the output.
1836+
1837+
Returns
1838+
-------
1839+
GitVersionInfo
1840+
Dataclass containing structured information about the git version and build
1841+
1842+
Raises
1843+
------
1844+
InvalidBuildOptions
1845+
If the git build options output is in an unexpected format
1846+
1847+
Examples
1848+
--------
1849+
>>> git = Git(path=example_git_repo.path)
1850+
>>> version_info = git.build_options()
1851+
>>> isinstance(version_info, GitVersionInfo)
1852+
True
1853+
>>> isinstance(version_info.version, str)
1854+
True
1855+
"""
1856+
# Get raw output directly using run() instead of version()
1857+
output = self.run(
1858+
["version", "--build-options"],
1859+
check_returncode=check_returncode,
1860+
)
1861+
1862+
# Parse the output into a structured format
1863+
result = GitVersionInfo(version="")
1864+
1865+
# First line is always "git version X.Y.Z"
1866+
lines = output.strip().split("\n")
1867+
if not lines or not lines[0].startswith("git version "):
1868+
raise InvalidBuildOptions(output)
1869+
1870+
version_str = lines[0].replace("git version ", "").strip()
1871+
result.version = version_str
1872+
1873+
# Parse semantic version components
1874+
try:
1875+
parsed_version = parse_version(version_str)
1876+
result.version_info = (
1877+
parsed_version.major,
1878+
parsed_version.minor,
1879+
parsed_version.micro,
1880+
)
1881+
except InvalidVersion:
1882+
# Fall back to string-only if can't be parsed
1883+
result.version_info = None
1884+
1885+
# Parse additional build info
1886+
for line in lines[1:]:
1887+
line = line.strip()
1888+
if not line:
1889+
continue
1890+
1891+
if ":" in line:
1892+
key, value = line.split(":", 1)
1893+
key = key.strip()
1894+
value = value.strip()
1895+
1896+
if key == "cpu":
1897+
result.cpu = value
1898+
elif key == "sizeof-long":
1899+
result.sizeof_long = value
1900+
elif key == "sizeof-size_t":
1901+
result.sizeof_size_t = value
1902+
elif key == "shell-path":
1903+
result.shell_path = value
1904+
elif key == "commit":
1905+
result.commit = value
1906+
# Special handling for the "no commit" line which has no colon
1907+
elif "no commit associated with this build" in line.lower():
1908+
result.commit = line
1909+
1910+
return result
1911+
17761912
def rev_parse(
17771913
self,
17781914
*,

src/libvcs/sync/git.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -658,13 +658,8 @@ def get_git_version(self) -> str:
658658
-------
659659
git version
660660
"""
661-
VERSION_PFX = "git version "
662661
version = self.cmd.version()
663-
if version.startswith(VERSION_PFX):
664-
version = version[len(VERSION_PFX) :].split()[0]
665-
else:
666-
version = ""
667-
return ".".join(version.split(".")[:3])
662+
return ".".join([str(x) for x in (version.major, version.minor, version.micro)])
668663

669664
def status(self) -> GitStatus:
670665
"""Retrieve status of project in dict format.

tests/cmd/test_git.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pytest
99

10+
from libvcs._vendor.version import InvalidVersion, Version
1011
from libvcs.cmd import git
1112

1213

@@ -19,3 +20,106 @@ def test_git_constructor(
1920
repo = git.Git(path=path_type(tmp_path))
2021

2122
assert repo.path == tmp_path
23+
24+
25+
def test_version_basic(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
26+
"""Test basic git version output."""
27+
git_cmd = git.Git(path=tmp_path)
28+
29+
monkeypatch.setattr(git_cmd, "run", lambda *args, **kwargs: "git version 2.43.0")
30+
31+
result = git_cmd.version()
32+
assert isinstance(result, Version)
33+
assert result.major == 2
34+
assert result.minor == 43
35+
assert result.micro == 0
36+
assert str(result) == "2.43.0"
37+
38+
39+
def test_build_options(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
40+
"""Test build_options() method."""
41+
git_cmd = git.Git(path=tmp_path)
42+
43+
sample_output = """git version 2.43.0
44+
cpu: x86_64
45+
no commit associated with this build
46+
sizeof-long: 8
47+
sizeof-size_t: 8
48+
shell-path: /bin/sh"""
49+
50+
# Mock run() directly instead of version()
51+
def mock_run(cmd_args: list[str], **kwargs: t.Any) -> str:
52+
assert cmd_args == ["version", "--build-options"]
53+
return sample_output
54+
55+
monkeypatch.setattr(git_cmd, "run", mock_run)
56+
57+
result = git_cmd.build_options()
58+
59+
assert isinstance(result, git.GitVersionInfo)
60+
assert result.version == "2.43.0"
61+
assert result.version_info == (2, 43, 0)
62+
assert result.cpu == "x86_64"
63+
assert result.commit == "no commit associated with this build"
64+
assert result.sizeof_long == "8"
65+
assert result.sizeof_size_t == "8"
66+
assert result.shell_path == "/bin/sh"
67+
68+
69+
def test_build_options_invalid_version(
70+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
71+
) -> None:
72+
"""Test build_options() with invalid version string."""
73+
git_cmd = git.Git(path=tmp_path)
74+
75+
sample_output = """git version development
76+
cpu: x86_64
77+
commit: abcdef123456
78+
sizeof-long: 8
79+
sizeof-size_t: 8
80+
shell-path: /bin/sh"""
81+
82+
def mock_run(cmd_args: list[str], **kwargs: t.Any) -> str:
83+
assert cmd_args == ["version", "--build-options"]
84+
return sample_output
85+
86+
monkeypatch.setattr(git_cmd, "run", mock_run)
87+
88+
result = git_cmd.build_options()
89+
90+
assert isinstance(result, git.GitVersionInfo)
91+
assert result.version == "development"
92+
assert result.version_info is None
93+
assert result.commit == "abcdef123456"
94+
95+
96+
def test_version_invalid_format(
97+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
98+
) -> None:
99+
"""Test version() with invalid output format."""
100+
git_cmd = git.Git(path=tmp_path)
101+
102+
invalid_output = "not a git version format"
103+
104+
monkeypatch.setattr(git_cmd, "run", lambda *args, **kwargs: invalid_output)
105+
106+
with pytest.raises(InvalidVersion) as excinfo:
107+
git_cmd.version()
108+
109+
assert f"Invalid version: '{invalid_output}'" in str(excinfo.value)
110+
111+
112+
def test_build_options_invalid_format(
113+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
114+
) -> None:
115+
"""Test build_options() with invalid output format."""
116+
git_cmd = git.Git(path=tmp_path)
117+
118+
invalid_output = "not a git version format"
119+
120+
monkeypatch.setattr(git_cmd, "run", lambda *args, **kwargs: invalid_output)
121+
122+
with pytest.raises(git.InvalidBuildOptions) as excinfo:
123+
git_cmd.build_options()
124+
125+
assert "Unexpected git version output format" in str(excinfo.value)

0 commit comments

Comments
 (0)