Skip to content

Commit a00e36b

Browse files
mobenvinegar
andauthored
fix(loaders): restore a/b prefixes on noprefix patch input (#240)
Co-authored-by: Ben Vinegar <ben@benv.ca>
1 parent 8ed7c8d commit a00e36b

2 files changed

Lines changed: 231 additions & 2 deletions

File tree

src/core/loaders.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,4 +1058,125 @@ describe("loadAppBootstrap", () => {
10581058
expect(bootstrap.changeset.files[0]?.path.endsWith("after.ts")).toBe(true);
10591059
expect(bootstrap.changeset.files[0]?.stats.additions).toBeGreaterThan(0);
10601060
});
1061+
1062+
test("loads patch text emitted with diff.noprefix=true (e.g. from `hunk pager` stdin)", async () => {
1063+
const bootstrap = await loadAppBootstrap({
1064+
kind: "patch",
1065+
text: [
1066+
"diff --git src/example.ts src/example.ts",
1067+
"index 0000000..1111111 100644",
1068+
"--- src/example.ts",
1069+
"+++ src/example.ts",
1070+
"@@ -1,1 +1,2 @@",
1071+
" const value = 1;",
1072+
"+const added = 2;",
1073+
].join("\n"),
1074+
options: { mode: "auto" },
1075+
});
1076+
1077+
expect(bootstrap.changeset.files).toHaveLength(1);
1078+
expect(bootstrap.changeset.files[0]).toMatchObject({
1079+
path: "src/example.ts",
1080+
metadata: { name: "src/example.ts", type: "change" },
1081+
});
1082+
expect(bootstrap.changeset.files[0]?.stats.additions).toBe(1);
1083+
});
1084+
1085+
test("loads noprefix rename patches by recovering the rename pair from the headers", async () => {
1086+
const bootstrap = await loadAppBootstrap({
1087+
kind: "patch",
1088+
text: [
1089+
"diff --git old/path.ts new/path.ts",
1090+
"similarity index 100%",
1091+
"rename from old/path.ts",
1092+
"rename to new/path.ts",
1093+
].join("\n"),
1094+
options: { mode: "auto" },
1095+
});
1096+
1097+
expect(bootstrap.changeset.files).toHaveLength(1);
1098+
expect(bootstrap.changeset.files[0]).toMatchObject({
1099+
path: "new/path.ts",
1100+
previousPath: "old/path.ts",
1101+
metadata: { type: "rename-pure" },
1102+
});
1103+
});
1104+
1105+
test("loads quoted noprefix patch text emitted for escaped git paths", async () => {
1106+
const dir = createTempRepo("hunk-patch-quoted-noprefix-");
1107+
const fileName = "src\tfile.txt";
1108+
1109+
writeFileSync(join(dir, fileName), "one\n");
1110+
git(dir, "add", ".");
1111+
git(dir, "commit", "-m", "initial");
1112+
1113+
writeFileSync(join(dir, fileName), "two\n");
1114+
const patchText = git(dir, "-c", "diff.noprefix=true", "diff", "--", fileName);
1115+
1116+
expect(patchText).toContain('diff --git "src\\tfile.txt" "src\\tfile.txt"');
1117+
1118+
const bootstrap = await loadAppBootstrap({
1119+
kind: "patch",
1120+
text: patchText,
1121+
options: { mode: "auto" },
1122+
});
1123+
1124+
expect(bootstrap.changeset.files).toHaveLength(1);
1125+
expect(bootstrap.changeset.files[0]).toMatchObject({
1126+
path: "src\\tfile.txt",
1127+
metadata: { name: "src\\tfile.txt", type: "change" },
1128+
});
1129+
expect(bootstrap.changeset.files[0]?.stats).toEqual({ additions: 1, deletions: 1 });
1130+
});
1131+
1132+
test("does not mangle a deleted SQL `-- comment` line in a noprefix patch", async () => {
1133+
// The original source line `-- drop table users;` (a SQL comment) is encoded in a unified
1134+
// diff deletion as `--- drop table users;` — three dashes (one for the deletion marker,
1135+
// two from the comment) and a space. That looks identical to a `--- a/path` file header
1136+
// on its own, so the noprefix prefix-restorer must stop rewriting `--- ` lines once the
1137+
// `+++ ` line of the current block has been emitted.
1138+
const bootstrap = await loadAppBootstrap({
1139+
kind: "patch",
1140+
text: [
1141+
"diff --git db/schema.sql db/schema.sql",
1142+
"index 0000000..1111111 100644",
1143+
"--- db/schema.sql",
1144+
"+++ db/schema.sql",
1145+
"@@ -1,3 +1,2 @@",
1146+
" CREATE TABLE users (id INT);",
1147+
"--- drop table users;",
1148+
" CREATE TABLE posts (id INT);",
1149+
].join("\n"),
1150+
options: { mode: "auto" },
1151+
});
1152+
1153+
expect(bootstrap.changeset.files).toHaveLength(1);
1154+
const file = bootstrap.changeset.files[0]!;
1155+
expect(file.path).toBe("db/schema.sql");
1156+
expect(file.stats.deletions).toBe(1);
1157+
// The deleted content must round-trip as `-- drop table users;` (the original SQL line),
1158+
// not as `-- a/drop table users;` (the corruption produced when the rewriter is still
1159+
// active inside the hunk body).
1160+
expect(file.metadata.deletionLines).toContain("-- drop table users;\n");
1161+
expect(file.metadata.deletionLines.some((line) => line.includes("a/"))).toBe(false);
1162+
});
1163+
1164+
test("leaves correctly prefixed patches untouched even when paths sit inside an `a/` directory", async () => {
1165+
const bootstrap = await loadAppBootstrap({
1166+
kind: "patch",
1167+
text: [
1168+
"diff --git a/a/inner.ts b/a/inner.ts",
1169+
"index 0000000..1111111 100644",
1170+
"--- a/a/inner.ts",
1171+
"+++ b/a/inner.ts",
1172+
"@@ -1,1 +1,2 @@",
1173+
" const x = 1;",
1174+
"+const y = 2;",
1175+
].join("\n"),
1176+
options: { mode: "auto" },
1177+
});
1178+
1179+
expect(bootstrap.changeset.files).toHaveLength(1);
1180+
expect(bootstrap.changeset.files[0]?.path).toBe("a/inner.ts");
1181+
});
10611182
});

