Skip to content

Commit b46fbcf

Browse files
codexByron
authored andcommitted
feat(gix): select the released backend independently of fixture tooling
Add `GitPython[gix]` with the published `GixPython==0.2.0` dependency. The test extra also installs it because both backend suites use `gix.testtools` for fixture generation. Keep the dependency's Python 3.11 marker so GitPython's existing runtime support still resolves on older interpreters; fixture-based tests require Python 3.11 or newer. Use `GIT_PYTHON_BACKEND=cli` or `gix` to select the backend at process startup, retaining automatic selection when unset. Explicit Gix selection requires the package, and invalid values fail rather than silently choosing a backend. The CLI mode leaves `gix.testtools` importable. Set tox's backend explicitly and skip native-only cases according to the selected backend. Start with an empty native dispatch table so library-managed commands retain CLI behavior. Preserve the safety boundary and distinguish unsupported calls from failures after a native write. Add operation reporting and an isolated local test runner. Installation tests verify backend selection and the local tutorial fixture avoids cloning GitHub. The official 0.2.0 Apple Silicon wheel was downloaded from PyPI and verified against its published SHA-256 digest. On CPython 3.12/macOS, selection tests passed in CLI mode (three passed, two native cases skipped) and Gix mode (five passed). Ruff lint and formatting passed. Later native conversions and the fixture migration are validated on the restored stack. Keep `tox` installation/import smoke checks on Python 3.8–3.10; fixture suites run on 3.11+ where GixPython is supported. Backend-selection regressions passed all three tests at this commit; Ruff passed.
1 parent cd896a8 commit b46fbcf

12 files changed

Lines changed: 321 additions & 2 deletions

