Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
cc51d12
feat!: use guarded Git commands for repository storage
codex Oct 1, 2026
427dc41
ci: build supported Git for Cygwin tests
codex Oct 1, 2026
c935506
test: handle symlinked Git directories in refresh checks
codex Oct 1, 2026
2df22e8
fix: preserve repository and object paths across platforms
codex Oct 1, 2026
ea767d5
test: cover Cygwin index filename restrictions
codex Oct 1, 2026
f954793
test: preserve hidden gitfiles when preparing submodule aliases
codex Oct 1, 2026
7453896
test: clean up read-only submodule object files on Windows
codex Oct 1, 2026
7ff0a87
fix(submodule): close repositories owned by update operations
codex Oct 1, 2026
889f213
ci: install supported Git in the normal Cygwin layout
codex Oct 1, 2026
bf27f8b
test(downstream): run released Bandit baseline tests against this che…
codex Oct 1, 2026
24c3ca4
test(downstream): validate the latest MLflow Git integration
codex Oct 1, 2026
25cefa9
test(downstream): exercise released LangChain GitLoader integration
codex Oct 1, 2026
e0171d8
test(downstream): cover SWE-bench cloning across Git storage formats
codex Oct 1, 2026
bdd6be4
test(downstream): run DataHub Git integration for its latest release
codex Oct 1, 2026
6a9cb4f
feat(gix): select the released backend through an installation extra
codex Oct 1, 2026
22f2ca9
feat(gix): read object headers with native lookup
codex Oct 1, 2026
3b8015f
feat(gix): stream small objects from native storage
codex Oct 1, 2026
b60c3e7
feat(gix): resolve revisions and repository metadata natively
codex Oct 1, 2026
c43175a
feat(gix): enumerate tree entries natively
codex Oct 1, 2026
1af0f6f
feat(gix): resolve symbolic reference targets natively
codex Oct 1, 2026
36c9af1
feat(gix): enumerate reference names natively
codex Oct 1, 2026
2afb498
feat(gix): query merged repository configuration natively
codex Oct 1, 2026
5b60175
feat(gix): list worktree metadata natively
codex Oct 1, 2026
cb0c391
feat(gix): find merge bases and test ancestry natively
codex Oct 1, 2026
908183e
feat(gix): count reachable commits natively
codex Oct 1, 2026
2f44fc4
feat(gix): iterate first-parent commit history natively
codex Oct 1, 2026
d375802
feat(gix): read index entries natively
codex Oct 1, 2026
3aa19b5
feat(gix): query the index version natively
codex Oct 1, 2026
cfb7543
feat(gix): hash and store objects natively
codex Oct 1, 2026
e23e20a
feat(gix): write index trees with the native editor
codex Oct 1, 2026
9fbdb4f
feat(gix): serialize trees with native object storage
codex Oct 1, 2026
fbd48eb
feat(gix): initialize private indexes from trees natively
codex Oct 1, 2026
2444ec7
feat(gix): persist index edits through native snapshots
codex Oct 1, 2026
fb17ed1
feat(gix): create unsigned UTF-8 commits natively
codex Oct 1, 2026
bab4aaa
feat(gix): read reference logs natively
codex Oct 1, 2026
57a4c79
feat(gix): resolve committer identity natively
codex Oct 1, 2026
6c69dec
feat(gix): update supported reference targets natively
codex Oct 1, 2026
e53abf3
feat(gix): compute raw tree diffs natively
codex Oct 1, 2026
2ea09f8
feat(gix): compute commit statistics natively
codex Oct 1, 2026
8a4ba4f
feat(gix): discover untracked files natively
codex Oct 1, 2026
9d62798
feat(gix): check ignored paths natively
codex Oct 1, 2026
7357193
feat(gix): check repository dirtiness natively
codex Oct 1, 2026
d67b46c
build(gix): preserve the extra in local source distributions
codex Oct 1, 2026
dc3d65f
fix(gix): omit dangling symbolic references from enumeration
codex Oct 1, 2026
9b1dd6f
fix(gix): defer compatibility object formats to Git
codex Oct 1, 2026
6d7490d
docs(gix): document release setup and native conversion gaps
codex Oct 1, 2026
64e7256
test: avoid repository setup for actor and exception tests
codex Oct 6, 2026
968b883
test: prepare historical dependency sources once on demand
codex Oct 6, 2026
d72ffb3
test: copy prepared submodule baselines for each case
codex Oct 6, 2026
a467e99
test: reuse prepared submodule rejection layouts
codex Oct 6, 2026
75ba2c3
test: reuse a prepared revision graph with isolated copies
codex Oct 6, 2026
f0ee0d3
test: read historical trees without cloning a checkout
codex Oct 6, 2026
14e0c11
test: copy prepared no-fetch source and submodule repositories
codex Oct 6, 2026
2da7cd1
docs: record fixture optimization validation and suite timings
codex Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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.
  • Loading branch information
