Skip to content

Commit f6779db

Browse files
codexByron
authored andcommitted
perf(repo): batch repository metadata queries
PR #2274's Python 3.12 CI jobs spent 57m 25s in Windows `pytest` versus 5m 2s on Ubuntu, with 85,777 and 82,683 `Git.execute` launches respectively. A local profile of the file-handle and recursive submodule regressions spent about 65 of 77 seconds executing Git, including 44 seconds in repository construction. Repeated process startup makes the separate metadata probes expensive. Query reference format, object format, bare status and the absolute common directory in one `git rev-parse` call on the CLI construction path. Keep the path last and split only the three fixed scalar fields so embedded newlines remain intact. Git still computes every value. Supported Gix discovery keeps its native metadata and existing CLI format-validation query, with unchanged native launch ceilings and fallbacks. The focused selection drops from 2,447 to 2,009 launches: three saved for each of 146 repository opens. Three paired Windows measurements of 20 opens each reduce the median from 300.74 ms to 221.18 ms per open, with matching metadata and 11 versus eight launches. Add `--durations=30` to CI so future slow tests are visible, and record the profiling evidence and reproduction requirements in `doc/gix-backend.md`. Cover both object formats, both reference formats, bare and non-bare repos, spaces and embedded newlines. The eight applicable Windows cases fail before the fix and pass afterward; all 16 cases pass on Ubuntu WSL. The optional Windows Gix selection reproduces the same 17 failures on the original and patched sources. Document the demonstrated path-formatting and undecodable `commondir` differences under `GIX-14`, distinguishing them from Windows test assumptions. Keep these bugs open pending compatible upstream behavior. Validation: the full Windows CLI suite passes with 1,662 passed, 60 skipped, nine expected failures, two unexpected passes and 40 passing subtests. It records 77,732 launches in 3,715.16s. The initial baseline records 86,032 in 3,990.28s; its only two failures pass on unchanged code after restoring CI's `core.autocrlf=true` and placing `pytest`'s temporary directory outside a Git checkout. The overlapping full runs are not a controlled elapsed-time comparison. Repository-wide Ruff lint/format, all pinned pre-commit hooks, `mypy` (47 files), `basedpyright` (zero errors or warnings), the Sphinx HTML build and `git diff --check` pass.
1 parent ea24b47 commit f6779db

4 files changed

Lines changed: 126 additions & 10 deletions

File tree

‎.github/workflows/pythonpackage.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ jobs:
172172
173173
- name: Test with pytest
174174
run: |
175-
pytest --color=yes -p no:sugar --instafail -vv
175+
pytest --color=yes -p no:sugar --instafail -vv --durations=30
176176
continue-on-error: false
177177

178178
- name: Documentation

‎doc/gix-backend.md‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,57 @@ their focused tests. No full-suite rerun or fresh timed benchmark is claimed
458458
for this follow-up. Logs, backend reports and the count comparison are under
459459
`.cache/gix-capability-followup/` (`15-final-*` and `16-benchmark-*`).
460460