‎git/_backend.py‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Optional native implementation of library-managed Git operations.
2+
3+
Installing ``GitPython[gix]`` makes the ``gix`` module available. Unsupported
4+
operations return ``NotImplemented`` *before* doing work and use the existing
5+
CLI implementation. Native failures are never retried as CLI mutations.
6+
"""
7+
8+
from collections import Counter
9+
from importlib import import_module
10+
import logging
11+
import os
12+
from threading import Lock
13+
from typing import Any, Callable, Dict, List, Sequence, Tuple
14+
15+
from git.compat import safe_decode
16+
from git.exc import GitCommandError
17+
18+
_selection = os.environ.get("GIT_PYTHON_BACKEND")
19+
if _selection not in (None, "cli", "gix"):
20+
raise ValueError("GIT_PYTHON_BACKEND must be 'cli' or 'gix'")
21+
gix: Any = None
22+
if _selection != "cli":
23+
try:
24+
gix = import_module("gix")
25+
except ModuleNotFoundError as exc:
26+
if exc.name != "gix" or _selection == "gix":
27+
raise
28+
29+
name = "gix" if gix is not None else "cli"
30+
_counts: Counter = Counter()
31+
_lock = Lock()
32+
_logger = logging.getLogger("git.backend")
33+
34+
35+
def record(method: str, outcome: str) -> None:
36+
with _lock:
37+
_counts[method, outcome] += 1
38+
_logger.debug("%s: %s", method, outcome)
39+
40+
41+
def statistics() -> Dict[Tuple[str, str], int]:
42+
"""Return counts by operation and native/fallback reason for this process."""
43+
with _lock:
44+
return dict(_counts)
45+
46+
47+
class _Unsupported(Exception):
48+
"""A capability decision made before a native mutation."""
49+
50+
51+
def _fallback(method: str, reason: str) -> Any:
52+
record(method, "CLI: " + reason)
53+
return NotImplemented
54+
55+
56+
def _repository(command: Any, env: Dict[str, Any], *, query_config: bool = False) -> Any:
57+
if command._git_options or command._persistent_git_options:
58+
raise _Unsupported("global command options")
59+
overrides = {**command.environment(), **env}
60+
effective = {**os.environ, **overrides}
61+
path = overrides.get("GIT_DIR")
62+
if not path:
63+
raise _Unsupported("repository not bound")
64+
if not os.path.isabs(path):
65+
path = os.path.join(command.working_dir or os.getcwd(), path)
66+
for key in ("GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE"):
67+
if effective.get(key):
68+
raise _Unsupported("storage environment")
69+
supported = {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"}
70+
supported.update(
71+
"GIT_%s_%s" % (role, field) for role in ("AUTHOR", "COMMITTER") for field in ("NAME", "EMAIL", "DATE")
72+
)
73+
if any(key not in supported and value != os.environ.get(key) for key, value in overrides.items()):
74+
raise _Unsupported("command environment")
75+
options = gix.OpenOptions().open_path_as_is(True).bail_if_untrusted(True).strict_config(True)
76+
if not query_config:
77+
options = options.config_overrides(
78+
["core.fsmonitor=false", "gc.auto=0", "maintenance.auto=false", "core.hooksPath=" + os.devnull]
79+
)
80+
repo = gix.open_opts(path, options)
81+
if repo.config_snapshot().string("extensions.refStorage") == b"reftable":
82+
raise _Unsupported("reftable (GIX-1)")
83+
if effective.get("GIT_WORK_TREE"):
84+
workdir = effective["GIT_WORK_TREE"]
85+
if not os.path.isabs(workdir):
86+
workdir = os.path.join(command.working_dir or os.getcwd(), workdir)
87+
repo.set_workdir(workdir)
88+
return repo
89+
90+
91+
_HANDLERS: Dict[str, Callable[[Any, List[str], Dict[str, Any]], bytes]] = {}
92+
93+
94+
def dispatch(
95+
command: Any, method: str, args: Tuple[Any, ...], kwargs: Dict[str, Any], config: Sequence[str] = ()
96+
) -> Any:
97+
if gix is None:
98+
return NotImplemented
99+
handler = _HANDLERS.get(method)
100+
if handler is None:
101+
return _fallback(method, "not converted")
102+
allowed = {"env", "stdout_as_string", "strip_newline_in_stdout", "with_extended_output"}
103+
if method == "merge_base":
104+
allowed.add("all")
105+
if method in ("hash_object", "mktree", "commit_tree"):
106+
allowed.add("istream")
107+
if kwargs.keys() - allowed:
108+
return _fallback(method, "command/process options")
109+
if config and method != "commit_tree":
110+
return _fallback(method, "configuration overrides")
111+
if config:
112+
kwargs = dict(kwargs, _config=config)
113+
kwargs = dict(kwargs, env={**command.environment(), **(kwargs.get("env") or {})})
114+
try:
115+
args_list = command._unpack_args([arg for arg in args if arg is not None])
116+
repo = _repository(command, kwargs.get("env", {}), query_config=method == "config")
117+
output = handler(repo, args_list, kwargs)
118+
except _Unsupported as exc:
119+
return _fallback(method, str(exc))
120+
except GitCommandError:
121+
record(method, "native")
122+
raise
123+
except gix.Error as exc:
124+
_logger.debug("%s native read: %s", method, exc)
125+
return _fallback(method, "native read diagnostics")
126+
record(method, "native")
127+
if kwargs.get("strip_newline_in_stdout", True) and output.endswith(b"\n"):
128+
output = output[:-1]
129+
result = safe_decode(output) if kwargs.get("stdout_as_string", True) else output
130+
return (0, result, "") if kwargs.get("with_extended_output") else result

‎git/cmd.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
)
4545

4646
from git.compat import defenc, force_bytes, safe_decode
47+
from git import _backend
4748
from git.exc import (
4849
CommandError,
4950
GitCommandError,
@@ -1193,6 +1194,9 @@ def _call_process_safe(
11931194
options.extend(("-c", setting))
11941195
elif _config:
11951196
raise ValueError("Configuration queries must not include synthetic settings")
1197+
native = _backend.dispatch(self, method, args, kwargs, _config)
1198+
if native is not NotImplemented:
1199+
return native
11961200
env = dict(kwargs.pop("env", {}) or {})
11971201
env.update(LC_ALL="C", LANGUAGE="C")
11981202
if not _allow_network:

‎gix-requirements.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
GixPython==0.2.0 ; python_version >= "3.11"

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ include-package-data = true
5454
version = {file = "VERSION"}
5555
dependencies = {file = "requirements.txt"}
5656
optional-dependencies."test" = {file = "test-requirements.txt"}
57+
optional-dependencies."gix" = {file = "gix-requirements.txt"}
5758

5859
[tool.setuptools.packages.find]
5960
include = ["git", "git.*"]

‎test-requirements.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
coverage[toml]
2+
GixPython==0.2.0 ; python_version >= "3.11"
23
basedpyright==1.39.9 ; python_version >= "3.9" and sys_platform != "cygwin"
34
ddt >= 1.1.1, != 1.4.3
45
mock # Also needed to typecheck the Python 3.7 import branches.

‎test/conftest.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Report native coverage when the optional backend is installed."""
2+
3+
import json
4+
from pathlib import Path
5+
6+
from git import _backend
7+
8+
9+
def pytest_addoption(parser):
10+
parser.addoption("--backend-report", help="Write native/CLI operation counts as JSON")
11+
12+
13+
def pytest_report_header(config):
14+
return "GitPython backend: " + _backend.name
15+
16+
17+
def pytest_terminal_summary(terminalreporter, exitstatus, config):
18+
if _backend.name != "gix":
19+
return
20+
records = [
21+
{"method": method, "outcome": outcome, "count": count}
22+
for (method, outcome), count in sorted(_backend.statistics().items())
23+
]
24+
terminalreporter.section("GixPython operation coverage")
25+
for item in records:
26+
terminalreporter.write_line("{method}: {outcome} ({count})".format(**item))
27+
path = config.getoption("--backend-report")
28+
if path:
29+
Path(path).parent.mkdir(parents=True, exist_ok=True)
30+
with open(path, "w", encoding="utf-8") as stream:
31+
json.dump(records, stream, indent=2)
32+
stream.write("\n")

