Skip to content

Commit 4038d25

Browse files
committed
fix(code-review): check git-quoted diff paths instead of skipping them
1 parent 2ed4f35 commit 4038d25

3 files changed

Lines changed: 152 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
#### Fixed
1010
- Reviewer and verifier agents resolved repo-relative source paths against the invoking session's working directory instead of the checkout under review (ISS-7382). `resolve-scope` emitted `review_root: ""` for every scope kind except a local PR-head worktree, and `shared_prompt.txt` and `verifier_prompt.txt` read an empty root as "read from the working directory" — which for a spawned agent is the session's checkout, not the tree the diff came from. `resolve-scope` now resolves `review_root` for every scope kind (the PR-head worktree when one is created, otherwise `git rev-parse --show-toplevel` of the invoking checkout), records the commit it resolved at as `review_root_sha`, and exits `3` instead of emitting an empty root when the working directory is not inside a git worktree.
11-
- The four stages that hand work to agents — `derive-spawn-spec`, `derive-static-spec`, `verify-prepare`, and `review-dismissed-prepare` — re-prove `review_root` before dispatching and exit `3` when they cannot. The root must be absolute, free of control characters, `<`, `>` and backticks, exist, and be the root of a git worktree. A PR-head worktree's HEAD must still equal `review_root_sha`, and a live checkout must still contain that commit. Every non-removed file in `diff_data.json` must exist under the root (git C-quoted paths are skipped), and a root with neither a recorded commit nor a resolvable changed file is refused.
11+
- The four stages that hand work to agents — `derive-spawn-spec`, `derive-static-spec`, `verify-prepare`, and `review-dismissed-prepare` — re-prove `review_root` before dispatching and exit `3` when they cannot. The root must be absolute, free of control characters, `<`, `>` and backticks, exist, and be the root of a git worktree. A PR-head worktree's HEAD must still equal `review_root_sha`, and a live checkout must still contain that commit. Every non-removed file in `diff_data.json` must exist under the root, and a root with neither a recorded commit nor a resolvable changed file is refused. `parse-diff` records names as `git diff --name-only` prints them, so a path holding a non-ASCII, control, `"` or `\` byte is stored C-quoted; those names are decoded (named escapes and 3-digit octal escapes) to the name on disk and go through the existence and drift checks like any other file, and an entry that starts with `"` but is not valid quoting is refused.
1212
- Dispatch is also refused when a file the diff changes no longer matches `review_root_sha` in the working tree: a commit, reset, or uncommitted edit to that file after the scope was resolved, including uncommitted edits already present when a branch review starts. The comparison is one `git diff --name-only -z --no-renames <review_root_sha>` call intersected with the diff's file list, so no pathspec is passed and no path is glob-interpreted, and a failing git call refuses rather than passes. Changes to files outside the diff are still allowed, and `staged` scope is exempt because its diff is index-vs-HEAD.
1313
- Exit `3` now aborts the walk regardless of the stage's `on_failure`. `derive-spawn-spec`, `derive-static-spec`, and `verify-prepare` are `on_failure: continue`, so a plain non-zero exit would fall back to the static reviewer table or skip verification and dispatch agents against the same unproven tree. `_execute_stage_inprocess` and both walkers in `prefix_golden_harness.py` apply the override; every other non-zero exit still follows `on_failure`. `start.md` tells the orchestrator not to resume a stage that exited `3` from the `run-prefix` error fallback.
1414
- A positional scope argument that is a git ref or revision range (e.g. `/code-review origin/main...HEAD`) is rejected with a pointer to `--base`, instead of being folded into a `--` pathspec that matched nothing and produced an empty, clean review.