codex authored and Byron committed Oct 6, 2026
commit 6a9cb4fd00153e98a1e6a93811accd82d03c0d47
126 changes: 126 additions & 0 deletions git/_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Optional native implementation of library-managed Git operations.

Installing ``GitPython[gix]`` makes the ``gix`` module available. Unsupported
operations return ``NotImplemented`` *before* doing work and use the existing
CLI implementation. Native failures are never retried as CLI mutations.
"""

from collections import Counter
from importlib import import_module
import logging
import os
from threading import Lock
from typing import Any, Callable, Dict, List, Sequence, Tuple

from git.compat import safe_decode
from git.exc import GitCommandError

try:
gix: Any = import_module("gix")
except ModuleNotFoundError as exc:
if exc.name != "gix":
raise
gix = None

name = "gix" if gix is not None else "cli"
_counts: Counter = Counter()
_lock = Lock()
_logger = logging.getLogger("git.backend")


def record(method: str, outcome: str) -> None:
with _lock:
_counts[method, outcome] += 1
_logger.debug("%s: %s", method, outcome)


def statistics() -> Dict[Tuple[str, str], int]:
"""Return counts by operation and native/fallback reason for this process."""
with _lock:
return dict(_counts)


class _Unsupported(Exception):
"""A capability decision made before a native mutation."""


def _fallback(method: str, reason: str) -> Any:
record(method, "CLI: " + reason)
return NotImplemented


def _repository(command: Any, env: Dict[str, Any], *, query_config: bool = False) -> Any:
if command._git_options or command._persistent_git_options:
raise _Unsupported("global command options")
overrides = {**command.environment(), **env}
effective = {**os.environ, **overrides}
path = overrides.get("GIT_DIR")
if not path:
raise _Unsupported("repository not bound")
if not os.path.isabs(path):
path = os.path.join(command.working_dir or os.getcwd(), path)
for key in ("GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE"):
if effective.get(key):
raise _Unsupported("storage environment")
supported = {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"}
supported.update(
"GIT_%s_%s" % (role, field) for role in ("AUTHOR", "COMMITTER") for field in ("NAME", "EMAIL", "DATE")
)
if any(key not in supported and value != os.environ.get(key) for key, value in overrides.items()):
raise _Unsupported("command environment")
options = gix.OpenOptions().open_path_as_is(True).bail_if_untrusted(True).strict_config(True)
if not query_config:
options = options.config_overrides(
["core.fsmonitor=false", "gc.auto=0", "maintenance.auto=false", "core.hooksPath=" + os.devnull]
)
repo = gix.open_opts(path, options)
if repo.config_snapshot().string("extensions.refStorage") == b"reftable":
raise _Unsupported("reftable (GIX-1)")
if effective.get("GIT_WORK_TREE"):
workdir = effective["GIT_WORK_TREE"]
if not os.path.isabs(workdir):
workdir = os.path.join(command.working_dir or os.getcwd(), workdir)
repo.set_workdir(workdir)
return repo


_HANDLERS: Dict[str, Callable[[Any, List[str], Dict[str, Any]], bytes]] = {}


def dispatch(
command: Any, method: str, args: Tuple[Any, ...], kwargs: Dict[str, Any], config: Sequence[str] = ()
) -> Any:
if gix is None:
return NotImplemented
handler = _HANDLERS.get(method)
if handler is None:
return _fallback(method, "not converted")
allowed = {"env", "stdout_as_string", "strip_newline_in_stdout", "with_extended_output"}
if method == "merge_base":
allowed.add("all")
if method in ("hash_object", "mktree", "commit_tree"):
allowed.add("istream")
if kwargs.keys() - allowed:
return _fallback(method, "command/process options")
if config and method != "commit_tree":
return _fallback(method, "configuration overrides")
if config:
kwargs = dict(kwargs, _config=config)
kwargs = dict(kwargs, env={**command.environment(), **(kwargs.get("env") or {})})
try:
args_list = command._unpack_args([arg for arg in args if arg is not None])
repo = _repository(command, kwargs.get("env", {}), query_config=method == "config")
output = handler(repo, args_list, kwargs)
except _Unsupported as exc:
return _fallback(method, str(exc))
except GitCommandError:
record(method, "native")
raise
except gix.Error as exc:
_logger.debug("%s native read: %s", method, exc)
return _fallback(method, "native read diagnostics")
record(method, "native")
if kwargs.get("strip_newline_in_stdout", True) and output.endswith(b"\n"):
output = output[:-1]
result = safe_decode(output) if kwargs.get("stdout_as_string", True) else output
return (0, result, "") if kwargs.get("with_extended_output") else result
4 changes: 4 additions & 0 deletions git/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import warnings

from git.compat import defenc, force_bytes, safe_decode
from git import _backend
from git.exc import (
CommandError,
GitCommandError,
Expand Down Expand Up @@ -1143,6 +1144,9 @@ def _call_process_safe(
options.extend(("-c", setting))
elif _config:
raise ValueError("Configuration queries must not include synthetic settings")
native = _backend.dispatch(self, method, args, kwargs, _config)
if native is not NotImplemented:
return native
env = dict(kwargs.pop("env", {}) or {})
env.update(LC_ALL="C", LANGUAGE="C")
if not _allow_network:
Expand Down
1 change: 1 addition & 0 deletions gix-requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
GixPython==0.1.0 ; python_version >= "3.11"
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ include-package-data = true
version = {file = "VERSION"}
dependencies = {file = "requirements.txt"}
optional-dependencies."test" = {file = "test-requirements.txt"}
optional-dependencies."gix" = {file = "gix-requirements.txt"}

[tool.setuptools.packages.find]
include = ["git", "git.*"]
Expand Down
32 changes: 32 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Report native coverage when the optional backend is installed."""

