From 6c99e0439971749d75375beca74acb15b2ad8e04 Mon Sep 17 00:00:00 2001 From: Keerthana KT Date: Sat, 10 Oct 2026 01:38:04 +0530 Subject: [PATCH 1/2] fix: send one object name or path per git stdin record `Git._prepare_ref` terminates an object name with a line feed and writes it to the persistent `git cat-file --batch-check` or `--batch` process, and `__get_object_header` reads exactly one response line back. A name that itself contains a line feed is therefore two requests against one response read, and the stream is left one response behind for the lifetime of the `Git` instance, so every later lookup is answered with the header of an object it did not ask for. `Repo.is_valid_object` hands its argument straight to `partial_to_complete_sha_hex`, so a single call with an untrusted revision string is enough to get there. After `repo.is_valid_object("HEAD\nHEAD")`, `repo.commit()` returns a different commit than the one named and `Repo.odb.info` reports another object's type and size, with nothing to indicate it. On the `--batch` stream the size from the mismatched header also misaligns the content reads, so object data is read from the wrong offsets and a tree read right afterwards fails with `Invalid tree entry mode`. Git has no way to express an object name containing a line feed on that stdin, so `_prepare_ref` now refuses one and keeps the single trailing line feed that is the request terminator. `Repo.is_valid_object` reports such a name as invalid, which it is, and `short_to_long` keeps turning it into `BadName`. `IndexFile._write_path_to_stdin` writes paths to `git checkout-index --stdin` the same way, and unlike an object name a path can legitimately contain a line feed. With `dir/x\nkeep.txt` in the index, `index.checkout(paths=["dir"], force=True)` had Git read two paths, so `keep.txt` was checked out over the local copy while the requested file was never written. Git also unquotes a line-feed separated path that begins with a double quote, which made a file named `"q"` impossible to check out. That call site now runs with `-z` and NUL-terminated paths, which Git does not unquote. Adds regression tests in `test/test_git.py` and `test/test_index.py`, both of which fail before this change. The rest of the suite is unaffected on Python 3.11 on macOS, apart from eight failures that predate the change in `test_commit.py` and `test_submodule.py`; `ruff`, `mypy`, `basedpyright --warnings` and the docs build are clean. --- git/cmd.py | 12 +++++++++--- git/index/base.py | 11 ++++++----- test/test_git.py | 13 +++++++++++++ test/test_index.py | 21 +++++++++++++++++++++ 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 3ddb9ae7f..1d1ca909a 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -1940,9 +1940,15 @@ def _prepare_ref(self, ref: object) -> bytes: else: refstr = ref - if not refstr.endswith("\n"): - refstr += "\n" - return refstr.encode(defenc) + # A line feed terminates a request, so one object name must be one line. An + # embedded one would queue a second request on the persistent command while + # only one response line is read back, leaving every later call one response + # behind, answered with the header of an object it did not ask for. + if refstr.endswith("\n"): + refstr = refstr[:-1] + if "\n" in refstr: + raise ValueError("Object name %r contains a line feed" % refstr) + return (refstr + "\n").encode(defenc) def _get_persistent_cmd(self, attr_name: str, cmd_name: str, *args: Any, **kwargs: Any) -> "Git.AutoInterrupt": cur_val = getattr(self, attr_name) diff --git a/git/index/base.py b/git/index/base.py index 3d32d7d81..17a722c21 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -525,17 +525,17 @@ def _write_path_to_stdin( the piped-in files are processed anyway and just in time. :note: - Newlines are essential here, git's behaviour is somewhat inconsistent on - this depending on the version, hence we try our best to deal with newlines - carefully. Usually the last newline will not be sent, instead we will close - stdin to break the pipe. + Paths are NUL-terminated, so the command has to run with ``-z``. A path can + contain a line feed, and with line-feed separation git would read such a + path as two paths and act on files that were never passed. git also unquotes + a line-feed separated path that begins with a double quote. """ fprogress(filepath, False, item) rval: Union[None, str] = None if proc.stdin is not None: try: - proc.stdin.write(("%s\n" % filepath).encode(defenc)) + proc.stdin.write(("%s\0" % filepath).encode(defenc)) except OSError as e: # Pipe broke, usually because some error happened. raise fmakeexc() from e @@ -1452,6 +1452,7 @@ def handle_stderr(proc: "Popen[bytes]", iter_checked_out_files: Iterable[PathLik # initialization. self.entries # noqa: B018 + args.append("-z") args.append("--stdin") kwargs["as_process"] = True kwargs["istream"] = subprocess.PIPE diff --git a/test/test_git.py b/test/test_git.py index 26ff4e44e..5224842ee 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -593,6 +593,19 @@ def test_persistent_cat_file_command(self): self.assertEqual(typename, typename_two) self.assertEqual(size, size_two) + def test_object_header_rejects_an_embedded_line_feed(self): + hexsha = "b2339455342180c7cc1e9bba3e9f181f7baa5167" + git = Git(self.rorepo.working_dir) + header = git.get_object_header(hexsha) + + # A single trailing line feed is the request terminator, not a second request. + self.assertEqual(git.get_object_header(hexsha + "\n"), header) + + self.assertRaises(ValueError, git.get_object_header, "HEAD\nHEAD") + + # The persistent command is still in step, so this is not HEAD's header. + self.assertEqual(git.get_object_header(hexsha), header) + def test_version_info(self): """The version_info attribute is a tuple of up to four ints.""" v = self.git.version_info diff --git a/test/test_index.py b/test/test_index.py index 150b39466..23e59cc04 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1878,6 +1878,27 @@ def test_checkout_pathlike(self, tmp_path, path_type, absolute, directory, conta for name, data in files.items(): assert (tmp_path / name).read_bytes() == data + @pytest.mark.skipif(os.name == "nt", reason="Line feeds and quotes are not valid Windows filenames") + def test_checkout_sends_each_path_as_one_record(self, tmp_path): + with Repo.init(tmp_path) as repo: + nested = tmp_path / "nested" + nested.mkdir() + (nested / "first\noutside").write_bytes(b"nested") + (tmp_path / "outside").write_bytes(b"committed") + (tmp_path / '"quoted"').write_bytes(b"quoted") + repo.index.add(["nested", "outside", '"quoted"']) + + (nested / "first\noutside").unlink() + (tmp_path / '"quoted"').unlink() + (tmp_path / "outside").write_bytes(b"local") + + checked_out = {"nested/first\noutside", '"quoted"'} + assert set(repo.index.checkout(["nested", '"quoted"'], force=True)) == checked_out + assert (nested / "first\noutside").read_bytes() == b"nested" + assert (tmp_path / '"quoted"').read_bytes() == b"quoted" + # Neither "nested/first" nor "outside" was requested. + assert (tmp_path / "outside").read_bytes() == b"local" + class TestIndexUtils: @pytest.mark.parametrize("file_path_type", [str, Path]) From 3a35778b6dcfc355d4b9a0c9edaf6b532cc6cc2a Mon Sep 17 00:00:00 2001 From: Byron Date: Sat, 10 Oct 2026 04:26:55 +0200 Subject: [PATCH 2/2] review - also update agents.md for clearer separation of speech, as an experiment really. Assisted-by: GPT 6.1 Sol Co-authored-by: GPT 6.1 Sol --- AGENTS.md | 3 ++- CONTRIBUTING.md | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6b2963cf4..cdffa4764 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,8 @@ Before starting work, read and follow [CONTRIBUTING.md](CONTRIBUTING.md), including the [Prevent agent impersonation](CONTRIBUTING.md#prevent-agent-impersonation) -section governing identification when communicating through a person's account. +section governing agent identification and separation of unaltered user statements +when communicating through a person's account. # Commit messages diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76f276323..1a64a701d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,13 @@ example in issue or PR descriptions and comments. AI assistance that does not re the person as the speaker, such as proofreading or wording polish, does not require identification. +Even when identifying themselves, agents must not assert on a person's behalf that +that person performed an action, such as reviewing or approving a PR. The person +must make any such statement themselves. If it is included alongside agent-authored +content, it must be supplied by the person and preserved verbatim in a clearly +labeled, separate user-authored section. Agents must not draft, paraphrase, or embed +such statements in their own narration. + Attributing AI assistance in commit metadata, for example with a `Co-authored-by` trailer, is welcome but not required. Code is reviewed the same way regardless of its origin.