‎test/run-local.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Run pytest with local packages, an isolated config, and a disposable fixture.
2+
3+
Use the Python interpreter from the installation to test, for example:
4+
.tox/gix/bin/python test/run-local.py --backend-report=.cache/gix-coverage.json
5+
"""
6+
7+
import os
8+
from pathlib import Path
9+
import socket
10+
import subprocess
11+
import sys
12+
import tempfile
13+
14+
15+
def main():
16+
root = Path(__file__).resolve().parent.parent
17+
with tempfile.TemporaryDirectory(prefix="gitpython-local-tests-") as directory:
18+
temporary = Path(directory)
19+
config = temporary / "gitconfig"
20+
config.write_text("[user]\nname = GitPython Tests\nemail = tests@example.invalid\n", encoding="utf-8")
21+
env = {
22+
**os.environ,
23+
"GIT_CONFIG_NOSYSTEM": "1",
24+
"GIT_CONFIG_GLOBAL": str(config),
25+
"PIP_NO_INDEX": "1",
26+
"PIP_DISABLE_PIP_VERSION_CHECK": "1",
27+
"PIP_FIND_LINKS": os.environ.get("PIP_FIND_LINKS", str(root / ".cache/gix-wheels")),
28+
"UV_OFFLINE": "1",
29+
}
30+
31+
def git(*args, cwd=root):
32+
return subprocess.check_output(["git", *map(str, args)], cwd=cwd, env=env, text=True).strip()
33+
34+
git("config", "--file", config, "include.path", root / "test/fixtures/.gitconfig")
35+
fixture = temporary / "repo"
36+
git("clone", "--shared", "--no-checkout", root, fixture)
37+
git("checkout", "--detach", git("rev-parse", "HEAD"), cwd=fixture)
38+
if not git("tag", "--list", "[0-9]*", "v[0-9]*", cwd=fixture):
39+
raise RuntimeError("The local checkout needs version tags for the tests; no remote will be fetched")
40+
# This prepares only the disposable clone and cannot fetch with tags present.
41+
if git("tag", "--list", "__testing_point__", cwd=fixture):
42+
git("tag", "--delete", "__testing_point__", cwd=fixture)
43+
subprocess.run(
44+
["sh", str(root / "init-tests-after-clone.sh")],
45+
cwd=fixture,
46+
env={**env, "GITHUB_ACTIONS": "true", "GIT_ALLOW_PROTOCOL": "file"},
47+
check=True,
48+
)
49+
env["GIT_PYTHON_TEST_GIT_REPO_BASE"] = str(fixture)
50+
# Separate CLI and gix runs can use their own localhost daemon.
51+
with socket.socket() as listener:
52+
listener.bind(("127.0.0.1", 0))
53+
env["GIT_PYTHON_TEST_GIT_DAEMON_PORT"] = str(listener.getsockname()[1])
54+
return subprocess.call([sys.executable, "-m", "pytest", *sys.argv[1:]], cwd=root, env=env)
55+
56+
57+
if __name__ == "__main__":
58+
sys.exit(main())

‎test/test_backend_selection.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Fixture tooling is available independently of the selected backend."""
2+
3+
import os
4+
import subprocess
5+
import sys
6+
7+
import pytest
8+
9+
10+
@pytest.mark.parametrize("backend", ["cli", "gix"])
11+
def test_backend_selection_leaves_fixture_tools_available(backend):
12+
result = subprocess.check_output(
13+
[sys.executable, "-c", "from git import _backend; from gix import testtools; print(_backend.name)"],
14+
env={**os.environ, "GIT_PYTHON_BACKEND": backend},
15+
text=True,
16+
)
17+
assert result.strip() == backend
18+
19+
20+
def test_unknown_backend_is_rejected():
21+
result = subprocess.run(
22+
[sys.executable, "-c", "import git"],
23+
env={**os.environ, "GIT_PYTHON_BACKEND": "unknown"},
24+
capture_output=True,
25+
text=True,
26+
)
27+
assert result.returncode != 0
28+
assert "GIT_PYTHON_BACKEND must be 'cli' or 'gix'" in result.stderr

