Preserve CLI file creation times across in-place edits

This commit is contained in:
vorotamoroz committed 2026-10-15 11:07:43 +00:00
1 parent 1ce753a302
commit 0bcd9af6a5
9 files changed
+186 -14

No files matched your search

@@ -13,7 +13,7 @@ import type { FileEventItemSentinel } from "@vrtmrz/livesync-commonlib/compat/ma
import type { NodeFile, NodeFolder } from "@/apps/cli/adapters/NodeTypes";
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
import { fsPromises as fs, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
import { fsPromises as fs, getNodeFileCreationTime, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
/**
@@ -123,7 +123,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
stat: {
// Floor to integer milliseconds; Linux fs.Stats.*Ms carry sub-millisecond
// precision, and timestamps are stored as integer ms everywhere else.
ctime: Math.floor(stats?.ctimeMs ?? Date.now()),
ctime: stats ? getNodeFileCreationTime(stats) : Date.now(),
mtime: Math.floor(stats?.mtimeMs ?? Date.now()),
size: stats?.size ?? 0,
type: "file",
@@ -105,6 +105,59 @@ describe("CLIStorageEventManagerAdapter", () => {
expect(created.stat?.mtime).toBe(1778511180999);
});
it("keeps creation time across add and change events when inode change time advances", async () => {
const adapter = new CLIStorageEventManagerAdapter("/vault", undefined, true);
const handlers = makeHandlers();
await adapter.watch.beginWatch(handlers);
const add = mockWatcher.on.mock.calls.find(([event]) => event === "add")![1];
const change = mockWatcher.on.mock.calls.find(([event]) => event === "change")![1];
add("/vault/note.md", { birthtimeMs: 1000.9, ctimeMs: 2000.8, mtimeMs: 2000.7, size: 7 });
change("/vault/note.md", { birthtimeMs: 1000.9, ctimeMs: 3000.8, mtimeMs: 3000.7, size: 8 });
expect(handlers.onCreate).toHaveBeenCalledWith(
expect.objectContaining({
stat: { ctime: 1000, mtime: 2000, size: 7, type: "file" },
})
);
expect(handlers.onChange).toHaveBeenCalledWith(
expect.objectContaining({
stat: { ctime: 1000, mtime: 3000, size: 8, type: "file" },
})
);
});
it("falls back to change time when the filesystem supplies zero birth time", async () => {
const adapter = new CLIStorageEventManagerAdapter("/vault", undefined, true);
const handlers = makeHandlers();
await adapter.watch.beginWatch(handlers);
const change = mockWatcher.on.mock.calls.find(([event]) => event === "change")![1];
change("/vault/note.md", { birthtimeMs: 0, ctimeMs: 2000.8, mtimeMs: 3000.7, size: 8 });
expect(handlers.onChange).toHaveBeenCalledWith(
expect.objectContaining({
stat: { ctime: 2000, mtime: 3000, size: 8, type: "file" },
})
);
});
it("uses the current time for a deletion without stats", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(4000);
try {
const adapter = new CLIStorageEventManagerAdapter("/vault", undefined, true);
const handlers = makeHandlers();
await adapter.watch.beginWatch(handlers);
const unlink = mockWatcher.on.mock.calls.find(([event]) => event === "unlink")![1];
unlink("/vault/note.md");
expect(handlers.onDelete).toHaveBeenCalledWith(
expect.objectContaining({
stat: { ctime: 4000, mtime: 4000, size: 0, type: "file" },
})
);
} finally {
now.mockRestore();
}
});
it("close() calls watcher.close()", async () => {
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
const handlers = makeHandlers();