Skip to content

Commit 02e2cc4

Browse files
codexByron
authored andcommitted
perf(gix): retain native repository handles on Repo
The native adapter previously called `gix.open_opts()` for every operation, repeating configuration parsing and losing shared index/object-store state. Each `Repo` now owns a native handle, associated with `Git` through a weak reference. `close()` releases it and pickling excludes native resources. Reuse preserves storage/environment guards. Metadata and environment changes, and successful raw CLI launches, invalidate the configuration view and trigger `reload()` on the retained handle. Config queries keep a separate fresh view without synthetic safety settings. Includes reload conservatively because GixPython does not expose their source paths. A per-repository lock prevents concurrent refresh races; Gix handles read sharing and index/ODB refresh. Validated Gix first: 260 affected tests and 14 subtests passed, with 3 skips; 38 native regressions passed again after the metadata guard was completed. CLI compatibility: 222 affected tests and 14 subtests passed, with 3 skips. Ruff lint/format, mypy and basedpyright passed.
1 parent edf4501 commit 02e2cc4

5 files changed

Lines changed: 143 additions & 23 deletions

File tree

‎doc/gix-backend.md‎

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,8 @@ an import error; only an absent top-level `gix` selects CLI mode.
167167
operations and their complete journey on one already-open `git.Repo`, plus
168168
separate direct opening and discovery from `git/objects`. Fresh high-level
169169
wrappers preserve the cost of actual operations; imports, fixture preparation
170-
and parity preflight are outside timing. Native repository reopening remains
171-
inside operation timing, so retaining native state in the future can improve
172-
these measurements without changing the workload.
170+
and parity preflight are outside timing. Native repository refresh remains inside operation timing, so these
171+
measurements include the cost of keeping retained state current.
173172

174173
At `244e418da6cc43de129cbe2908d11be4c5ad457a`, using official GixPython
175174
0.1.0 and the same existing CPython 3.12.14/macOS arm64 interpreter for both
@@ -208,8 +207,8 @@ ratios are observational on shared runners.
208207

209208
### Test-suite setup measurements
210209

211-
Native repository reuse remains future work. The fixture optimizations below
212-
are implemented as separate commits, each validated with GixPython before CLI.
210+
The fixture optimizations below are implemented as separate commits, each
211+
validated with GixPython before CLI.
213212
The original local measurements use official GixPython 0.1.0 and existing
214213
CPython 3.12.14 on macOS arm64 at `89c609cf92335e75652d7725682e215a9ea080e5`.
215214

@@ -219,22 +218,26 @@ expected failure. Operation reporting counted 114,800 native operations and
219218
39,002 CLI fallback decisions. Those decisions are not a complete subprocess
220219
count: raw `repo.git` calls and subprocesses started by Git are not all counted.
221220

222-
### Retain native repository state
223-
224-
`git._backend._repository()` calls `gix.open_opts()` for every operation;
225-
`git.Repo` does not retain a native repository. This deliberately avoids stale
226-
configuration and storage views after CLI mutations, but also loses native
227-
state reuse. In a 1,000-call benchmark without coverage, an object read using
228-
one native handle took 0.046 ms per call, compared with 0.635 ms through the
229-
current stream adapter. Opening and validating a native repository alone took
230-
0.418 ms per call. These are small repeated-read measurements, not an estimate
231-
of whole-suite speedup.
232-
233-
Consider retaining native repository/object-store state per `git.Repo`, with
234-
explicit ownership, `close()` behavior, thread semantics, and invalidation for
235-
CLI/native mutations, configuration changes, and environment overrides.
236-
Preserve the current safety checks and diagnostics. Establish those contracts
237-
before replacing the current fresh-open policy.
221+
### Retain native repository state (implemented)
222+
223+
Each `git.Repo` owns a native `gix.Repository`. Managed operations reuse it;
224+
`close()` releases it, and a later operation can reopen it. Pickling excludes
225+
native resources and restores the command's weak owner reference. Gix provides
226+
thread-safe handle access and automatic index/ODB snapshot refresh. A per-Repo
227+
lock serializes handle refresh, without serializing read operations.
228+
229+
Configuration and storage metadata changes, environment changes and raw CLI
230+
launches cause `reload()` before reuse. Configuration queries use a separate
231+
fresh handle so synthetic safety settings never appear as user settings.
232+
Configurations with includes conservatively reload on each operation because
233+
the binding does not expose all included source paths. Storage overrides,
234+
reftable and compatibility object formats retain their existing CLI guards.
235+
236+
An earlier 1,000-call benchmark without coverage measured an object read using
237+
one native handle at 0.046 ms per call, compared with 0.635 ms through the
238+
fresh-open stream adapter. Opening and validating a native repository alone
239+
took 0.418 ms per call. These small repeated-read measurements do not estimate
240+
whole-suite speedup.
238241

239242
### Reuse prepared test fixtures (implemented)
240243