import json
from pathlib import Path

from git import _backend


def pytest_addoption(parser):
parser.addoption("--backend-report", help="Write native/CLI operation counts as JSON")


def pytest_report_header(config):
return "GitPython backend: " + _backend.name


def pytest_terminal_summary(terminalreporter, exitstatus, config):
if _backend.name != "gix":
return
records = [
{"method": method, "outcome": outcome, "count": count}
for (method, outcome), count in sorted(_backend.statistics().items())
]
terminalreporter.section("GixPython operation coverage")
for item in records:
terminalreporter.write_line("{method}: {outcome} ({count})".format(**item))
path = config.getoption("--backend-report")
if path:
Path(path).parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as stream:
json.dump(records, stream, indent=2)
stream.write("\n")
58 changes: 58 additions & 0 deletions test/run-local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Run pytest with local packages, an isolated config, and a disposable fixture.

Use the Python interpreter from the installation to test, for example:
.tox/gix/bin/python test/run-local.py --backend-report=.cache/gix-coverage.json
"""

import os
from pathlib import Path
import socket
import subprocess
import sys
import tempfile


def main():
root = Path(__file__).resolve().parent.parent
with tempfile.TemporaryDirectory(prefix="gitpython-local-tests-") as directory:
temporary = Path(directory)
config = temporary / "gitconfig"
config.write_text("[user]\nname = GitPython Tests\nemail = tests@example.invalid\n", encoding="utf-8")
env = {
**os.environ,
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_CONFIG_GLOBAL": str(config),
"PIP_NO_INDEX": "1",
"PIP_DISABLE_PIP_VERSION_CHECK": "1",
"PIP_FIND_LINKS": os.environ.get("PIP_FIND_LINKS", str(root / ".cache/gix-wheels")),
"UV_OFFLINE": "1",
}

def git(*args, cwd=root):
return subprocess.check_output(["git", *map(str, args)], cwd=cwd, env=env, text=True).strip()

git("config", "--file", config, "include.path", root / "test/fixtures/.gitconfig")
fixture = temporary / "repo"
git("clone", "--shared", "--no-checkout", root, fixture)
git("checkout", "--detach", git("rev-parse", "HEAD"), cwd=fixture)
if not git("tag", "--list", "[0-9]*", "v[0-9]*", cwd=fixture):
raise RuntimeError("The local checkout needs version tags for the tests; no remote will be fetched")
# This prepares only the disposable clone and cannot fetch with tags present.
if git("tag", "--list", "__testing_point__", cwd=fixture):
git("tag", "--delete", "__testing_point__", cwd=fixture)
subprocess.run(
["sh", str(root / "init-tests-after-clone.sh")],
cwd=fixture,
env={**env, "GITHUB_ACTIONS": "true", "GIT_ALLOW_PROTOCOL": "file"},
check=True,
)
env["GIT_PYTHON_TEST_GIT_REPO_BASE"] = str(fixture)
# Separate CLI and gix runs can use their own localhost daemon.
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
env["GIT_PYTHON_TEST_GIT_DAEMON_PORT"] = str(listener.getsockname()[1])
return subprocess.call([sys.executable, "-m", "pytest", *sys.argv[1:]], cwd=root, env=env)


if __name__ == "__main__":
sys.exit(main())
31 changes: 31 additions & 0 deletions test/test_gix_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Exercise the installed backend, including proof that converted calls avoid Git."""

