Skip to content

Commit 248639f

Browse files
codexByron
authored andcommitted
feat(gix): select the released backend through an installation extra
Add `GitPython[gix]` with the published `GixPython==0.1.0` dependency and select it when the `gix` module is installed. Start with an empty native dispatch table so library-managed commands retain their CLI behavior. Keep the existing safety boundary and distinguish unsupported calls from errors after a native write. Add per-operation reporting and a test runner that creates its historical fixture in a disposable local clone. The installation test verifies backend selection, and the tutorial fixture no longer clones GitHub. Let the optional tox environment resolve its dependencies from the package index rather than requiring an unpublished local wheel. Tests themselves retain offline package installation using cached wheels. Pin the official initial release so the optional backend has a reproducible API baseline. Restrict that dependency to Python 3.11 or newer, matching its published interpreter requirement while allowing universal resolution of GitPython extras on the existing Python 3.8+ support range. The published Apple Silicon wheel was downloaded from PyPI and verified against its SHA-256 digest; it replaces the previous local artifact with the same version number. Validation on CPython 3.12/macOS: the SHA-1/SHA-256 backend smoke checks and fresh extra-installation check passed (3 tests). Ruff lint and formatting pass. Universal `uv sync --all-extras --all-groups --dry-run` initially rejected the unmarked dependency for Python 3.8–3.10; the Python-version marker fixes that resolution failure when dynamic package metadata is refreshed.
1 parent 48329d7 commit 248639f

10 files changed

Lines changed: 280 additions & 2 deletions

‎git/_backend.py‎

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

2424
from git.compat import defenc, force_bytes, safe_decode
25+
from git import _backend
2526
from git.exc import (
2627
CommandError,
2728
GitCommandError,
@@ -1143,6 +1144,9 @@ def _call_process_safe(
11431144
options.extend(("-c", setting))
11441145
elif _config:
11451146
raise ValueError("Configuration queries must not include synthetic settings")
1147+
native = _backend.dispatch(self, method, args, kwargs, _config)
1148+
if native is not NotImplemented:
1149+
return native
11461150
env = dict(kwargs.pop("env", {}) or {})
11471151
env.update(LC_ALL="C", LANGUAGE="C")
11481152
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.1.0 ; python_version >= "3.11"

‎pyproject.toml‎

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

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

‎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_gix_backend.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
12+
13+
@pytest.fixture(params=["sha1", "sha256"])
14+
def repo(request, tmp_path):
15+
with Repo.init(tmp_path / "repo", object_format=request.param, initial_branch="main") as repo:
16+
yield repo
17+
18+
19+
def test_unknown_command_and_storage_environment_use_cli(repo):
20+
before = _backend.statistics()
21+
assert repo.git._call_process_safe("status", "--porcelain") == ""
22+
assert _backend.statistics()["status", "CLI: not converted"] > before.get(("status", "CLI: not converted"), 0)
23+
with repo.git.custom_environment(GIT_OBJECT_DIRECTORY=str(Path(repo.common_dir, "objects"))):
24+
with patch.object(Git, "execute", return_value="fallback") as cli:
25+
assert repo.git._call_process_safe("rev_parse", "--verify", "--end-of-options", "HEAD") == "fallback"
26+
cli.assert_called_once()
27+
with patch.object(Git, "execute", return_value="fallback") as cli:
28+
assert repo.git._call_process_safe("for_each_ref", "--format=%(refname)", "--", "refs/*") == "fallback"
29+
with repo.git.custom_environment(GIT_INDEX_FILE=".git/index"):
30+
assert repo.git._call_process_safe("ls_files", "--stage", "-v", "-z", "--full-name") == "fallback"
31+
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

‎test/test_quick_doc.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
33

44
import gc
5+
from pathlib import Path
6+
import tempfile
57

68
from test.lib import TestBase
79
from test.lib.helper import with_rw_directory
@@ -31,11 +33,21 @@ def test_init_repo_object(self, path_to_dir):
3133
def test_cloned_repo_object(self, local_dir):
3234
from git import Repo
3335

36+
source = tempfile.TemporaryDirectory(prefix="gitpython-quickstart-")
37+
self.addCleanup(source.cleanup)
38+
with Repo.init(source.name) as fixture:
39+
for path in ("dir1/file1.txt", "dir1/file2.txt", "Downloads/file3.txt", "file4.txt"):
40+
file = Path(source.name, path)
41+
file.parent.mkdir(parents=True, exist_ok=True)
42+
file.write_text("File version 1\n")
43+
fixture.index.add(["dir1", "Downloads", "file4.txt"])
44+
fixture.index.commit("Initial tutorial files", skip_hooks=True)
45+
3446
# Code to clone from url
3547
# [1-test_cloned_repo_object]
3648
# $ git clone <url> <local_dir>
3749

38-
repo_url = "https://github.com/gitpython-developers/QuickStartTutorialFiles.git"
50+
repo_url = source.name # A local repository path is also a valid Git URL.
3951

4052
repo = Repo.clone_from(repo_url, local_dir)
4153
# ![1-test_cloned_repo_object]

‎tox.ini‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ set_env =
1717
CLICOLOR_FORCE = 1 # Set NO_COLOR to override this.
1818
commands = ruff check .
1919

20+
[testenv:gix]
21+
description = Run the suite with released GixPython and CLI fallback
22+
base_python = python3
23+
package_env = .pkg-gix
24+
extras = test, gix
25+
commands = python test/run-local.py {posargs:--backend-report=.cache/gix-coverage.json}
26+
2027
[testenv:format]
2128
description = Check formatting with Ruff
2229
base_python = py{39,310,311,312,38}

0 commit comments

Comments
 (0)