‎git/_backend.py‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ def _fallback(method: str, reason: str) -> Any:
5555

5656

5757
def _repository(command: Any, env: Dict[str, Any], *, query_config: bool = False) -> Any:
58+
owner = command._repo() if command._repo is not None else None
59+
if owner is not None:
60+
with owner._gix_lock:
61+
return _open_repository(command, env, query_config=query_config)
62+
return _open_repository(command, env, query_config=query_config)
63+
64+
65+
def _open_repository(command: Any, env: Dict[str, Any], *, query_config: bool = False) -> Any:
5866
if command._git_options or command._persistent_git_options:
5967
raise _Unsupported("global command options")
6068
overrides = {**command.environment(), **env}
@@ -78,17 +86,56 @@ def _repository(command: Any, env: Dict[str, Any], *, query_config: bool = False
7886
options = options.config_overrides(
7987
["core.fsmonitor=false", "gc.auto=0", "maintenance.auto=false", "core.hooksPath=" + os.devnull]
8088
)
81-
repo = gix.open_opts(path, options)
89+
owner = command._repo() if command._repo is not None else None
90+
state = None
91+
repo = None
92+
if owner is not None and not query_config:
93+
# Gix refreshes index/ODB snapshots itself; configuration is loaded at open.
94+
paths = [
95+
path,
96+
os.fspath(owner.common_dir),
97+
os.path.join(owner.common_dir, "config"),
98+
os.path.join(path, "config.worktree"),
99+
]
100+
paths += [
101+
effective.get("GIT_CONFIG_SYSTEM", "/etc/gitconfig"),
102+
effective.get("GIT_CONFIG_GLOBAL", os.path.expanduser("~/.gitconfig")),
103+
os.path.join(effective.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "git", "config"),
104+
os.path.join(owner.common_dir, "objects", "info", "alternates"),
105+
os.path.join(path, "commondir"),
106+
os.path.join(path, "gitdir"),
107+
]
108+
stamps: List[Any] = []
109+
for filename in paths:
110+
try:
111+
info = os.stat(filename)
112+
stamps.append((info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns))
113+
except FileNotFoundError:
114+
stamps.append(None)
115+
state = (path, tuple(sorted(effective.items())), tuple(stamps))
116+
repo = owner._gix_repository
117+
if repo is not None and os.path.abspath(repo.git_dir()) != os.path.abspath(path):
118+
repo = None
119+
if repo is not None and state != owner._gix_state:
120+
repo.reload()
121+
if repo is None:
122+
repo = gix.open_opts(path, options)
82123
snapshot = repo.config_snapshot()
83124
if snapshot.string("extensions.refStorage") == b"reftable":
84125
raise _Unsupported("reftable (GIX-1)")
85126
if snapshot.string("extensions.compatObjectFormat") is not None:
86127
raise _Unsupported("compatibility object format (GIX-19)")
87-
if effective.get("GIT_WORK_TREE"):
128+
if effective.get("GIT_WORK_TREE") and (owner is None or query_config or state != owner._gix_state):
88129
workdir = effective["GIT_WORK_TREE"]
89130
if not os.path.isabs(workdir):
90131
workdir = os.path.join(command.working_dir or os.getcwd(), workdir)
91132
repo.set_workdir(workdir)
133+
if owner is not None and not query_config:
134+
owner._gix_repository = repo
135+
# ponytail: includes can load arbitrary files; reopen until Gix exposes their source paths.
136+
if state != owner._gix_state:
137+
included = re.search(rb"\[include(?:if)?[\s\]]", repo.config_snapshot().plumbing().to_bstring(), re.I)
138+
owner._gix_state = None if included else state
92139
return repo
93140

94141

‎git/cmd.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,13 +638,15 @@ class Git(metaclass=_GitMeta):
638638
"_git_options",
639639
"_persistent_git_options",
640640
"_environment",
641+
"_repo",
641642
)
642643

643644
_excluded_ = (
644645
"cat_file_all",
645646
"cat_file_header",
646647
"_version_info",
647648
"_version_info_token",
649+
"_repo",
648650
)
649651

650652
# Match Git's leading transport selector, including an empty helper name.
@@ -1180,6 +1182,7 @@ def __init__(self, working_dir: Union[None, PathLike] = None) -> None:
11801182

11811183
# Extra environment variables to pass to git commands
11821184
self._environment: Dict[str, Optional[str]] = {}
1185+
self._repo: Any = None # Weak reference; the Repo owns native resources.
11831186