461+
### Windows CI process overhead
462+
463+
The Python 3.12 jobs in [PR #2274's Python package run](https://github.com/gitpython-developers/GitPython/actions/runs/37628358853)
464+
spent 57m 25s in pytest on Windows and 5m 2s on Ubuntu. Their process reports
465+
counted 85,777 and 82,683 `Git.execute` launches respectively. The Windows
466+
submodule test modules accounted for approximately 36 minutes, based on the
467+
timestamps of their test results; the delay was spread across many operations.
468+
469+
A local Windows profile of `test_file_handle_leaks` and
470+
`test_update_no_fetch_is_recursive[root-no-fetch]`, with CI's coverage and
471+
pytest options, counted 2,447 launches. About 65 of 77 seconds were spent in
472+
Git command execution, including 44 seconds in repository construction;
473+
forced garbage collection accounted for about three seconds. Combining
474+
`--show-ref-format`, `--show-object-format`, `--is-bare-repository` and
475+
`--git-common-dir` in one `rev-parse` invocation reduced the same selection to
476+
2,009 launches: three saved for each of 146 repository opens. Git still
477+
computes all four values. The common-directory path is last and split only
478+
after the three scalar fields, preserving paths containing newlines.
479+
480+
Three paired measurements of 20 ordinary repository opens on Windows gave
481+
median times of 300.74 ms before and 221.18 ms after, with matching metadata
482+
and 11 versus eight launches per open. These use CPython 3.12.13 and Git
483+
2.55.0.windows.3; timing is observational, while the launch reduction is
484+
checked by the regression tests.
485+
486+
A 50-call `git version` probe on the same host measured median times of
487+
17.49 ms per call on Windows and 1.27 ms in Ubuntu WSL, which uses CPython
488+
3.14.4 and Git 2.53.0. Even trivial Git commands carry appreciable startup
489+
cost on Windows.
490+
491+
This optimizes the CLI backend's construction path. Supported Gix discovery
492+
still uses its native metadata and the separate Git format-validation query;
493+
the Gix launch ceilings and open compatibility bugs remain unchanged.
494+
CI now includes `--durations=30` so subsequent slow tests are visible directly.
495+
Local Windows reproduction must retain CI's `core.autocrlf=true` setting and
496+
place pytest's temporary directories outside a Git checkout. Isolating all
497+
Git configuration without restoring that setting changes the newline test;
498+
placing `--basetemp` under this checkout lets empty-directory Git probes
499+
discover the parent repository instead.
500+
501+
The full local baseline at `105114db` recorded 86,032 launches in 3,990.28s;
502+
the two submodule modules accounted for 2,421.59s. It had 1,652 passing tests
503+
and the two harness failures described above, both of which passed unchanged
504+
after correcting the setup. The patched run exited successfully with 1,662
505+
passed, 60 skipped, nine expected failures, two unexpected passes and 40
506+
passing subtests. It recorded 77,732 launches in 3,715.16s. These runs
507+
overlapped, so their wall times are not a controlled comparison. The paired
508+
repository-open benchmark above measures the affected operation separately.
509+
Logs, profiles, JUnit results and backend reports are retained locally under
510+
`.cache/ci-performance/`.
511+
461512
### Test-suite setup measurements
462513

463514
The fixture optimizations below are implemented as separate commits, each
@@ -619,7 +670,7 @@ identify the historical fixture where needed.
619670
| GIX-11 | Unverified | Inexact and ambiguous rename pairing/scores have not been established as Git-compatible. This is a conservative guard, not a claimed native bug. | Verified pairing, scoring, tie-breaking and option parity. Exact unambiguous renames are native. |
620671
| GIX-12 | Missing API | Generic config bindings lack standalone parsing, ordered section/key enumeration, multivars, unset/remove-section and source-scoped queries. Dedicated `.gitmodules` parsing is available and now used for supported submodule reads. | Those operations for `GitConfigParser` and remote/branch configuration. Native merged getters cannot replace repository-only config readers. |
621672
| GIX-13 | Bug; missing API | Native index writing normalizes v4 to v2, offers no version setter, and expands split indexes. | Version and split-index preservation. Tests now assert that a split index remains split after an update. |
622-
| GIX-14 | Bug; missing API | For a linked worktree of a bare main repository, `is_bare()` returns true while `git rev-parse --is-bare-repository` returns false; `workdir()` does expose the worktree. Initial gitfile opening can retain a noncanonical target and use an arbitrary gitfile's location as the worktree, unlike Git. Discovery accepts undecodable HEADs until explicit `head()` decoding and ignores dangling `commondir` symlinks that Git rejects. Bindings flatten failure kinds into `gix.Error`. | Provide Git-compatible classification, canonical gitfile locations and layout validation, plus typed errors. The adapter combines Gix metadata, reopens through Gix and forces HEAD decoding, retaining CLI for remaining validation/diagnostic gaps. Those workarounds do not close these compatibility bugs. A Git-strict mode must cover these cases; strict config and trust settings alone do not. |
673+
| GIX-14 | Bug; missing API | For a linked worktree of a bare main repository, `is_bare()` returns true while `git rev-parse --is-bare-repository` returns false; `workdir()` does expose the worktree. Initial gitfile opening can retain a noncanonical target and use an arbitrary gitfile's location as the worktree, unlike Git. Discovery accepts undecodable HEADs until explicit `head()` decoding and ignores dangling `commondir` symlinks that Git rejects. Bindings flatten failure kinds into `gix.Error`. Windows also exposes adapter path-formatting differences and a worker panic on an undecodable `commondir`; see the Windows reproduction below. | Provide Git-compatible classification, canonical gitfile locations and layout validation, plus typed errors instead of panics. Format native locations compatibly with Git's command output. The adapter combines Gix metadata, reopens through Gix and forces HEAD decoding, retaining CLI for remaining validation/diagnostic gaps. Those workarounds do not close these compatibility bugs. A Git-strict mode must cover these cases; strict config and trust settings alone do not. |
623674
| GIX-15 | Bug | Native blame disagrees with Git even with Myers and rewrite tracking selected. In the fixture's `README.md`, lines 150 and 158 are attributed to the opposite commits. Incremental order also differs. | Attribution and incremental-output parity before replacing `Repo.blame` / `blame_incremental`. |
624675
| GIX-16 | Bug; missing API | Native archive streaming takes a tree rather than a commit. Its TAR omits Git's global PAX commit comment, leaves `export-subst` placeholders literal, and writes ordinary modes as `0644` where Git uses `0664`. | Commit-aware export substitution, metadata and permission parity. Both engines respected `export-ignore` in the probe. |
625676
| GIX-17 | Bug | Revision parsing accepts abbreviated IDs with `-dirty`, prefers the OID suffix over an exact describe-shaped tag, and treats escaped braces in message searches differently. | Git-compatible revision grammar and regex semantics. Existing `test_rev_parse.py` cases reproduce all three. |
@@ -632,6 +683,29 @@ identify the historical fixture where needed.
632683
| GIX-24 | Bug | In both object formats, `new_commit_as()` accepts a blob as the tree or a parent where `git commit-tree` rejects it. Tree-editor `upsert()`/`write()` accepts object IDs whose actual kinds disagree with blob/tree/gitlink modes; `git mktree --missing` rejects all three tested mismatches. `edit_references_as()` accepts a blob target under `refs/heads/`, rejected by `git update-ref`. | Provide checked commit/tree construction and reference edits, at least in Git-strict mode. Validate kinds and branch targets, including symbolic aliases, before writing and under the required ref locks. Existing `_commit_tree`, `_write_tree` and `_update_ref` guards query Gix headers and select CLI on invalid inputs; they do not replace Gix writes with Python. |
633684
| GIX-25 | Bug; missing API | After `git symbolic-ref refs/heads/dangling refs/heads/missing`, `Repository.references().all()` enumerates the dangling name, while `git for-each-ref --format=%(refname)` omits it. Valid symbolic aliases must remain present. Both SHA-1 and SHA-256 probes reproduce this difference. | Expose Git-compatible enumeration with the same dangling-reference and diagnostic behavior, retaining the raw iterator for callers that need it. `_for_each_ref` forces Gix to resolve symbolic targets and falls back to Git on failure; keep that fallback until a compatible Gix API/mode is verified. |
634685

686+
Windows reproduction for GIX-14 uses CPython 3.12.13, Git
687+
2.55.0.windows.3 and the released GixPython 0.1.0 source distribution with
688+
the same pinned Gitoxide revision above. Both `105114db` and the CLI metadata
689+
batching change reproduce the following existing failures:
690+
691+
- `test_native_command_queries_match_cli` and
692+
`test_native_worktree_inventory_includes_bare_main` return backslashes in
693+
the adapter's native path text where Git returns forward slashes, in both
694+
object formats. The locations agree; the command-output formatting does not.
695+
- The `commondir=b"\xff"` subtest of
696+
`test_repo_discovery_rejects_invalid_metadata` raises
697+
`RuntimeError: native worker panicked` instead of the CLI backend's
698+
`InvalidGitRepositoryError`. The binding must reject this input through its
699+
normal native error contract so discovery can take the existing fallback.
700+
701+
These remain open compatibility bugs. The optional Windows selection also
702+
has tests that fail before a native/Git comparison: newline-containing
703+
filenames, a raw backslash gitfile operand, and a POSIX-specific symlink
704+
canonicalization assertion. Its symbolic-alias blob assertion is not evidence
705+
of a Gix mismatch on this Git version: direct Git controls in both object
706+
formats also accept the alias update and leave the branch pointing to a blob.
707+
This does not invalidate GIX-24's separate direct-branch validation comparison.
708+
635709
For GIX-8, at fixture commit `44e0a8ec55c42559dfcdf5117710b26261a7c937`,
636710
compare `git rev-list HEAD` with
637711
`repo.rev_walk([repo.head_id()]).sorting("newest_first").all()`.

‎git/repo/base.py‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,21 @@ def __init__(
369369
self.git_dir = git_dir
370370
probe.update_environment(GIT_DIR=git_dir)
371371
try:
372-
self.ref_format = probe._call_process_safe("rev_parse", "--show-ref-format")
372+
if native is not NotImplemented:
373+
self.ref_format = probe._call_process_safe("rev_parse", "--show-ref-format")
374+
else:
375+
# Query the fixed scalar fields together, leaving the path last so
376+
# embedded newlines in the common directory remain unambiguous.
377+
metadata = probe._call_process_safe(
378+
"rev_parse",
379+
"--show-ref-format",
380+
"--show-object-format",
381+
"--is-bare-repository",
382+
"--path-format=absolute",
383+
"--git-common-dir",
384+
)
385+
self.ref_format, self.object_format, bare, self._common_dir = metadata.split("\n", 3)
386+
self._bare = bare == "true"
373387
except GitCommandError as exc:
374388
raise InvalidGitRepositoryError(epath) from exc
375389
if native is not NotImplemented:
@@ -379,13 +393,6 @@ def __init__(
379393
# Gix's configured bare flag also applies to linked worktrees.
380394
self._bare = native.is_bare() and self._working_tree_dir is None
381395
else:
382-
try:
383-
self._common_dir = probe._call_process_safe("rev_parse", "--path-format=absolute", "--git-common-dir")
384-
self._bare = probe._call_process_safe("rev_parse", "--is-bare-repository") == "true"
385-
self.object_format = probe._call_process_safe("rev_parse", "--show-object-format")
386-
except GitCommandError as exc:
387-
raise InvalidGitRepositoryError(epath) from exc
388-
389396
self._working_tree_dir = environment.get("GIT_WORK_TREE")
390397
if self._working_tree_dir is None and not self._bare and environment.get("GIT_COMMON_DIR") is None:
391398
try:

‎test/test_repo.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,6 +1700,41 @@ def test_ignored_raises_error_w_symlink(self):
17001700
temp_repo.ignored(tmp_dir / "symlink/file.txt")
17011701

17021702

1703+
@pytest.mark.parametrize("object_format", ["sha1", "sha256"])
1704+
@pytest.mark.parametrize("ref_format", ["files", "reftable"])
1705+
@pytest.mark.parametrize("bare", [False, True])
1706+
@pytest.mark.parametrize(
1707+
"dirname",
1708+
[
1709+
"repo with spaces",
1710+
pytest.param(
1711+
"repo\nwith\nnewlines",
1712+
marks=pytest.mark.skipif(sys.platform == "win32", reason="Windows paths cannot contain newlines"),
1713+
),
1714+
],
1715+
)
1716+
def test_repo_batches_cli_metadata_queries(tmp_path, monkeypatch, object_format, ref_format, bare, dirname):
1717+
from git import _backend
1718+
1719+
monkeypatch.setattr(_backend, "gix", None)
1720+
with Repo.init(tmp_path / dirname, object_format=object_format, ref_format=ref_format, bare=bare) as original:
1721+
with mock.patch.object(Git, "execute", autospec=True, side_effect=Git.execute) as execute:
1722+
with Repo(original.git_dir) as reopened:
1723+
assert reopened.ref_format == ref_format
1724+
assert reopened.object_format == object_format
1725+
assert reopened.bare == bare
1726+
assert reopened.common_dir == original.common_dir
1727+
assert reopened.git_dir == original.git_dir
1728+
assert reopened.working_tree_dir == original.working_tree_dir
1729+
1730+
metadata_options = {"--show-ref-format", "--show-object-format", "--is-bare-repository", "--git-common-dir"}
1731+
metadata_commands = [
1732+
call.args[1] for call in execute.call_args_list if metadata_options.intersection(call.args[1])
1733+
]
1734+
assert len(metadata_commands) == 1
1735+
assert metadata_options.issubset(metadata_commands[0])
1736+
1737+
17031738
@pytest.mark.parametrize("allow_unsafe_options", (False, True))
17041739
@pytest.mark.parametrize(
17051740
"kwargs",

0 commit comments

Comments
 (0)