fix(diff): preserve nested checkouts and ambiguous patch paths

Only regular files and symlinks are readable untracked entries. Prefer patch metadata to ambiguous unquoted headers so paths containing a prefix delimiter keep their hunks.
This commit is contained in:
Mohamed Boudra
2026-11-06 07:12:50 +01:00
parent 92be3e9dff
commit cee05e3c6c
3 changed files with 92 additions and 9 deletions
@@ -374,3 +374,79 @@ test.skipIf(process.platform === "win32")(
}
},
);
test("untracked nested repositories do not hide tracked edits", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-nested-repository-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const { mkdir } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "a.txt"), "after\n");
await mkdir(join(cwd, "nested"));
git("-C", "nested", "init");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
try {
expect(comparison.files.map((file) => file.entry.path)).toEqual(["a.txt"]);
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "after",
});
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test.each(["worktree", "index", "rev"] as const)(
"%s preserves hunks for paths containing a patch prefix delimiter",
async (headKind) => {
const cwd = await mkdtemp(join(tmpdir(), "diff-path-delimiter-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const { mkdir } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await mkdir(join(cwd, "dir b"));
await writeFile(join(cwd, "dir b", "file.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git("rev-parse", "HEAD");
await writeFile(join(cwd, "dir b", "file.txt"), "after\n");
if (headKind !== "worktree") git("add", ".");
if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind };
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: base },
head,
highlight: false,
});
try {
expect(comparison.files[0].entry.path).toBe("dir b/file.txt");
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "after",
});
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
+1
View File
@@ -328,6 +328,7 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
);
for (const path of untracked.stdout.split("\0").filter(Boolean)) {
const value = await lstat(resolve(cwd, path), { bigint: true });
if (!value.isFile() && !value.isSymbolicLink()) continue;
const symlink = value.isSymbolicLink();
let binary = false;
let omitted = false;
@@ -70,11 +70,6 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri
function extractPathFromDiffHeader(lines: string[]): string {
const firstLine = lines[0] ?? "";
const prefixedPathMatch = firstLine.match(/^a\/(.+) b\/(.+)$/);
if (prefixedPathMatch) {
return prefixedPathMatch[2];
}
const quotedPathMatch =
firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/);
if (quotedPathMatch) {
@@ -86,10 +81,21 @@ function extractPathFromDiffHeader(lines: string[]): string {
return newPath;
}
const metadataPath =
extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- ");
if (metadataPath) {
return metadataPath;
// The ---/+++ records are unambiguous even when a path contains " b/".
const oldMetadata = extractPathFromMetadata(lines, "--- ");
const newMetadata = extractPathFromMetadata(lines, "+++ ");
const metadata = newMetadata ?? oldMetadata;
if (metadata) {
const prefixed =
(oldMetadata !== null &&
newMetadata !== null &&
usesDiffPathPrefixes(oldMetadata, newMetadata)) ||
firstLine === `a/${metadata.slice(2)} b/${metadata.slice(2)}`;
return prefixed ? metadata.slice(2) : metadata;
}
const prefixedPathMatch = firstLine.match(/^a\/(.+) b\/(.+)$/);
if (prefixedPathMatch) {
return prefixedPathMatch[2];
}
const pathMatch = firstLine.match(/^(\S+)\s+(\S+)$/);