Skip to content

Commit 633815c

Browse files
authored
fix(loaders): normalize mnemonic pager prefixes (#267)
1 parent a00e36b commit 633815c

3 files changed

Lines changed: 272 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ All notable user-visible changes to Hunk are documented in this file.
1010

1111
### Fixed
1212

13+
- Fixed `hunk pager` parsing for Git diffs emitted with `diff.mnemonicPrefix=true` so file paths do not keep `i/`, `w/`, `c/`, `1/`, or `2/` side prefixes.
1314
- Fixed large tracked and untracked file handling so very large diffs render as skipped placeholders instead of slowing startup or overflowing the JavaScript call stack.
1415

1516
## [0.11.0] - 2026-05-09

src/core/loaders.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,88 @@ describe("loadAppBootstrap", () => {
10821082
expect(bootstrap.changeset.files[0]?.stats.additions).toBe(1);
10831083
});
10841084

1085+
test("loads patch text emitted with diff.mnemonicPrefix=true (e.g. from `hunk pager` stdin)", async () => {
1086+
const dir = createTempRepo("hunk-patch-mnemonic-prefix-");
1087+
1088+
writeFileSync(join(dir, "example.ts"), "export const value = 1;\n");
1089+
git(dir, "add", ".");
1090+
git(dir, "commit", "-m", "initial");
1091+
1092+
writeFileSync(join(dir, "example.ts"), "export const value = 2;\n");
1093+
const patchText = git(dir, "-c", "diff.mnemonicPrefix=true", "diff", "--", "example.ts");
1094+
1095+
expect(patchText).toContain("diff --git i/example.ts w/example.ts");
1096+
1097+
const bootstrap = await loadAppBootstrap({
1098+
kind: "patch",
1099+
text: patchText,
1100+
options: { mode: "auto" },
1101+
});
1102+
1103+
expect(bootstrap.changeset.files).toHaveLength(1);
1104+
expect(bootstrap.changeset.files[0]).toMatchObject({
1105+
path: "example.ts",
1106+
metadata: { name: "example.ts", type: "change" },
1107+
});
1108+
expect(bootstrap.changeset.files[0]?.stats).toEqual({ additions: 1, deletions: 1 });
1109+
});
1110+
1111+
test("loads renamed patch text emitted with diff.mnemonicPrefix=true", async () => {
1112+
const dir = createTempRepo("hunk-patch-mnemonic-rename-");
1113+
1114+
writeFileSync(join(dir, "old.ts"), "export const value = 1;\n");
1115+
git(dir, "add", ".");
1116+
git(dir, "commit", "-m", "initial");
1117+
1118+
git(dir, "mv", "old.ts", "new.ts");
1119+
const patchText = git(dir, "-c", "diff.mnemonicPrefix=true", "diff", "--cached");
1120+
1121+
expect(patchText).toContain("diff --git c/old.ts i/new.ts");
1122+
1123+
const bootstrap = await loadAppBootstrap({
1124+
kind: "patch",
1125+
text: patchText,
1126+
options: { mode: "auto" },
1127+
});
1128+
1129+
expect(bootstrap.changeset.files).toHaveLength(1);
1130+
expect(bootstrap.changeset.files[0]).toMatchObject({
1131+
path: "new.ts",
1132+
previousPath: "old.ts",
1133+
metadata: { type: "rename-pure" },
1134+
});
1135+
expect(bootstrap.changeset.files[0]?.patch).toContain("diff --git a/old.ts b/new.ts");
1136+
});
1137+
1138+
test("does not strip real directories that look like mnemonic prefixes in noprefix renames", async () => {
1139+
const dir = createTempRepo("hunk-patch-noprefix-mnemonic-dir-");
1140+
1141+
mkdirSync(join(dir, "c"));
1142+
writeFileSync(join(dir, "c/foo.ts"), "export const value = 1;\n");
1143+
git(dir, "add", ".");
1144+
git(dir, "commit", "-m", "initial");
1145+
1146+
mkdirSync(join(dir, "w"));
1147+
git(dir, "mv", "c/foo.ts", "w/bar.ts");
1148+
const patchText = git(dir, "-c", "diff.noprefix=true", "diff", "--cached");
1149+
1150+
expect(patchText).toContain("diff --git c/foo.ts w/bar.ts");
1151+
1152+
const bootstrap = await loadAppBootstrap({
1153+
kind: "patch",
1154+
text: patchText,
1155+
options: { mode: "auto" },
1156+
});
1157+
1158+
expect(bootstrap.changeset.files).toHaveLength(1);
1159+
expect(bootstrap.changeset.files[0]).toMatchObject({
1160+
path: "w/bar.ts",
1161+
previousPath: "c/foo.ts",
1162+
metadata: { type: "rename-pure" },
1163+
});
1164+
expect(bootstrap.changeset.files[0]?.patch).toContain("diff --git a/c/foo.ts b/w/bar.ts");
1165+
});
1166+
10851167
test("loads noprefix rename patches by recovering the rename pair from the headers", async () => {
10861168
const bootstrap = await loadAppBootstrap({
10871169
kind: "patch",

src/core/loaders.ts

Lines changed: 189 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -267,87 +267,237 @@ function buildDiffFile(
267267
* SQL/Lua/Haskell comment, which becomes `--- foo` on disk) is not mistaken for a file
268268
* header inside the hunk body.
269269
*/
270+
type GitHeaderRewriteMode = "add" | "strip";
271+
270272
function normalizeGitPatchPrefixes(patchText: string) {
271273
if (!patchText.includes("diff --git ")) {
272274
return patchText;
273275
}
274276

275-
let blockNeedsPrefix = false;
277+
const lines = patchText.split("\n");
278+
const normalizedLines: string[] = [];
279+
let blockLines: string[] = [];
276280

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-
}
281+
const flushBlock = () => {
282+
if (blockLines.length === 0) {
283+
return;
284+
}
285285

286-
if (blockNeedsPrefix && line.startsWith("--- ")) {
287-
return rewriteUnifiedFileLine(line, "--- ", "a/");
288-
}
286+
for (const line of rewriteGitPatchBlock(blockLines)) {
287+
normalizedLines.push(line);
288+
}
289+
blockLines = [];
290+
};
289291

290-
if (blockNeedsPrefix && line.startsWith("+++ ")) {
291-
blockNeedsPrefix = false;
292-
return rewriteUnifiedFileLine(line, "+++ ", "b/");
293-
}
292+
for (const line of lines) {
293+
if (line.startsWith("diff --git ")) {
294+
flushBlock();
295+
blockLines.push(line);
296+
continue;
297+
}
294298

295-
return line;
296-
})
297-
.join("\n");
299+
if (blockLines.length > 0) {
300+
blockLines.push(line);
301+
} else {
302+
normalizedLines.push(line);
303+
}
304+
}
305+
306+
flushBlock();
307+
return normalizedLines.join("\n");
298308
}
299309

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 } {
310+
/** Rewrite one `diff --git` block, keeping file-header rewrites out of hunk bodies. */
311+
function rewriteGitPatchBlock(blockLines: string[]) {
312+
const firstLine = blockLines[0];
313+
if (!firstLine?.startsWith("diff --git ")) {
314+
return blockLines;
315+
}
316+
317+
const result = rewriteGitDiffHeader(firstLine, blockLines);
318+
let blockRewriteMode = result.rewriteMode;
319+
320+
const rewrittenLines = [result.line];
321+
322+
for (const line of blockLines.slice(1)) {
323+
if (blockRewriteMode && line.startsWith("--- ")) {
324+
rewrittenLines.push(rewriteUnifiedFileLine(line, "--- ", "a/", blockRewriteMode));
325+
continue;
326+
}
327+
328+
if (blockRewriteMode && line.startsWith("+++ ")) {
329+
const rewriteMode = blockRewriteMode;
330+
blockRewriteMode = null;
331+
rewrittenLines.push(rewriteUnifiedFileLine(line, "+++ ", "b/", rewriteMode));
332+
continue;
333+
}
334+
335+
rewrittenLines.push(line);
336+
}
337+
338+
return rewrittenLines;
339+
}
340+
341+
/** Detect prefixed/noprefix `diff --git` lines and rewrite them into Pierre's `a/X b/Y` form. */
342+
function rewriteGitDiffHeader(
343+
line: string,
344+
blockLines: string[],
345+
): {
346+
line: string;
347+
rewriteMode: GitHeaderRewriteMode | null;
348+
} {
302349
const rest = line.slice("diff --git ".length).trimEnd();
303350

304351
const quotedMatch = rest.match(/^"((?:\\.|[^"\\])*)" "((?:\\.|[^"\\])*)"$/);
305352
if (quotedMatch) {
306353
const [, oldPath = "", newPath = ""] = quotedMatch;
354+
const pair = canonicalizeGitPathPair(oldPath, newPath, blockLines);
307355
// Pierre's git header parser does not currently handle the quoted `"a/..." "b/..."`
308356
// 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-
};
357+
return { line: `diff --git ${pair.oldPath} ${pair.newPath}`, rewriteMode: pair.rewriteMode };
313358
}
314359

315360
const tokens = rest.split(" ");
316361

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-
}
321362
if (tokens.length >= 2 && tokens.length % 2 === 0) {
322363
const half = tokens.length / 2;
323364
const firstHalf = tokens.slice(0, half).join(" ");
324365
const secondHalf = tokens.slice(half).join(" ");
325-
if (firstHalf.startsWith("a/") && secondHalf.startsWith("b/")) {
326-
return { line, changed: false };
366+
const knownPair = canonicalizeKnownGitPathPair(firstHalf, secondHalf, blockLines);
367+
368+
if (knownPair?.changed) {
369+
return {
370+
line: `diff --git ${knownPair.oldPath} ${knownPair.newPath}`,
371+
rewriteMode: knownPair.rewriteMode,
372+
};
373+
}
374+
375+
// Already prefixed: `a/X b/Y` (covers single-token and equally split multi-token paths).
376+
if (knownPair?.isCanonical) {
377+
return { line, rewriteMode: null };
327378
}
379+
328380
// Non-rename noprefix: identical halves regardless of whether the path contains spaces.
329381
if (firstHalf === secondHalf && firstHalf.length > 0) {
330-
return { line: `diff --git a/${firstHalf} b/${secondHalf}`, changed: true };
382+
return { line: `diff --git a/${firstHalf} b/${secondHalf}`, rewriteMode: "add" };
331383
}
332384
}
333385

334386
// Two-token rename without prefix and without spaces in either path.
335387
if (tokens.length === 2 && tokens[0] && tokens[1]) {
336-
return { line: `diff --git a/${tokens[0]} b/${tokens[1]}`, changed: true };
388+
return { line: `diff --git a/${tokens[0]} b/${tokens[1]}`, rewriteMode: "add" };
337389
}
338390

339391
// Genuinely ambiguous (rename with spaces and no quoting). Leave untouched and let the
340392
// parser surface the existing failure rather than guess at the path split.
341-
return { line, changed: false };
393+
return { line, rewriteMode: null };
394+
}
395+
396+
const GIT_MNEMONIC_PREFIXES = new Set(["c", "i", "o", "w", "1", "2"]);
397+
398+
/** Return one Git mnemonic side prefix from a path, if present. */
399+
function splitGitMnemonicPrefix(path: string) {
400+
const [prefix, ...rest] = path.split("/");
401+
if (!prefix || rest.length === 0 || !GIT_MNEMONIC_PREFIXES.has(prefix)) {
402+
return null;
403+
}
404+
405+
return { prefix, path: rest.join("/") };
406+
}
407+
408+
/** Remove Git's outer quotes from one path-like metadata value. */
409+
function stripGitPathQuotes(path: string) {
410+
return path.match(/^"((?:\\.|[^"\\])*)"$/)?.[1] ?? path;
411+
}
412+
413+
/** Return rename metadata, which Git writes without mnemonic side prefixes. */
414+
function findRenameMetadata(blockLines: string[]) {
415+
const oldPath = blockLines.find((line) => line.startsWith("rename from "));
416+
const newPath = blockLines.find((line) => line.startsWith("rename to "));
417+
418+
if (!oldPath || !newPath) {
419+
return null;
420+
}
421+
422+
return {
423+
oldPath: stripGitPathQuotes(oldPath.slice("rename from ".length)),
424+
newPath: stripGitPathQuotes(newPath.slice("rename to ".length)),
425+
};
342426
}
343427