from pathlib import Path
from unittest.mock import patch

import pytest

from git import Git, Repo, _backend

gix = pytest.importorskip("gix")


@pytest.fixture(params=["sha1", "sha256"])
def repo(request, tmp_path):
with Repo.init(tmp_path / "repo", object_format=request.param, initial_branch="main") as repo:
yield repo


def test_unknown_command_and_storage_environment_use_cli(repo):
before = _backend.statistics()
assert repo.git._call_process_safe("status", "--porcelain") == ""
assert _backend.statistics()["status", "CLI: not converted"] > before.get(("status", "CLI: not converted"), 0)
with repo.git.custom_environment(GIT_OBJECT_DIRECTORY=str(Path(repo.common_dir, "objects"))):
with patch.object(Git, "execute", return_value="fallback") as cli:
assert repo.git._call_process_safe("rev_parse", "--verify", "--end-of-options", "HEAD") == "fallback"
cli.assert_called_once()
with patch.object(Git, "execute", return_value="fallback") as cli:
assert repo.git._call_process_safe("for_each_ref", "--format=%(refname)", "--", "refs/*") == "fallback"
with repo.git.custom_environment(GIT_INDEX_FILE=".git/index"):
assert repo.git._call_process_safe("ls_files", "--stage", "-v", "-z", "--full-name") == "fallback"
assert cli.call_count == 2
8 changes: 7 additions & 1 deletion test/test_installation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os
import subprocess

from git import _backend
from test.lib import TestBase, VirtualEnvironment, requires_symlinks, with_rw_directory


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

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

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

result = run([venv.python, "-c", "from git import _backend; print(_backend.name)"])
self._check_result(result, "Backend selection failed")
self.assertEqual(result.stdout.strip(), _backend.name)

result = run([venv.python, "-c", "import gitdb; import smmap"])
self._check_result(result, "Dependencies not installed")

Expand Down
14 changes: 13 additions & 1 deletion test/test_quick_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/

import gc
from pathlib import Path
import tempfile

from test.lib import TestBase
from test.lib.helper import with_rw_directory
Expand Down Expand Up @@ -31,11 +33,21 @@ def test_init_repo_object(self, path_to_dir):
def test_cloned_repo_object(self, local_dir):
from git import Repo

source = tempfile.TemporaryDirectory(prefix="gitpython-quickstart-")
self.addCleanup(source.cleanup)
with Repo.init(source.name) as fixture:
for path in ("dir1/file1.txt", "dir1/file2.txt", "Downloads/file3.txt", "file4.txt"):
file = Path(source.name, path)
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text("File version 1\n")
fixture.index.add(["dir1", "Downloads", "file4.txt"])
fixture.index.commit("Initial tutorial files", skip_hooks=True)

# Code to clone from url
# [1-test_cloned_repo_object]
# $ git clone <url> <local_dir>

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

repo = Repo.clone_from(repo_url, local_dir)
# ![1-test_cloned_repo_object]
Expand Down
7 changes: 7 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ set_env =
CLICOLOR_FORCE = 1 # Set NO_COLOR to override this.
commands = ruff check .

[testenv:gix]
description = Run the suite with released GixPython and CLI fallback
base_python = python3
package_env = .pkg-gix
extras = test, gix
commands = python test/run-local.py {posargs:--backend-report=.cache/gix-coverage.json}

[testenv:format]
description = Check formatting with Ruff
base_python = py{39,310,311,312,38}
Expand Down