11841187
# Cached version slots
11851188
self._version_info: Union[Tuple[int, ...], None] = None
@@ -1605,6 +1608,9 @@ def execute(
16051608
raise GitCommandNotFound(redacted_command, err) from err
16061609
else:
16071610
_backend.record("Git.execute", "CLI process")
1611+
owner = self._repo() if self._repo is not None else None
1612+
if owner is not None:
1613+
owner._gix_state = None
16081614
# Replace with a typeguard for Popen[bytes]?
16091615
proc.stdout = cast(BinaryIO, proc.stdout)
16101616
proc.stderr = cast(BinaryIO, proc.stderr)

‎git/repo/base.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
import shlex
1616
import sys
1717
import tempfile
18+
from threading import RLock
1819
import warnings
20+
import weakref
1921

2022
import gitdb
2123
import gitdb.util
@@ -124,6 +126,8 @@ class Repo:
124126

125127
# Must exist, or __del__ will fail in case we raise on `__init__()`.
126128
git = cast("Git", None)
129+
_gix_repository: Any = None
130+
_gix_state: Any = None
127131

128132
working_dir: PathLike
129133
"""The working directory of the git command."""
@@ -421,7 +425,9 @@ def __init__(
421425
break
422426

423427
self.working_dir = self._working_tree_dir or self.common_dir
428+
self._gix_lock = RLock()
424429
self.git = self.GitCommandWrapperType(self.working_dir)
430+
self.git._repo = weakref.ref(self)
425431
self.git._environment.update(GIT_DIR=git_dir, **environment)
426432
if self._working_tree_dir is not None:
427433
self.git.update_environment(GIT_WORK_TREE=os.fspath(self._working_tree_dir))
@@ -447,6 +453,18 @@ def __init__(
447453
)
448454
self.odb = odbt(rootpath)
449455

456+
def __getstate__(self) -> Dict[str, Any]:
457+
return {
458+
key: value
459+
for key, value in self.__dict__.items()
460+
if key not in ("_gix_repository", "_gix_state", "_gix_lock")
461+
}
462+
463+
def __setstate__(self, state: Dict[str, Any]) -> None:
464+
self.__dict__.update(state)
465+
self._gix_lock = RLock()
466+
self.git._repo = weakref.ref(self)
467+
450468
def __enter__(self) -> "Repo":
451469
return self
452470

@@ -460,6 +478,7 @@ def __del__(self) -> None:
460478
pass
461479

462480
def close(self) -> None:
481+
self._gix_repository = self._gix_state = None
463482
if self.git:
464483
self.git.clear_cache()
465484
# Tempfiles objects on Windows are holding references to open files until

‎test/test_gix_backend.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from io import BytesIO
44
from pathlib import Path
5+
import pickle
6+
from concurrent.futures import ThreadPoolExecutor
57
import tempfile
68
from unittest.mock import patch
79

@@ -20,6 +22,49 @@ def repo(request, tmp_path):
2022
yield repo
2123

2224

25+
def test_native_repository_lifetime_and_refresh(repo, tmp_path):
26+
handle = repo._gix_repository
27+
assert isinstance(handle, gix.Repository)
28+
with patch.object(gix, "open_opts", side_effect=AssertionError("unexpected reopen")):
29+
assert repo.git._call_process_safe("rev_parse", "--show-object-format") == repo.object_format
30+
assert repo._gix_repository is handle
31+
with ThreadPoolExecutor(max_workers=2) as pool:
32+
assert list(
33+
pool.map(lambda _: repo.git._call_process_safe("rev_parse", "--show-object-format"), range(2))
34+
) == [
35+
repo.object_format,
36+
repo.object_format,
37+
]
38+
Path(repo.working_dir, "file").write_text("payload")
39+
repo.index.add(["file"])
40+
first = repo.index.commit("native", skip_hooks=True)
41+
assert repo.commit().hexsha == first.hexsha
42+
repo.git.commit("--allow-empty", "-m", "CLI", "--no-verify")
43+
assert repo.commit().message == "CLI\n"
44+
assert repo._gix_repository is handle
45+
assert set(repo.index.entries) == {("file", 0)}
46+
47+
with repo.config_writer() as writer:
48+
writer.set_value("core", "abbrev", "9")
49+
assert _backend._repository(repo.git, {}).config_snapshot().integer("core.abbrev") == 9
50+
assert repo._gix_repository is handle
51+
include = tmp_path / "included"
52+
include.write_text("[test]\nvalue = first\n")
53+
repo.git.config("include.path", str(include))
54+
assert _backend._repository(repo.git, {}).config_snapshot().string("test.value") == b"first"
55+
include.write_text("[test]\nvalue = second\n")
56+
assert _backend._repository(repo.git, {}).config_snapshot().string("test.value") == b"second"
57+
58+
with pickle.loads(pickle.dumps(repo)) as restored:
59+
assert restored._gix_repository is None
60+
assert restored.commit().hexsha == repo.commit().hexsha
61+
assert restored._gix_repository is not None
62+
repo.close()
63+
assert repo._gix_repository is None
64+
assert repo.commit().message == "CLI\n"
65+
assert repo._gix_repository is not handle
66+
67+
2368
def test_native_objects_trees_commits_and_index_reads(repo):
2469
actor = Actor("Example", "example@example.invalid")
2570
paths = ["file", "dir/with space", "dir/unicode-é", "--option"]

0 commit comments

Comments
 (0)