‎test/test_gix_backend.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Exercise the installed backend, including proof that converted calls avoid Git."""
2+
3+
from pathlib import Path
4+
from unittest.mock import patch
5+
6+
import pytest
7+
8+
from git import Git, Repo, _backend
9+
10+
gix = pytest.importorskip("gix")
11+
pytestmark = pytest.mark.skipif(_backend.name != "gix", reason="requires the Gix backend")
12+
13+
14+
@pytest.fixture(params=["sha1", "sha256"])
15+
def repo(request, tmp_path):
16+
with Repo.init(tmp_path / "repo", object_format=request.param, initial_branch="main") as repo:
17+
yield repo
18+
19+
20+
def test_unknown_command_and_storage_environment_use_cli(repo):
21+
before = _backend.statistics()
22+
assert repo.git._call_process_safe("status", "--porcelain") == ""
23+
assert _backend.statistics()["status", "CLI: not converted"] > before.get(("status", "CLI: not converted"), 0)
24+
with repo.git.custom_environment(GIT_OBJECT_DIRECTORY=str(Path(repo.common_dir, "objects"))):
25+
with patch.object(Git, "execute", return_value="fallback") as cli:
26+
assert repo.git._call_process_safe("rev_parse", "--verify", "--end-of-options", "HEAD") == "fallback"
27+
cli.assert_called_once()
28+
with patch.object(Git, "execute", return_value="fallback") as cli:
29+
assert repo.git._call_process_safe("for_each_ref", "--format=%(refname)", "--", "refs/*") == "fallback"
30+
with repo.git.custom_environment(GIT_INDEX_FILE=".git/index"):
31+
assert repo.git._call_process_safe("ls_files", "--stage", "-v", "-z", "--full-name") == "fallback"
32+
assert cli.call_count == 2

‎test/test_installation.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
import subprocess
88

9+
from git import _backend
910
from test.lib import TestBase, VirtualEnvironment, requires_symlinks, with_rw_directory
1011

1112

@@ -15,13 +16,18 @@ class TestInstallation(TestBase):
1516
def test_installation(self, rw_dir):
1617
venv, run = self._set_up_venv(rw_dir)
1718

18-
for project in ("./smmap", "./gitdb", "."):
19+
gitpython = ".[gix]" if _backend.name == "gix" else "."
20+
for project in ("./smmap", "./gitdb", gitpython):
1921
result = run([venv.pip, "install", project])
2022
self._check_result(result, f"Can't install {project}")
2123

2224
result = run([venv.python, "-c", "import git"])
2325
self._check_result(result, "Self-test failed")
2426

27+
result = run([venv.python, "-c", "from git import _backend; print(_backend.name)"])
28+
self._check_result(result, "Backend selection failed")
29+
self.assertEqual(result.stdout.strip(), _backend.name)
30+
2531
result = run([venv.python, "-c", "import gitdb; import smmap"])
2632
self._check_result(result, "Dependencies not installed")
2733

0 commit comments

Comments
 (0)