From 04a7d1b3a7772f05c1d8d0343835e45b5333f8e2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 08:03:49 +0100 Subject: [PATCH] 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. --- packages/server/src/server/diff/git.ts | 10 ++- .../src/server/diff/subscriptions.test.ts | 69 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/diff/git.ts b/packages/server/src/server/diff/git.ts index f38bd53b35..da38902b25 100644 --- a/packages/server/src/server/diff/git.ts +++ b/packages/server/src/server/diff/git.ts @@ -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 { 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 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 }); + } + }, +);