Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 9 additions & 3 deletions git/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 6 additions & 5 deletions git/index/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions test/test_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions test/test_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading