diff --git a/impact_gate/core/gitplumb.py b/impact_gate/core/gitplumb.py index c14c312..5948a81 100644 --- a/impact_gate/core/gitplumb.py +++ b/impact_gate/core/gitplumb.py @@ -38,6 +38,7 @@ class GitRepo: def __init__(self, path: str): self.path = path self._batch: subprocess.Popen | None = None + self._batch_z: bool | None = None def _run(self, *args: str) -> str: out = subprocess.run(["git", "-C", self.path, "-c", "diff.mnemonicprefix=false", *args], @@ -51,11 +52,26 @@ def diff(self, old: str, new: str) -> list[FileDiff]: return parse_diff(text) # ---- blob streaming via a persistent cat-file --batch ----------------- + def _supports_batch_z(self) -> bool: + # cat-file --batch -Z (NUL-delimited I/O) needs git >= 2.42; older git + # exits non-zero on the unknown option. Probe once and fall back to the + # newline protocol so older environments keep working (a filename with a + # literal newline is then the only case that stays broken). + if self._batch_z is None: + probe = subprocess.run( + ["git", "-C", self.path, "cat-file", "--batch", "-Z"], + input=b"", capture_output=True, + ) + self._batch_z = probe.returncode == 0 + return self._batch_z + def _ensure_batch(self) -> subprocess.Popen: if self._batch is None or self._batch.poll() is not None: + args = ["git", "-C", self.path, "cat-file", "--batch"] + if self._supports_batch_z(): + args.append("-Z") self._batch = subprocess.Popen( - ["git", "-C", self.path, "cat-file", "--batch", "-Z"], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, + args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, ) return self._batch @@ -77,9 +93,13 @@ def blob(self, rev: str, path: str) -> tuple[str, bytes] | None: """ proc = self._ensure_batch() assert proc.stdin and proc.stdout - proc.stdin.write(f"{rev}:{path}\0".encode()) + z = self._supports_batch_z() + proc.stdin.write(f"{rev}:{path}".encode() + (b"\0" if z else b"\n")) proc.stdin.flush() - header = self._read_until_nul(proc.stdout).decode("utf-8", errors="replace") + if z: + header = self._read_until_nul(proc.stdout).decode("utf-8", errors="replace") + else: + header = proc.stdout.readline().decode("utf-8", errors="replace").strip() if not header or header.endswith(("missing", "ambiguous")): return None parts = header.split() @@ -89,7 +109,7 @@ def blob(self, rev: str, path: str) -> tuple[str, bytes] | None: except ValueError: return None data = proc.stdout.read(size) - proc.stdout.read(1) # trailing NUL (-Z mode) + proc.stdout.read(1) # trailing NUL (-Z) or newline return oid, data def close(self) -> None: diff --git a/tests/test_gitplumb.py b/tests/test_gitplumb.py new file mode 100644 index 0000000..35ed523 --- /dev/null +++ b/tests/test_gitplumb.py @@ -0,0 +1,32 @@ +"""GitRepo blob streaming: the persistent cat-file --batch protocol.""" +import pytest + +from impact_gate.core.gitplumb import GitRepo +from gitutil import commit, write + + +def test_blob_reads_file_with_newline_in_name(repo): + write(repo, "plain.py", "x = 1\n") + write(repo, "a\nb.py", "y = 2\n") + commit(repo, "base") + r = GitRepo(str(repo)) + if not r._supports_batch_z(): + r.close() + pytest.skip("git cat-file --batch -Z unsupported (git < 2.42)") + try: + assert r.blob("HEAD", "a\nb.py")[1] == b"y = 2\n" + assert r.blob("HEAD", "plain.py")[1] == b"x = 1\n" + finally: + r.close() + + +def test_blob_falls_back_to_newline_protocol_without_batch_z(repo): + write(repo, "plain.py", "x = 1\n") + commit(repo, "base") + r = GitRepo(str(repo)) + r._batch_z = False + try: + assert "-Z" not in r._ensure_batch().args + assert r.blob("HEAD", "plain.py")[1] == b"x = 1\n" + finally: + r.close()