mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-17 14:52:32 +00:00
Preserve CLI file creation times across in-place edits
This commit is contained in:
@@ -16,7 +16,7 @@ import type { CLICommandContext, CLIOptions } from "./types";
|
||||
import { toArrayBuffer, toDatabaseRelativePath } from "./utils";
|
||||
import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { fsPromises as fs, getNodeFileCreationTime, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
@@ -232,7 +232,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
|
||||
await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), {
|
||||
mtime: Math.floor(sourceStat.mtimeMs),
|
||||
ctime: Math.floor(sourceStat.ctimeMs),
|
||||
ctime: getNodeFileCreationTime(sourceStat),
|
||||
});
|
||||
const destinationPathWithPrefix = destinationDatabasePath as FilePathWithPrefix;
|
||||
const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true);
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
replicationFailed,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
vi.mock("node:fs/promises", { spy: true });
|
||||
|
||||
function createStandardIoMock() {
|
||||
return {
|
||||
readStdin: vi.fn(async () => ""),
|
||||
@@ -363,6 +365,38 @@ describe("runCommand abnormal cases", () => {
|
||||
expect(core.serviceModules.databaseFileAccess.fetch).toHaveBeenCalledWith("file.md", "9-invalid", true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ birthtimeMs: 1000.9, expectedCtime: 1000 },
|
||||
{ birthtimeMs: 0, expectedCtime: 2000 },
|
||||
])(
|
||||
"push selects the source creation time with a change-time fallback (%j)",
|
||||
async ({ birthtimeMs, expectedCtime }) => {
|
||||
const core = createCoreMock();
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-push-time-"));
|
||||
const source = path.join(directory, "source.md");
|
||||
await fs.writeFile(source, "content");
|
||||
const sourceStat = await fs.stat(source);
|
||||
const stat = vi
|
||||
.spyOn(fs, "stat")
|
||||
.mockResolvedValue({ ...sourceStat, birthtimeMs, ctimeMs: 2000.8, mtimeMs: 3000.7 });
|
||||
try {
|
||||
const context = { core, vaultPath: "/tmp/vault", databasePath: "/tmp/vault" } as any;
|
||||
expect(await runCommand(makeOptions("push", [source, "note.md"]), context)).toBe(true);
|
||||
expect(core.serviceModules.storageAccess.writeFileAuto).toHaveBeenCalledWith(
|
||||
"note.md",
|
||||
expect.any(ArrayBuffer),
|
||||
{
|
||||
ctime: expectedCtime,
|
||||
mtime: 3000,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
stat.mockRestore();
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("push rejects when source file does not exist", async () => {
|
||||
const core = createCoreMock();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { appendFile, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as chokidar from "chokidar";
|
||||
@@ -204,6 +204,45 @@ describe("CLI database preparation", () => {
|
||||
}
|
||||
);
|
||||
|
||||
it("retains filesystem creation time in database Metadata after an in-place edit", async (context) => {
|
||||
const note = join(vaultPath, "notes/local.md");
|
||||
const initial = await stat(note);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await appendFile(note, "\nedit");
|
||||
const edited = await stat(note);
|
||||
if (
|
||||
initial.birthtimeMs <= 0 ||
|
||||
edited.birthtimeMs !== initial.birthtimeMs ||
|
||||
Math.floor(edited.ctimeMs) <= Math.floor(initial.birthtimeMs)
|
||||
) {
|
||||
context.skip();
|
||||
}
|
||||
|
||||
const observed: { before?: number; after?: number; content?: string; stored?: boolean } = {};
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
const filePath = "notes/local.md" as FilePathWithPrefix;
|
||||
const before = await core.serviceModules.databaseFileAccess.fetch(filePath);
|
||||
if (before) observed.before = before.stat.ctime;
|
||||
await appendFile(note, "\nsecond edit");
|
||||
observed.stored = await core.serviceModules.fileHandler.storeFileToDB(filePath, true);
|
||||
const after = await core.serviceModules.databaseFileAccess.fetch(filePath);
|
||||
if (after) {
|
||||
observed.after = after.stat.ctime;
|
||||
observed.content = await after.body.text();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
await start("daemon", runner);
|
||||
expect(runner).toHaveBeenCalledOnce();
|
||||
expect(observed).toEqual({
|
||||
before: Math.floor(initial.birthtimeMs),
|
||||
after: Math.floor(initial.birthtimeMs),
|
||||
content: "local content\nedit\nsecond edit",
|
||||
stored: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs the mirror scan once and exits without starting file watching", async () => {
|
||||
const enumerate = vi.spyOn(ServiceFileAccessCLI.prototype, "getFiles");
|
||||
const watch = vi.mocked(chokidar.watch);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user