344428
/** Return a path with the expected Git side prefix while avoiding double-prefixing. */
345429
function withGitPrefix(path: string, prefix: "a/" | "b/") {
346430
return path.startsWith(prefix) ? path : `${prefix}${path}`;
347431
}
348432

433+
/** Decide whether a mnemonic-looking path pair is real mnemonic output or a noprefix rename. */
434+
function shouldStripMnemonicPair(oldPath: string, newPath: string, blockLines: string[]) {
435+
const oldMnemonic = splitGitMnemonicPrefix(oldPath);
436+
const newMnemonic = splitGitMnemonicPrefix(newPath);
437+
438+
if (!oldMnemonic || !newMnemonic || oldMnemonic.prefix === newMnemonic.prefix) {
439+
return null;
440+
}
441+
442+
const rename = findRenameMetadata(blockLines);
443+
if (!rename) {
444+
return true;
445+
}
446+
447+
if (rename.oldPath === oldPath && rename.newPath === newPath) {
448+
return false;
449+
}
450+
451+
if (rename.oldPath === oldMnemonic.path && rename.newPath === newMnemonic.path) {
452+
return true;
453+
}
454+
455+
return true;
456+
}
457+
458+
/** Convert already-prefixed or mnemonic-prefixed path pairs into Pierre's canonical shape. */
459+
function canonicalizeKnownGitPathPair(oldPath: string, newPath: string, blockLines: string[]) {
460+
const oldMnemonic = splitGitMnemonicPrefix(oldPath);
461+
const newMnemonic = splitGitMnemonicPrefix(newPath);
462+
const isCanonical = oldPath.startsWith("a/") && newPath.startsWith("b/");
463+
464+
if (isCanonical) {
465+
return { oldPath, newPath, rewriteMode: "add" as const, changed: false, isCanonical: true };
466+
}
467+
468+
if (oldMnemonic && newMnemonic && shouldStripMnemonicPair(oldPath, newPath, blockLines)) {
469+
return {
470+
oldPath: `a/${oldMnemonic.path}`,
471+
newPath: `b/${newMnemonic.path}`,
472+
rewriteMode: "strip" as const,
473+
changed: true,
474+
isCanonical: false,
475+
};
476+
}
477+
478+
return null;
479+
}
480+
481+
/** Convert one quoted `diff --git` path pair into Pierre's canonical side-prefix shape. */
482+
function canonicalizeGitPathPair(oldPath: string, newPath: string, blockLines: string[]) {
483+
return (
484+
canonicalizeKnownGitPathPair(oldPath, newPath, blockLines) ?? {
485+
oldPath: withGitPrefix(oldPath, "a/"),
486+
newPath: withGitPrefix(newPath, "b/"),
487+
rewriteMode: "add" as const,
488+
changed: true,
489+
isCanonical: false,
490+
}
491+
);
492+
}
493+
349494
/** 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/") {
495+
function rewriteUnifiedFileLine(
496+
line: string,
497+
marker: "--- " | "+++ ",
498+
prefix: "a/" | "b/",
499+
mode: GitHeaderRewriteMode,
500+
) {
351501
const path = line.slice(marker.length);
352502
const quotedPath = path.match(/^"((?:\\.|[^"\\])*)"(.*)$/);
353503
const pathName = quotedPath?.[1] ?? path;
@@ -357,7 +507,10 @@ function rewriteUnifiedFileLine(line: string, marker: "--- " | "+++ ", prefix: "
357507
return line;
358508
}
359509

360-
return `${marker}${withGitPrefix(pathName, prefix)}${suffix}`;
510+
const normalizedPath =
511+
mode === "strip" ? (splitGitMnemonicPrefix(pathName)?.path ?? pathName) : pathName;
512+
513+
return `${marker}${withGitPrefix(normalizedPath, prefix)}${suffix}`;
361514
}
362515

363516
/** Escape only the filename characters that break unified-diff header parsing. */

0 commit comments

Comments
 (0)