Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/cli/src/utils/appHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("node:fs")>();
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 {
Expand Down Expand Up @@ -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();
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/utils/appHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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("/"));
}
};
Expand Down
Loading