src/core/loaders.ts

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,114 @@ function buildDiffFile(
252252
};
253253
}
254254

255+
/**
256+
* Re-add Git's `a/` and `b/` path prefixes to patch headers when stdin came from a
257+
* `git diff` that was emitted with `diff.noprefix=true` (or otherwise stripped prefixes).
258+
*
259+
* `@pierre/diffs` requires `a/` and `b/` on `diff --git`, `---`, and `+++` lines and throws
260+
* a `TypeError` on the first noprefix header, which leaves the review with zero files. The
261+
* git-backed paths force `diff.noprefix=false` when they invoke git internally; this helper
262+
* covers the patch path (`hunk patch`, `hunk pager`) where the input was produced by an
263+
* outer `git` process we do not control.
264+
*
265+
* The rewrite is scoped to header lines only: once the `+++ ` line has been emitted for a
266+
* block we clear the flag so a deleted line whose content starts with `-- ` (e.g. a removed
267+
* SQL/Lua/Haskell comment, which becomes `--- foo` on disk) is not mistaken for a file
268+
* header inside the hunk body.
269+
*/
270+
function normalizeGitPatchPrefixes(patchText: string) {
271+
if (!patchText.includes("diff --git ")) {
272+
return patchText;
273+
}
274+
275+
let blockNeedsPrefix = false;
276+
277+
return patchText
278+
.split("\n")
279+
.map((line) => {
280+
if (line.startsWith("diff --git ")) {
281+
const result = rewriteGitDiffHeader(line);
282+
blockNeedsPrefix = result.changed;
283+
return result.line;
284+
}
285+
286+
if (blockNeedsPrefix && line.startsWith("--- ")) {
287+
return rewriteUnifiedFileLine(line, "--- ", "a/");
288+
}
289+
290+
if (blockNeedsPrefix && line.startsWith("+++ ")) {
291+
blockNeedsPrefix = false;
292+
return rewriteUnifiedFileLine(line, "+++ ", "b/");
293+
}
294+
295+
return line;
296+
})
297+
.join("\n");
298+
}
299+
300+
/** Detect prefixed/noprefix `diff --git` lines and rewrite the noprefix form into `a/X b/Y`. */
301+
function rewriteGitDiffHeader(line: string): { line: string; changed: boolean } {
302+
const rest = line.slice("diff --git ".length).trimEnd();
303+
304+
const quotedMatch = rest.match(/^"((?:\\.|[^"\\])*)" "((?:\\.|[^"\\])*)"$/);
305+
if (quotedMatch) {
306+
const [, oldPath = "", newPath = ""] = quotedMatch;
307+
// Pierre's git header parser does not currently handle the quoted `"a/..." "b/..."`
308+
// form, so canonicalize quoted paths to the unquoted form even when prefixes exist.
309+
return {
310+
line: `diff --git ${withGitPrefix(oldPath, "a/")} ${withGitPrefix(newPath, "b/")}`,
311+
changed: true,
312+
};
313+
}
314+
315+
const tokens = rest.split(" ");
316+
317+
// Already prefixed: `a/X b/Y` (covers single-token and equally split multi-token paths).
318+
if (tokens.length === 2 && tokens[0]?.startsWith("a/") && tokens[1]?.startsWith("b/")) {
319+
return { line, changed: false };
320+
}
321+
if (tokens.length >= 2 && tokens.length % 2 === 0) {
322+
const half = tokens.length / 2;
323+
const firstHalf = tokens.slice(0, half).join(" ");
324+
const secondHalf = tokens.slice(half).join(" ");
325+
if (firstHalf.startsWith("a/") && secondHalf.startsWith("b/")) {
326+
return { line, changed: false };
327+
}
328+
// Non-rename noprefix: identical halves regardless of whether the path contains spaces.
329+
if (firstHalf === secondHalf && firstHalf.length > 0) {
330+
return { line: `diff --git a/${firstHalf} b/${secondHalf}`, changed: true };
331+
}
332+
}
333+
334+
// Two-token rename without prefix and without spaces in either path.
335+
if (tokens.length === 2 && tokens[0] && tokens[1]) {
336+
return { line: `diff --git a/${tokens[0]} b/${tokens[1]}`, changed: true };
337+
}
338+
339+
// Genuinely ambiguous (rename with spaces and no quoting). Leave untouched and let the
340+
// parser surface the existing failure rather than guess at the path split.
341+
return { line, changed: false };
342+
}
343+
344+
/** Return a path with the expected Git side prefix while avoiding double-prefixing. */
345+
function withGitPrefix(path: string, prefix: "a/" | "b/") {
346+
return path.startsWith(prefix) ? path : `${prefix}${path}`;
347+
}
348+
349+
/** Insert the canonical `a/` or `b/` prefix on a unified-diff header that is missing it. */
350+
function rewriteUnifiedFileLine(line: string, marker: "--- " | "+++ ", prefix: "a/" | "b/") {
351+
const path = line.slice(marker.length);
352+
const quotedPath = path.match(/^"((?:\\.|[^"\\])*)"(.*)$/);
353+
const pathName = quotedPath?.[1] ?? path;
354+
const suffix = quotedPath?.[2] ?? "";
355+
356+
if (pathName === "/dev/null" || pathName.startsWith("/dev/null\t")) {
357+
return line;
358+
}
359+
360+
return `${marker}${withGitPrefix(pathName, prefix)}${suffix}`;
361+
}
362+
255363
/** Escape only the filename characters that break unified-diff header parsing. */
256364
function escapeUntrackedPatchPath(path: string) {
257365
return path
@@ -557,8 +665,8 @@ function normalizePatchChangeset(
557665
sourceLabel: string,
558666
agentContext: AgentContext | null,
559667
): Changeset {
560-
const normalizedPatchText = stripGitLogMetadata(
561-
stripTerminalControl(patchText.replaceAll("\r\n", "\n")),
668+
const normalizedPatchText = normalizeGitPatchPrefixes(
669+
stripGitLogMetadata(stripTerminalControl(patchText.replaceAll("\r\n", "\n"))),
562670
);
563671

564672
let parsedPatches: ReturnType<typeof parsePatchFiles>;

0 commit comments

Comments
 (0)