plugins/code-review/tools/python/code_review_helpers.py

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -529,13 +529,73 @@ def _git_drifted_paths(
529529
return [p for p in paths if p in differing]
530530

531531

532+
# Named escapes git's C-style path quoting emits; every other control or
533+
# non-ASCII byte becomes a 3-digit octal escape.
534+
_GIT_C_QUOTE_ESCAPES: dict[str, int] = {
535+
"a": 0x07, "b": 0x08, "t": 0x09, "n": 0x0A, "v": 0x0B, "f": 0x0C,
536+
"r": 0x0D, '"': 0x22, "\\": 0x5C,
537+
}
538+
539+
540+
def _git_unquote_path(recorded: str) -> str | None:
541+
"""The on-disk name for a path as ``git diff --name-only`` printed it.
542+
543+
Without ``-z`` git C-quotes any path holding a non-ASCII, control, double
544+
quote or backslash byte: the name is wrapped in double quotes, each such
545+
byte becomes one of ``_GIT_C_QUOTE_ESCAPES`` or a 3-digit octal escape,
546+
and the escapes spell raw bytes, not text. An unquoted name is returned
547+
unchanged. Returns None when a quoted name is not valid quoting
548+
(unterminated, a bare inner quote, an unknown or truncated escape, an
549+
out-of-range octal, or an empty name), so the caller refuses rather than
550+
guesses.
551+
"""
552+
if not recorded.startswith('"'):
553+
return recorded
554+
if len(recorded) < 3 or not recorded.endswith('"'):
555+
return None
556+
body = recorded[1:-1]
557+
raw = bytearray()
558+
i = 0
559+
while i < len(body):
560+
ch = body[i]
561+
if ch == '"':
562+
return None
563+
if ch != "\\":
564+
raw += ch.encode("utf-8", "surrogateescape")
565+
i += 1
566+
continue
567+
escape = body[i + 1:i + 2]
568+
if escape in _GIT_C_QUOTE_ESCAPES:
569+
raw.append(_GIT_C_QUOTE_ESCAPES[escape])
570+
i += 2
571+
continue
572+
octal = body[i + 1:i + 4]
573+
if (
574+
len(octal) == 3
575+
and all(c in "01234567" for c in octal)
576+
and int(octal, 8) <= 0xFF
577+
):
578+
raw.append(int(octal, 8))
579+
i += 4
580+
continue
581+
return None
582+
return os.fsdecode(bytes(raw))
583+
584+
532585
def _diff_changed_files(cr_dir: str | Path) -> list[str]:
533-
"""Repo-relative diff files that must exist at the reviewed tip.
586+
"""On-disk names of the repo-relative diff files that must exist at the tip.
534587

535588
Removals are excluded — they are absent from the head by definition.
536589
Empty when ``diff_data.json`` has not been written yet (stages that run
537590
before ``parse-diff``), which makes the containment check additive rather
538591
than a precondition on stage order.
592+
593+
``parse-diff`` records names as ``git diff --name-only`` prints them, so a
594+
path holding a non-ASCII, control, double quote or backslash byte is
595+
stored C-quoted. It is decoded to the name on disk, never skipped: a
596+
skipped entry would drop that file from both the containment and the drift
597+
check, so an edit to it after resolution would go unnoticed. An entry that
598+
looks quoted but does not decode raises ``ReviewRootError``.
539599
"""
540600
data = _read_optional_json(Path(cr_dir) / "diff_data.json", None)
541601
if not isinstance(data, dict):
@@ -545,15 +605,22 @@ def _diff_changed_files(cr_dir: str | Path) -> list[str]:
545605
files = data.get("files_to_review")
546606
if not isinstance(files, list):
547607
return []
548-
return [
549-
f for f in files
550-
if isinstance(f, str) and f and statuses.get(f) != "removed"
551-
# git C-quotes a path containing non-ASCII, control, quote or
552-
# backslash bytes, so the recorded string is not the name on disk and
553-
# its absence proves nothing. An entry we cannot resolve must not
554-
# produce a confident refusal.
555-
and not f.startswith('"')
556-
]
608+
changed: list[str] = []
609+
for recorded in files:
610+
if not isinstance(recorded, str) or not recorded:
611+
continue
612+
# file_statuses is keyed by the same quoted string parse-diff recorded.
613+
if statuses.get(recorded) == "removed":
614+
continue
615+
name = _git_unquote_path(recorded)
616+
if name is None:
617+
raise ReviewRootError(
618+
f"diff_data.json lists {recorded!r}, which is not valid git "
619+
"path quoting, so it cannot be resolved to a file on disk and "
620+
"the root cannot be proven to hold it.",
621+
)
622+
changed.append(name)
623+
return changed
557624

558625

559626
def _require_review_root(cr_dir: str | Path, scope_meta: object) -> str:

plugins/code-review/tools/python/test_code_review_helpers.py

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9986,23 +9986,90 @@ def test_root_proven_by_nothing_errors(self, tmp_path: Path) -> None:
99869986

99879987
assert rc == 3
99889988

9989+
@staticmethod
9990+
def _seed_root_with_git_quoted_changed_file(
9991+
tmp_path: Path,
9992+
) -> tuple[Path, Path, str]:
9993+
"""A live checkout whose diff changes a file git records C-quoted.
9994+
9995+
The recorded name comes from git itself (``ls-files`` quotes paths
9996+
exactly as ``diff --name-only`` does), so diff_data.json carries the
9997+
string parse-diff would store rather than a hand-written escape.
9998+
"""
9999+
cr_dir = tmp_path / "cr"
10000+
cr_dir.mkdir(parents=True)
10001+
root = Path(_make_review_root(
10002+
tmp_path / "checkout", {"src/café.py": "v1\n"},
10003+
))
10004+
recorded = next(
10005+
line for line in git_fixture(root, "ls-files").splitlines()
10006+
if "caf" in line
10007+
)
10008+
assert recorded.startswith('"'), f"git did not quote {recorded!r}"
10009+
(cr_dir / "scope.json").write_text(json.dumps({
10010+
"review_root": str(root),
10011+
"review_root_sha": git_fixture(root, "rev-parse", "HEAD").strip(),
10012+
}))
10013+
(cr_dir / "diff_data.json").write_text(json.dumps({
10014+
"files_to_review": [recorded],
10015+
"file_statuses": {recorded: "added"},
10016+
}))
10017+
return cr_dir, root, recorded
10018+
998910019
def test_git_quoted_path_does_not_refuse_a_correct_root(
999010020
self, tmp_path: Path,
999110021
) -> None:
999210022
# git C-quotes a non-ASCII path in `diff --name-only`, so the recorded
9993-
# string is not the name on disk. An entry we cannot resolve must not
9994-
# abort a review against a perfectly correct root.
9995-
cr_dir = tmp_path / "cr"
9996-
_seed_scope_review_root(cr_dir, tmp_path / "checkout")
10023+
# string is not the name on disk. It is decoded, and a root holding the
10024+
# decoded file unchanged must not be refused.
10025+
cr_dir, root, _recorded = self._seed_root_with_git_quoted_changed_file(
10026+
tmp_path,
10027+
)
10028+
10029+
finding = _make_validated_finding("bha_1", severity="HIGH")
10030+
rc, _manifest = _run_verify_prepare(tmp_path, [finding], cr_dir=cr_dir)
10031+
10032+
assert rc == 0
10033+
input_data = json.loads(
10034+
(cr_dir / "verifier_inputs" / "bha_1.json").read_text(),
10035+
)
10036+
assert input_data["review_root"] == str(root)
10037+
10038+
def test_git_quoted_changed_file_edited_after_resolution_errors(
10039+
self, tmp_path: Path,
10040+
) -> None:
10041+
# Skipping quoted entries dropped this file from both the containment
10042+
# and the drift check, so an edit to it after resolution went unseen.
10043+
cr_dir, root, _recorded = self._seed_root_with_git_quoted_changed_file(
10044+
tmp_path,
10045+
)
10046+
(root / "src" / "café.py").write_text("edited, never committed\n")
10047+
10048+
finding = _make_validated_finding("bha_1", severity="HIGH")
10049+
rc, _manifest = _run_verify_prepare(tmp_path, [finding], cr_dir=cr_dir)
10050+
10051+
assert rc == 3
10052+
assert not (cr_dir / "verifier_inputs" / "bha_1.json").exists()
10053+
10054+
def test_malformed_git_quoted_entry_errors(
10055+
self, tmp_path: Path, capsys: pytest.CaptureFixture[str],
10056+
) -> None:
10057+
# An entry that looks quoted but does not decode cannot be resolved on
10058+
# disk, so it refuses rather than being skipped or guessed at.
10059+
cr_dir, _root = self._seed_live_root_with_changed_file(tmp_path)
999710060
(cr_dir / "diff_data.json").write_text(json.dumps({
9998-
"files_to_review": ['"src/caf\\303\\251.py"'],
9999-
"file_statuses": {'"src/caf\\303\\251.py"': "added"},
10061+
"files_to_review": ["src/a.py", '"src/unterminated.py'],
10062+
"file_statuses": {
10063+
"src/a.py": "modified", '"src/unterminated.py': "added",
10064+
},
1000010065
}))
1000110066

1000210067
finding = _make_validated_finding("bha_1", severity="HIGH")
1000310068
rc, _manifest = _run_verify_prepare(tmp_path, [finding], cr_dir=cr_dir)
1000410069

10005-
assert rc == 0
10070+
assert rc == 3
10071+
assert not (cr_dir / "verifier_inputs" / "bha_1.json").exists()
10072+
assert "not valid git path quoting" in capsys.readouterr().err
1000610073

1000710074

1000810075
class TestFooterWorktreeTeardown:

0 commit comments

Comments
 (0)