diff --git a/packages/cli/src/utils/appHistory.test.ts b/packages/cli/src/utils/appHistory.test.ts index d24294035e3..843216bada1 100644 --- a/packages/cli/src/utils/appHistory.test.ts +++ b/packages/cli/src/utils/appHistory.test.ts @@ -23,6 +23,22 @@ import { } from "./appHistory.js"; import { readRecord, writeRecord } from "./projectRecords.js"; +const { afterDirectoryRead } = vi.hoisted(() => ({ + afterDirectoryRead: vi.fn<(path: unknown) => void>(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + readdirSync: vi.fn(original.readdirSync).mockImplementation((path, options) => { + const entries = original.readdirSync(path, options); + afterDirectoryRead(path); + return entries; + }), + }; +}); + const history = (dir: string) => join(dir, ".hyperframes", APP_HISTORY); function project(lines: string[] = []): string { @@ -128,6 +144,33 @@ describe("app history", () => { expect(filesChangedSince(dir, 5000)).toEqual(["compositions/a.html"]); }); + it("keeps listing changed files when one disappears after the directory read", () => { + const dir = project(); + const removed = join(dir, "removed.html"); + writeFileSync(removed, "removed"); + writeFileSync(join(dir, "index.html"), "kept"); + mkdirSync(join(dir, "compositions")); + writeFileSync(join(dir, "compositions", "scene.html"), "kept"); + afterDirectoryRead.mockImplementationOnce(() => { + rmSync(removed); + }); + + expect(filesChangedSince(dir, 0)).toEqual(["compositions/scene.html", "index.html"]); + }); + + it("skips a file replaced by a directory after the directory read", () => { + const dir = project(); + const replaced = join(dir, "replaced.html"); + writeFileSync(replaced, "replaced"); + writeFileSync(join(dir, "index.html"), "kept"); + afterDirectoryRead.mockImplementationOnce(() => { + rmSync(replaced); + mkdirSync(replaced); + }); + + expect(filesChangedSince(dir, 0)).toEqual(["index.html"]); + }); + it("names unseen turns at the end of a command run in the project or on it", () => { const dir = project([turn("2026-01-01T10:00:00Z", "a"), turn("2026-01-01T11:00:00Z", "b")]); expect(appHistoryNotice(dir, []), "never handed over from here").toBeNull(); diff --git a/packages/cli/src/utils/appHistory.ts b/packages/cli/src/utils/appHistory.ts index ea9afd2d23a..897453436d9 100644 --- a/packages/cli/src/utils/appHistory.ts +++ b/packages/cli/src/utils/appHistory.ts @@ -96,6 +96,15 @@ export function unseenTurns(dir: string, since: number, now = Date.now()): AppTu ); } +function isChangedFile(path: string, since: number): boolean { + try { + const stats = lstatSync(path); + return stats.isFile() && stats.mtimeMs > since; + } catch { + return false; + } +} + /** Project files changed after `since`, by the app or by hand; hidden folders and outputs are not the video. */ export function filesChangedSince(dir: string, since: number): string[] { const changed: string[] = []; @@ -112,7 +121,7 @@ export function filesChangedSince(dir: string, since: number): string[] { if (entry.name.startsWith(".") || SKIPPED_DIRS.has(entry.name)) continue; const path = join(folder, entry.name); if (entry.isDirectory()) walk(path); - else if (entry.isFile() && lstatSync(path).mtimeMs > since) + else if (entry.isFile() && isChangedFile(path, since)) changed.push(relative(dir, path).split(sep).join("/")); } };