Skip to content

Commit 8ce71f5

Browse files
codexByron
authored andcommitted
fix(repo): retain checkout context for separate Git directories
Mistral Vibe 3.0.1's two separate-directory worktree tests fail when `Repo.init(..., separate_git_dir=...)` returns the administrative directory's parent as `working_tree_dir`. Index staging then cannot find the files in the requested checkout. Released GitPython 3.2.1 passes the same tests. Distinguish a checkout's `.git` file from an arbitrary gitfile operand when opening a discovery candidate. Reopen the canonical checkout gitfile through `gix.open_opts()` and use its native `Repository.workdir()` result; reopening only its administrative target discards the supplied checkout context. Keep the existing canonical administrative reopen for standalone arbitrary-gitfile helpers and their GIX-14 workaround. In the CLI path, ask Git for the top-level directory from the supplied checkout instead of inferring it from metadata. Document the adapter correction without closing upstream compatibility bugs. Add a relocatable Bash fixture and staging/commit regression across SHA-1 and SHA-256 with files and reftable references. All four regressions failed on each backend before the change. Also execute linked-worktree regression fixtures at their final location because their absolute registrations cannot be safely copied between tests. The repository and downstream regression suites passed 145 tests, two skips and 14 subtests on CLI. Adding native/canonical gitfile tests passed 247 tests, two skips and 14 subtests with Gix. Both original Mistral Vibe regressions passed on each backend. Ruff lint/format, ShellCheck and mypy passed with Python 3.12.15, Git 2.53.0 and GixPython 0.2.0. The remaining downstream bare cleanup failure is a GC-timing assumption: both released and local GitPython call `close()` from finalization, but at different times. PR #2274's Ubuntu Python 3.12 CLI/Gix jobs failed in `basedpyright --warnings` with four possibly-unbound/operator errors at the checkout-context guard. The absolute discovery path always produces at least one iteration; express that invariant with `while True`, retaining the existing success, invalid metadata and root-directory exits. This ensures the candidate and `.git` path are assigned before later checkout selection without placeholder paths or changes to Git/Gix discovery semantics. The original four diagnostics reproduce locally and disappear after this correction. Repository/downstream tests pass 146 cases and 14 subtests on each backend with two skips, CLI first. Ruff lint/format and changed-module mypy pass. Hosted verification follows the branch push. The same Windows Gix run also failed all four separate-directory regression variants because Git returned forward slashes while native repository metadata used backslashes. Compare the resolved checkout with `Path.samefile()` while retaining exact metadata leaf names, blob reads and staging/commit checks. Resolve the command-test replay context without losing either regression. All 13 downstream command/discovery cases pass on CLI and Gix locally; Ruff lint/format passes. Actual Windows validation is left to hosted CI.
1 parent 2fd0d00 commit 8ce71f5

5 files changed

Lines changed: 53 additions & 7 deletions

File tree

‎doc/gix-backend.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ The compatibility `entries` property projects those queries into a read-only
5050
snapshot keyed by path and stage, preserving readers in dlt and Renku. It uses
5151
the same Gix-backed default-index query or documented Git fallback as
5252
`iter_entries()`; snapshots do not become editable index state.
53+
54+
Discovery distinguishes a checkout's `.git` file from an arbitrary gitfile
55+
operand. Gix reopens the canonical checkout gitfile through `gix.open_opts()`
56+
and returns `Repository.workdir()` for separate Git directories. Reopening only
57+
the administrative directory loses that checkout context. Standalone gitfile
58+
helpers still reopen the canonical native Git directory to avoid the known
59+
arbitrary-gitfile mismatch (GIX-14). The CLI path uses `rev-parse --show-toplevel`
60+
from the supplied checkout. Mistral Vibe 3.0.1 and scripted SHA-1/SHA-256,
61+
files/reftable regressions verify this adapter correction with Git 2.53.0 and
62+
GixPython 0.2.0; it does not close GIX-14's upstream compatibility bugs.
5363
Deferred/virtual state consists of opaque file bytes, materialized before Git
5464
operates and read back only after success. Private edits use Git because native
5565
bindings cannot load an arbitrary existing index (GIX-3) or validate a checked

‎git/_backend.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def _fallback(method: str, reason: str) -> Any:
5757
return NotImplemented
5858

5959

60-
def discover_repository(path: str, environment: Dict[str, Any]) -> Any:
60+
def discover_repository(path: str, environment: Dict[str, Any], *, from_worktree: bool = False) -> Any:
6161
"""Open one discovery candidate; the caller controls parent traversal."""
6262
if gix is None:
6363
return NotImplemented
@@ -73,7 +73,11 @@ def discover_repository(path: str, environment: Dict[str, Any]) -> Any:
7373
)
7474
try:
7575
repo = gix.open_opts(path, options)
76-
if os.path.isfile(path):
76+
if os.path.isfile(path) and from_worktree:
77+
# Canonicalize the supplied checkout path and let Gix resolve both
78+
# its Git directory and worktree from that .git file.
79+
repo = gix.open_opts(os.path.realpath(path), options)
80+
elif os.path.isfile(path):
7781
# Reopening also makes Gix derive the worktree from its Git directory,
7882
# rather than retaining an arbitrary gitfile's location as a worktree.
7983
repo = gix.open_opts(os.path.realpath(repo.git_dir()), options)

‎git/repo/base.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -321,13 +321,13 @@ def __init__(
321321
curpath = osp.abspath(os.fspath(epath))
322322
git_dir = None
323323
native = NotImplemented
324-
while curpath:
324+
while True:
325325
dotgit = osp.join(curpath, ".git")
326326
candidate = curpath if explicit_git_dir or not osp.lexists(dotgit) else dotgit
327327
try:
328328
# Git resolves relative gitfile targets using the last forward
329329
# slash in this operand, including on Windows.
330-
native = _backend.discover_repository(candidate, environment)
330+
native = _backend.discover_repository(candidate, environment, from_worktree=candidate == dotgit)
331331
git_dir = (
332332
probe._call_process_safe("rev_parse", "--resolve-git-dir", to_native_path_linux(candidate))
333333
if native is NotImplemented
@@ -358,6 +358,14 @@ def __init__(
358358
if git_dir is None:
359359
raise InvalidGitRepositoryError(epath)
360360

361+
checkout = None
362+
if native is NotImplemented and candidate == dotgit and osp.isfile(candidate):
363+
# Let Git select the checkout through the supplied .git file. Its
364+
# administrative directory alone need not identify the worktree.
365+
checkout_probe = self.GitCommandWrapperType(curpath)
366+
checkout_probe._environment.update(GIT_DIR=None, **environment)
367+
checkout = checkout_probe._call_process_safe("rev_parse", "--show-toplevel")
368+
361369
self.git_dir = git_dir
362370
probe.update_environment(GIT_DIR=git_dir)
363371
try:
@@ -388,7 +396,7 @@ def __init__(
388396
# Gix's configured bare flag also applies to linked worktrees.
389397
self._bare = native.is_bare() and self._working_tree_dir is None
390398
else:
391-
self._working_tree_dir = environment.get("GIT_WORK_TREE")
399+
self._working_tree_dir = environment.get("GIT_WORK_TREE") or checkout
392400
if self._working_tree_dir is None and not self._bare and environment.get("GIT_COMMON_DIR") is None:
393401
try:
394402
probe._call_process_safe("config", "--get", "core.worktree")

‎test/test_downstream_compatibility.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ def test_index_entries_readers_retain_pending_and_published_snapshots(object_for
3636
@pytest.mark.parametrize("object_format", ["sha1", "sha256"])
3737
@pytest.mark.parametrize("ref_format", ["files", "reftable"])
3838
def test_git_directory_option_selects_the_requested_worktree(object_format, ref_format):
39-
with fixture_repo("downstream-worktree.sh", object_format, ref_format, write=True) as repo:
39+
# Linked-worktree registration contains absolute paths that cannot be copied.
40+
with fixture_repo("downstream-worktree.sh", object_format, ref_format, write=True, execute=True) as repo:
4041
root = Path(repo.working_tree_dir).parent
4142
linked = root / "linked"
4243
original_index = Path(repo.index.path).read_bytes()
@@ -55,10 +56,24 @@ def test_git_directory_option_selects_the_requested_worktree(object_format, ref_
5556

5657

5758
def test_git_directory_option_retains_explicit_environment():
58-
with fixture_repo("downstream-worktree.sh", "sha1", "files", write=True) as repo:
59+
# Linked-worktree registration contains absolute paths that cannot be copied.
60+
with fixture_repo("downstream-worktree.sh", "sha1", "files", write=True, execute=True) as repo:
5961
root = Path(repo.working_tree_dir).parent
6062
other = root / "other"
6163
with repo.git.custom_environment(GIT_DIR=repo.git_dir, GIT_WORK_TREE=repo.working_tree_dir):
6264
assert repo.git(C=str(other)).show("HEAD:tracked") == "original"
6365
assert repo.git(C=str(other)).show("HEAD:tracked") == "other"
6466
assert repo.git(C=str(other)).show("HEAD:tracked", env={"GIT_DIR": repo.git_dir}) == "original"
67+
68+
69+
@pytest.mark.parametrize("object_format", ["sha1", "sha256"])
70+
@pytest.mark.parametrize("ref_format", ["files", "reftable"])
71+
def test_separate_git_directory_retains_the_supplied_checkout(object_format, ref_format):
72+
with fixture_repo("downstream-separate-git-dir.sh", object_format, ref_format, write=True) as repo:
73+
assert Path(repo.working_tree_dir).name == "repo"
74+
assert Path(repo.git_dir).name == "metadata"
75+
assert Path(repo.git.rev_parse("--show-toplevel")).samefile(repo.working_tree_dir)
76+
assert repo.index.entry("tracked").to_blob(repo).data_stream.read() == b"tracked"
77+
Path(repo.working_tree_dir, "tracked").write_text("updated")
78+
repo.index.add(["tracked"])
79+
assert repo.index.commit("updated").tree["tracked"].data_stream.read() == b"updated"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env bash
2+
set -eu
3+
git init -q --object-format="$1" --ref-format="$2" --separate-git-dir=metadata repo
4+
# Keep the fixture relocatable when copied into a writable test directory.
5+
printf 'gitdir: ../metadata\n' > repo/.git
6+
cd repo
7+
printf tracked > tracked
8+
git add tracked
9+
git commit -qm initial

0 commit comments

Comments
 (0)