Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 5bc78b951b fix(diff): align untracked patches and comparison modes
Read untracked bodies with --no-textconv so they follow the same patch
rules as tracked bodies. Include the comparison-side mode in worktree
identity so a base file type change cannot keep stale hunks. Use object
parameters for the identity inputs.
2026-11-13 11:12:24 +01:00
Mohamed Boudra e2f31e5db2 fix(diff): identify worktree files by content
Stat-only identity changes re-sent unchanged diff bodies after a touch or
git add. Worktree tracked and untracked files now share one content identity
built from the comparison-side blob, working-tree content, and Git
interpretation. Stat metadata no longer forces body delivery, while real
content changes still change the identity.

Closes #9563
2026-11-13 11:00:23 +01:00
3 changed files with 344 additions and 41 deletions
+113 -34
View File
@@ -12,7 +12,6 @@ import {
type CheckoutDiffCompare,
} from "../../utils/checkout-git.js";
import { lstat, readFile, readlink } from "node:fs/promises";
import type { BigIntStats } from "node:fs";
import { resolve } from "node:path";
import type { DiffEndpoint, DiffSubscribeRequest } from "@getpaseo/protocol/messages";
import type { DiffEntry, DiffBody } from "@getpaseo/protocol/binary-frames/index";
@@ -27,6 +26,16 @@ export interface ListedFile {
identity: string;
source: { kind: "tracked" } | { kind: "untracked"; size: number };
}
interface WorktreeInput {
/** Comparison-side mode, or null when the file is added/untracked on this side. */
oldMode: string | null;
/** Comparison-side blob, or null when the file is added/untracked on this side. */
oldBlob: string | null;
/** A regular file whose bytes readGitInputs blob-hashes. */
hashed: boolean;
/** Content Git cannot read as a regular file, such as a symlink target or submodule state. */
literal: string | null;
}
interface ResolvedComparison {
baseSha: string;
headSha: string;
@@ -195,20 +204,47 @@ async function readComparisonList(
...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}),
},
};
const { files, worktreeFiles, interpretationPaths } = await readTrackedFiles(
compare,
args,
options,
);
if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options)));
const tracked = await readTrackedFiles(compare, args, options);
const files = tracked.files;
const worktreeInputs = tracked.worktreeInputs;
if (compare.head.kind === "worktree") {
const untracked = await listUntrackedFiles(options);
files.push(...untracked.files);
for (const [path, input] of untracked.worktreeInputs) worktreeInputs.set(path, input);
}
files.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
for (const file of files) interpretationPaths.add(file.entry.path);
const gitInputs = await readGitInputs([...interpretationPaths].sort(), worktreeFiles, options);
for (const file of files)
for (const file of files) tracked.interpretationPaths.add(file.entry.path);
const hashedPaths = new Set(
[...worktreeInputs].filter(([, input]) => input.hashed).map(([path]) => path),
);
const literalContent = new Map(
[...worktreeInputs].flatMap(([path, input]) =>
input.literal === null ? [] : [[path, input.literal] as const],
),
);
const gitInputs = await readGitInputs({
paths: [...tracked.interpretationPaths].sort(),
hashedPaths,
options,
literalContent,
});
for (const file of files) {
if (compare.head.kind === "worktree") {
const input = worktreeInputs.get(file.entry.path);
if (!input) throw new Error(`Missing worktree identity for ${file.entry.path}`);
file.identity = JSON.stringify([
input.oldMode,
input.oldBlob,
gitInputs.identities.get(file.entry.path) ?? null,
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path) ?? null,
]);
continue;
}
file.identity += JSON.stringify([
gitInputs.identities.get(file.entry.path),
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path),
]);
}
const metrics = { highlightCalls: 0 };
const patches = new Patches(args, options, files);
const sources = new HighlightSources({
@@ -237,7 +273,15 @@ async function readComparisonList(
const result =
file.source.kind === "untracked"
? await runGitCommand(
["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path],
[
"diff",
"--no-index",
"--no-textconv",
...GIT_PATCH_FORMAT_ARGS,
"--",
"/dev/null",
file.entry.path,
],
{
...options,
acceptExitCodes: [0, 1],
@@ -293,7 +337,11 @@ async function readTrackedFiles(
compare: Compare,
args: string[],
options: GitCommandOptions,
): Promise<{ files: ListedFile[]; worktreeFiles: Set<string>; interpretationPaths: Set<string> }> {
): Promise<{
files: ListedFile[];
worktreeInputs: Map<string, WorktreeInput>;
interpretationPaths: Set<string>;
}> {
const [raw, stat] = await Promise.all([
runGitCommand([...args.filter((arg) => arg !== "-w"), "--raw", "--no-abbrev", "-z"], options),
runGitCommand([...args, "--numstat", "-z"], options),
@@ -303,7 +351,7 @@ async function readTrackedFiles(
const stats = parseStats(stat.stdout);
const fields = raw.stdout.split("\0");
const files: ListedFile[] = [];
const worktreeFiles = new Set<string>();
const worktreeInputs = new Map<string, WorktreeInput>();
const interpretationPaths = new Set<string>();
for (let index = 0; index < fields.length - 1; index++) {
const header = fields[index].split(" ");
@@ -325,10 +373,25 @@ async function readTrackedFiles(
else if (code === "D") change = "deleted";
else if (oldPath !== null) change = "renamed";
let identity = header.join(" ");
if (compare.head.kind === "worktree" && code !== "D") {
const worktree = await readWorktreeIdentity(path, header, options);
identity += `:${worktree.identity}`;
if (worktree.regular && !counts.binary) worktreeFiles.add(path);
if (compare.head.kind === "worktree") {
const input: WorktreeInput = {
oldMode: code === "A" ? null : header[0],
oldBlob: code === "A" ? null : absentBlob(header[2]),
hashed: false,
literal: null,
};
if (code !== "D") {
const worktree = await readWorktreeContent({
path,
header,
text: !counts.binary,
options,
});
input.hashed = worktree.hashed;
input.literal = worktree.literal;
}
worktreeInputs.set(path, input);
identity = "";
}
files.push({
@@ -345,24 +408,32 @@ async function readTrackedFiles(
source: { kind: "tracked" },
});
}
return { files, worktreeFiles, interpretationPaths };
return { files, worktreeInputs, interpretationPaths };
}
async function readWorktreeIdentity(
path: string,
header: string[],
options: GitCommandOptions,
): Promise<{ identity: string; regular: boolean }> {
const value = await lstat(resolve(options.cwd, path), { bigint: true });
let identity = fileStatIdentity(value);
async function readWorktreeContent({
path,
header,
text,
options,
}: {
path: string;
header: string[];
text: boolean;
options: GitCommandOptions;
}): Promise<{ hashed: boolean; literal: string | null }> {
const absolutePath = resolve(options.cwd, path);
const value = await lstat(absolutePath, { bigint: true });
if (value.isSymbolicLink())
return { hashed: false, literal: `symlink:${await readlink(absolutePath)}` };
if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) {
const submodule = await runGitCommand(
["status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
{ cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
{ cwd: absolutePath, envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
);
identity += `:${submodule.stdout}`;
return { hashed: false, literal: `submodule:${submodule.stdout}` };
}
return { identity, regular: value.isFile() };
return { hashed: value.isFile() && text, literal: null };
}
function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] {
@@ -385,9 +456,12 @@ async function readUntrackedText(
return readFile(path);
}
async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFile[]> {
async function listUntrackedFiles(
options: GitCommandOptions,
): Promise<{ files: ListedFile[]; worktreeInputs: Map<string, WorktreeInput> }> {
const cwd = options.cwd;
const files: ListedFile[] = [];
const worktreeInputs = new Map<string, WorktreeInput>();
const untracked = await runGitCommand(
["ls-files", "--others", "--exclude-standard", "-z"],
options,
@@ -408,6 +482,12 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
binary || content.length === 0
? 0
: content.toString("utf8").split("\n").length - Number(content.at(-1) === 10);
worktreeInputs.set(path, {
oldMode: null,
oldBlob: null,
hashed: !symlink && !binary && !omitted,
literal: symlink ? `symlink:${content.toString("utf8")}` : null,
});
files.push({
entry: {
path,
@@ -418,14 +498,13 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
deletions: 0,
body: !binary,
},
identity: fileStatIdentity(value),
identity: "",
source: { kind: "untracked", size: Number(value.size) },
});
}
return files;
return { files, worktreeInputs };
}
function fileStatIdentity(value: BigIntStats): string {
// Timestamp-preserving copies still change file status or replace the inode.
return `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`;
function absentBlob(value: string): string | null {
return /^0+$/.test(value) ? null : value;
}
+15 -7
View File
@@ -9,12 +9,20 @@ interface Interpretation {
// Reuse depends on Git's interpretation, not just filesystem metadata. Ask Git
// for effective attributes/config and filtered blob identities without writing objects.
export async function readGitInputs(
paths: string[],
worktreeFiles: Set<string>,
options: GitCommandOptions,
) {
const regular = paths.filter((path) => worktreeFiles.has(path));
interface ReadGitInputsOptions {
paths: string[];
hashedPaths: ReadonlySet<string>;
options: GitCommandOptions;
literalContent?: ReadonlyMap<string, string>;
}
export async function readGitInputs({
paths,
hashedPaths,
options,
literalContent = new Map(),
}: ReadGitInputsOptions) {
const regular = paths.filter((path) => hashedPaths.has(path));
const [interpretation, blobs] = await Promise.all([
readInterpretation(paths, options),
regular.length
@@ -34,7 +42,7 @@ export async function readGitInputs(
fingerprint([
interpretation.config,
interpretation.attributes.get(path) ?? [],
blobByPath.get(path) ?? null,
blobByPath.get(path) ?? literalContent.get(path) ?? null,
]),
]),
);
@@ -530,6 +530,222 @@ test("submodule commits invalidate cached worktree gitlink bodies", async () =>
}
});
test("stat-only changes reuse cached bodies and send no body frames", async () => {
const { utimes } = await import("node:fs/promises");
const cwd = await mkdtemp(join(tmpdir(), "diff-stat-only-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
const path = join(cwd, "file.txt");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "README"), "base\n");
await writeFile(path, "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(path, "changed\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
expect(
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
).toContainEqual(expect.objectContaining({ path: "file.txt" }));
frames.length = 0;
for (let index = 0; index < 3; index++) {
const ended = frames.filter((frame) => frame.opcode === DiffOpcode.End).length;
const timestamp = new Date(Date.now() + 1000 * (index + 1));
await utimes(path, timestamp, timestamp);
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.filter((frame) => frame.opcode === DiffOpcode.End).length, {
timeout: 5000,
})
.toBeGreaterThanOrEqual(ended + 1);
await new Promise((resolve) => setTimeout(resolve, 300));
}
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
const beginnings = frames.filter((frame) => frame.opcode === DiffOpcode.Begin);
expect(beginnings.length).toBeGreaterThanOrEqual(3);
for (const begin of beginnings) {
expect(begin.entries.find((entry) => entry.path === "file.txt")?.body).toBe(false);
}
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
});
test("staging unchanged worktree content reuses its cached body", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-staging-only-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "staged.ts"), "export const staged = true;\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
expect(
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
).toContainEqual(expect.objectContaining({ path: "staged.ts" }));
frames.length = 0;
git("add", "-A");
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
entries: [{ path: "staged.ts", change: "added", body: false }],
});
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
});
test.skipIf(process.platform === "win32")(
"untracked bodies use the same patch conversion as tracked files",
async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-textconv-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, ".gitattributes"), "*.dat diff=upper\n");
git("config", "diff.upper.textconv", "sed s/hello/HELLO/");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "x.dat"), "hello\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
const initial = frames
.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : []))
.find((file) => file.path === "x.dat");
expect(initial?.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "hello",
});
frames.length = 0;
git("add", "x.dat");
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"comparison-side file type changes refresh cached bodies",
async () => {
const { symlink } = await import("node:fs/promises");
const cwd = await mkdtemp(join(tmpdir(), "diff-base-mode-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
const path = join(cwd, "x");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(path, "target");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "regular");
git("branch", "compare");
await rm(path);
await symlink("target", path);
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "symlink");
const moved = git("rev-parse", "HEAD");
await rm(path);
await writeFile(path, "target\n");
const compare = {
cwd,
base: { kind: "rev" as const, rev: "refs/heads/compare" },
head: { kind: "worktree" as const },
};
const subscription = await streams.subscribe(compare, async (frame) => {
frames.push(frame);
});
await subscription.start();
expect(
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
).toContainEqual(expect.objectContaining({ path: "x" }));
frames.length = 0;
git("update-ref", "refs/heads/compare", moved);
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
const bodies = frames.flatMap((frame) =>
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
);
expect(bodies).toContainEqual(expect.objectContaining({ path: "x" }));
const { listComparison } = await import("./git.js");
const fresh = await listComparison(compare);
try {
const record = await fresh.read(fresh.files[0]);
if (record.kind !== "ready") throw new Error("Expected readable type change");
expect(bodies).toContainEqual(record.body);
} finally {
await fresh.close();
}
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
},
);
test.each([true, false])(
"tracked=%s timestamp-preserving replacements refresh cached bodies",
async (tracked) => {