|
2 | 2 |
|
3 | 3 | from __future__ import annotations
|
4 | 4 |
|
| 5 | +import dataclasses |
5 | 6 | import datetime
|
6 | 7 | import pathlib
|
7 | 8 | import shlex
|
|
10 | 11 |
|
11 | 12 | from libvcs._internal.run import ProgressCallbackProtocol, run
|
12 | 13 | from libvcs._internal.types import StrOrBytesPath, StrPath
|
| 14 | +from libvcs._vendor.version import InvalidVersion, Version, parse as parse_version |
13 | 15 |
|
14 | 16 | _CMD = t.Union[StrOrBytesPath, Sequence[StrOrBytesPath]]
|
15 | 17 |
|
16 | 18 |
|
| 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 | + |
17 | 56 | class Git:
|
18 | 57 | """Run commands directly on a git repository."""
|
19 | 58 |
|
@@ -1746,33 +1785,130 @@ def config(
|
1746 | 1785 | def version(
|
1747 | 1786 | self,
|
1748 | 1787 | *,
|
1749 |
| - build_options: bool | None = None, |
1750 | 1788 | # libvcs special behavior
|
1751 | 1789 | check_returncode: bool | None = None,
|
1752 | 1790 | **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 |
1755 | 1803 |
|
1756 | 1804 | Examples
|
1757 | 1805 | --------
|
1758 | 1806 | >>> git = Git(path=example_git_repo.path)
|
1759 | 1807 |
|
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 |
1765 | 1811 | """
|
1766 | 1812 | local_flags: list[str] = []
|
1767 | 1813 |
|
1768 |
| - if build_options is True: |
1769 |
| - local_flags.append("--build-options") |
1770 |
| - |
1771 |
| - return self.run( |
| 1814 | + output = self.run( |
1772 | 1815 | ["version", *local_flags],
|
1773 | 1816 | check_returncode=check_returncode,
|
1774 | 1817 | )
|
1775 | 1818 |
|
| 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 | + |
1776 | 1912 | def rev_parse(
|
1777 | 1913 | self,
|
1778 | 1914 | *,
|
|
0 commit comments