fix(diff): invalidate timestamp-preserving file replacements

Size and modification time do not identify live file contents after a preserving copy. Include filesystem change time and file identity for tracked and untracked reuse, and verify live revisions agree with fresh reads.
This commit is contained in:
Mohamed Boudra
2026-11-06 08:03:49 +01:00
parent e044c788b5
commit 04a7d1b3a7
2 changed files with 77 additions and 2 deletions
+8 -2
View File
@@ -11,6 +11,7 @@ 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";
@@ -324,7 +325,7 @@ async function readWorktreeIdentity(
options: GitCommandOptions,
): Promise<string> {
const value = await lstat(resolve(options.cwd, path), { bigint: true });
let identity = `${value.size}:${value.mtimeNs}`;
let identity = fileStatIdentity(value);
if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) {
const submodule = await runGitCommand(
["status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
@@ -388,9 +389,14 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
deletions: 0,
body: !binary,
},
identity: `${value.size}:${value.mtimeNs}`,
identity: fileStatIdentity(value),
source: { kind: "untracked", size: Number(value.size) },
});
}
return files;
}
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}`;
}
@@ -416,3 +416,72 @@ test("submodule commits invalidate cached worktree gitlink bodies", async () =>
await rm(cwd, { recursive: true, force: true });
}
});
test.each([true, false])(
"tracked=%s timestamp-preserving replacements refresh cached bodies",
async (tracked) => {
const { lstat, utimes } = await import("node:fs/promises");
const cwd = await mkdtemp(join(tmpdir(), "diff-preserved-time-"));
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");
const timestamp = new Date("2000-01-01T00:00:00Z");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "README"), "base\n");
if (tracked) await writeFile(path, "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(path, "first\n");
await utimes(path, timestamp, timestamp);
const compare = {
cwd,
base: { kind: "rev" as const, rev: "HEAD" },
head: { kind: "worktree" as const },
};
const subscription = await streams.subscribe(compare, async (frame) => {
frames.push(frame);
});
await subscription.start();
expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null });
const before = await lstat(path, { bigint: true });
frames.length = 0;
await writeFile(path, "later\n");
await utimes(path, timestamp, timestamp);
const after = await lstat(path, { bigint: true });
expect(after.mtimeNs).toBe(before.mtimeNs);
expect(after.size).toBe(before.size);
expect(after.ctimeNs).not.toBe(before.ctimeNs);
streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
const files = frames.flatMap((frame) =>
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
);
expect(files.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines))).toContainEqual(
expect.objectContaining({ type: "add", content: "later" }),
);
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 replacement");
expect(files).toEqual([record.body]);
} finally {
await fresh.close();
}
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
},
);