From 61f5a6091668732ae40476c6eecdad355edd915e Mon Sep 17 00:00:00 2001 From: Danny Holloran Date: Sun, 23 Aug 2026 21:31:11 -0500 Subject: [PATCH] Track written-file identity (device + inode) for reuse --- src/libs/markdown.ts | 160 ++++++++++++---- tests/index.test.ts | 6 +- tests/libs/markdown.test.ts | 363 ++++++++++++++++++++++++++++++++++-- 3 files changed, 482 insertions(+), 47 deletions(-) diff --git a/src/libs/markdown.ts b/src/libs/markdown.ts index c4db400..979e8ef 100644 --- a/src/libs/markdown.ts +++ b/src/libs/markdown.ts @@ -45,17 +45,44 @@ const FILE_ALREADY_EXISTS_ERROR_CODE = 'EEXIST'; // another hash of the CLI's own output, never used as a security boundary. const CONTENT_HASH_ALGORITHM = 'sha256'; +// Identity of the exact file writeMarkdown put on disk, so a later pass can tell +// "still my file" from "a different regular file dropped at this path between +// passes". `deviceId` + `inode` is the OS's own identity for a file (what +// hardlink detection and `find -samefile` compare): a replacement (delete + +// recreate, move-over, or an editor's atomic temp-then-rename save) usually lands +// a new inode at the same path, and `deviceId` disambiguates inode numbers that +// are only unique within a single filesystem (a vault on an external/network +// mount). Stored as `bigint` (via lstat's `bigint: true`) so a 64-bit inode or +// device id — Btrfs, Windows file indexes, some network filesystems — can't lose +// its low bits rounding through a JS double and false-match a different file. +// Deliberately NOT mtime or birthtime: mtime changes on every in-place vault edit +// (which must NOT count as a different file — that edit is a first-class case, +// kept via the content-hash check), and birthtime is unreliable cross-platform +// (libuv aliases it to ctime on Linux without statx, which also moves on edits). +// inode is stable across in-place edits on every platform (issue #124). +export type FileIdentity = { + deviceId: bigint; + inode: bigint; +}; + // The per-uuid bookkeeping writeMarkdown carries across autoSync passes: the -// file a record landed on, plus a hash of the exact document written there. -// Path and hash are written, forgotten, and evicted as a unit, so they live in -// one record rather than two parallel maps that could drift out of sync. The -// hash is the baseline for the reuse-refresh check (see reuseWrittenFile): on a -// later pass it tells "the server content changed" (the freshly rendered -// document no longer hashes to this) apart from "the user edited the file in the -// vault" (the on-disk bytes no longer hash to this). +// file a record landed on, a hash of the exact document written there, and the +// identity (device + inode) of that on-disk file. Path, hash, and identity are +// written, forgotten, and evicted as a unit, so they live in one record rather +// than parallel maps that could drift out of sync. The hash is the baseline for +// the reuse-refresh check (see reuseWrittenFile): on a later pass it tells "the +// server content changed" (the freshly rendered document no longer hashes to +// this) apart from "the user edited the file in the vault" (the on-disk bytes no +// longer hash to this). The identity is the reuse-eligibility check: it rejects +// settling a record against an unrelated file that replaced its own file at the +// same path (see resolveReusableWrittenState). `identity` is optional: a rare +// post-write stat failure leaves it unverified, and the reuse check degrades to +// the plain existing-regular-file test rather than dropping tracking (which would +// spawn a suffixed duplicate next pass). export type WrittenRecordState = { path: string; contentHash: string; + identity?: FileIdentity; }; // Hash of the exact bytes writeMarkdown put on disk, used only to compare one @@ -280,26 +307,27 @@ const evictStalePathOwners = ( // own file instead of the suffix strategy dropping a fresh `-2.md`, // `-3.md` duplicate every pass — the written-vs-settled split: the local // file is already written, only the server-side step still needs retrying. -// Returns the reusable state (path + content hash), or null (the caller then -// falls through to a fresh write) when any guard fails: the uuid is untrackable -// (empty) or was never written; the tracked path is no longer a regular file -// (moved/deleted, or -// replaced by a directory or symlink — reuse writes nothing for suffix/skip, so -// settling the record against a non-file would strand it with no local content, -// and lstat rejects a symlink rather than following it out of the vault); or the -// file no longer lives in the current output directory (a mid-run -// outputDirectory change must not send the record back to the old vault — same -// reason seenSlugs is keyed by resolved path). evictStalePathOwners keeps +// Returns the reusable state (path + content hash + identity), or null (the +// caller then falls through to a fresh write) when any guard fails: the uuid is +// untrackable (empty) or was never written; the tracked path is no longer a +// regular file (moved/deleted, or replaced by a directory or symlink — reuse +// writes nothing for suffix/skip, so settling the record against a non-file +// would strand it with no local content, and lstat rejects a symlink rather than +// following it out of the vault); the file no longer lives in the current output +// directory (a mid-run outputDirectory change must not send the record back to +// the old vault — same reason seenSlugs is keyed by resolved path); or the file +// at the path is no longer the one we wrote (a *different* regular file was +// dropped there between passes — its device/inode no longer matches, so +// settling/deleting the record would lose it with no local copy: the data-loss +// edge under autoDelete this closes, issue #124). evictStalePathOwners keeps // writtenState to one uuid per path, so a surviving entry is unambiguously this // record's file. Keyed by uuid, so a record whose title changed server-side // between passes keeps its original filename (its existing file is reused rather -// than orphaned under a new slug). The check is existence + type, not identity: -// if an external process deletes this record's file and drops a *different* -// regular file at the same path between passes, it is reused as-is — an accepted -// edge, since the fix (tracking inode/mtime per path) is disproportionate to a -// rare same-path race. A stat error other than "missing" (EACCES, ENOTDIR) is -// treated as "not reusable" rather than thrown, so a best-effort lookup can -// never fail an otherwise-writable record. +// than orphaned under a new slug). The directory guard is a pure string compare, +// so it runs before the filesystem stat to skip a stat on a path already known +// out of scope. A stat error other than "missing" (EACCES, ENOTDIR) is treated as +// "not reusable" rather than thrown, so a best-effort lookup can never fail an +// otherwise-writable record. const resolveReusableWrittenState = ( outputDirectory: string, recordUuid: string, @@ -315,29 +343,80 @@ const resolveReusableWrittenState = ( return null; } - if (!isExistingRegularFile(existingState.path)) { + if (resolve(dirname(existingState.path)) !== resolve(outputDirectory)) { return null; } - if (resolve(dirname(existingState.path)) !== resolve(outputDirectory)) { + const currentIdentity = readRegularFileIdentity(existingState.path); + + if (!currentIdentity) { + return null; + } + + // Write-time identity was unverified (a post-write stat failed), so the + // existing-regular-file check above was the whole eligibility test this pass. + // Adopt the now-readable identity so the guard is re-armed from next pass on — + // never worse than leaving it unverified, and it re-closes the issue #124 + // window that would otherwise stay open for this record's whole lifetime (the + // no-rewrite suffix/skip reuse path never re-records it on its own). + if (!existingState.identity) { + existingState.identity = currentIdentity; + return existingState; + } + + // A known write-time identity must still match the file on disk; a different + // file dropped at the path (new device/inode) is refused. + if (!fileIdentityMatches(existingState.identity, currentIdentity)) { return null; } return existingState; }; -// True only when `filePath` is an existing regular file. lstat (not stat) so a -// symlink reports false rather than resolving to its target. `throwIfNoEntry` -// covers a missing entry; any other stat error (EACCES, ENOTDIR) is caught and -// treated as "not reusable" so the best-effort reuse lookup can't fail a record. -const isExistingRegularFile = (filePath: string): boolean => { +// The regular-file identity (device + inode) at `filePath`, or null when the path +// is missing, is not a regular file, or can't be stat'd. lstat (not stat) so a +// symlink reports as non-regular rather than resolving to its target; a null +// return covers all three "not reusable" cases (`throwIfNoEntry` handles a +// missing entry, `isFile()` the directory/symlink case). Any other stat error +// (EACCES, ENOTDIR) is caught and treated as "not reusable" so this best-effort +// lookup can never fail an otherwise-writable record. +const readRegularFileIdentity = (filePath: string): FileIdentity | null => { try { - return lstatSync(filePath, { throwIfNoEntry: false })?.isFile() ?? false; + // `bigint: true` returns the device/inode as BigInt, preserving 64-bit ids + // that would otherwise lose precision through a JS double and false-match. + const stats = lstatSync(filePath, { throwIfNoEntry: false, bigint: true }); + + if (!stats?.isFile()) { + return null; + } + + return { deviceId: stats.dev, inode: stats.ino }; } catch { - return false; + return null; } }; +// The tracked path still holds the file we wrote when both device and inode match. +// A mismatch means the file was replaced between passes (a different regular file +// dropped at the same path); reuse is refused so the record is written fresh +// rather than silently settled/deleted against an unrelated file (issue #124). An +// in-place vault edit keeps the same device and inode (only mtime moves), so it +// still matches — that edit is preserved by the content-hash check, not treated +// as a different file. This narrows the data-loss window rather than closing it +// absolutely: a filesystem that recycles a just-freed inode number for the +// replacement file (ext4, APFS) can still produce a false match, but that +// requires the exact freed inode to be reissued at the same path between two +// passes of one process — an extreme corner next to the common replace-with-new- +// inode case this rejects. +const fileIdentityMatches = ( + tracked: FileIdentity, + current: FileIdentity, +): boolean => { + return ( + tracked.deviceId === current.deviceId && tracked.inode === current.inode + ); +}; + // Best-effort read of a file's bytes; null when it can't be read (missing, // EACCES, a directory). Used only by the reuse-refresh check, where an // unreadable file means "can't confirm it's our untouched output", so the caller @@ -437,8 +516,18 @@ const shouldRewriteReusedFile = ( return serverChangedWhileLocalUntouched(reusableState, renderedContent); }; -// Record the file and content hash this uuid now occupies, so a later reuse pass -// can both find the file and tell a server-side change apart from a vault edit. +// Record the file, content hash, and on-disk identity this uuid now occupies, so +// a later reuse pass can find the file, tell a server-side change apart from a +// vault edit, and confirm the file is still the one we wrote (not a replacement +// dropped at the path). The identity is read back on a best-effort basis right +// after the write; stat-after-write is two syscalls on a path, not one operation +// on a handle, so it shares the same narrow replacement race as the reuse check +// (a replacement winning that window is recorded as ours) — the same corner +// fileIdentityMatches already concedes for inode recycling. If the read fails (a +// rare transient stat error), the entry is still recorded but without an +// identity — the reuse check then degrades to the existing-regular-file test next +// pass (and re-arms the identity then) rather than dropping tracking, which would +// spawn a suffixed duplicate. const rememberWrittenState = ( writtenState: Map, recordUuid: string, @@ -448,6 +537,7 @@ const rememberWrittenState = ( writtenState.set(recordUuid, { path: writtenPath, contentHash: hashContent(content), + identity: readRegularFileIdentity(writtenPath) ?? undefined, }); }; diff --git a/tests/index.test.ts b/tests/index.test.ts index 7e65ab8..2b49b0d 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -2096,7 +2096,11 @@ describe('index', () => { ): string | null => { const filePath = `/mock/output/${record.uuid}.md`; snapshots.push(new Map(writtenState)); - writtenState?.set(record.uuid, { path: filePath, contentHash: 'hash' }); + writtenState?.set(record.uuid, { + path: filePath, + contentHash: 'hash', + identity: { deviceId: 1n, inode: 1n }, + }); return filePath; }; diff --git a/tests/libs/markdown.test.ts b/tests/libs/markdown.test.ts index 81301ea..588121a 100644 --- a/tests/libs/markdown.test.ts +++ b/tests/libs/markdown.test.ts @@ -38,6 +38,23 @@ const mockWriteFileSyncRejectingExistingPaths = ( existingPaths: Iterable = [], ): void => { const takenPaths = new Set(existingPaths); + // Each path carries its own identity, assigned fresh on every write so a + // rewrite (rmSync + writeFileSync, the overwrite/refresh path) models a new + // file with a new inode — distinct identities let a bug that stats or compares + // the wrong path fail loudly instead of matching by accident. One shared device + // id models a single-filesystem vault (the common case). + const MODEL_DEVICE_ID = 1n; + const identities = new Map(); + let nextInode = 1n; + + const assignIdentity = (path: string): void => { + identities.set(path, { dev: MODEL_DEVICE_ID, ino: nextInode }); + nextInode += 1n; + }; + + for (const path of takenPaths) { + assignIdentity(path); + } vi.mocked(writeFileSync).mockImplementation((path, _content, options) => { const flag = @@ -48,19 +65,28 @@ const mockWriteFileSyncRejectingExistingPaths = ( } takenPaths.add(path as string); + assignIdentity(path as string); }); vi.mocked(rmSync).mockImplementation((path) => { takenPaths.delete(path as string); + identities.delete(path as string); }); // Back lstat from the same simulated disk: a taken path is an existing regular - // file, everything else is missing (undefined). This is what makes the reuse - // path actually run in these tests — without it lstat returns undefined and - // reuse never triggers, so the eviction/ownership guards would go untested. - vi.mocked(lstatSync).mockImplementation((path) => - takenPaths.has(path as string) ? regularFileStats : undefined, - ); + // file carrying its assigned identity, everything else is missing (undefined). + // This is what makes the reuse path actually run in these tests — without it + // lstat returns undefined and reuse never triggers, so the eviction/ownership + // and identity guards would go untested. + vi.mocked(lstatSync).mockImplementation((path) => { + const identity = identities.get(path as string); + + if (!identity) { + return undefined; + } + + return identityStats(identity.dev, identity.ino); + }); }; vi.mock('node:fs', () => ({ @@ -73,15 +99,26 @@ vi.mock('node:fs', () => ({ })); // lstatSync(path, { throwIfNoEntry: false }) returns undefined for a missing -// entry, a Stats-like object otherwise. These stand-ins expose just isFile(), -// the only method resolveReusableWrittenState calls. -const regularFileStats = { isFile: () => true } as unknown as ReturnType< - typeof lstatSync ->; +// entry, a Stats-like object otherwise. `nonFileStats` models a directory or +// symlink (isFile() false); `identityStats` models a regular file carrying the +// `dev`/`ino` the reuse-identity check reads. const nonFileStats = { isFile: () => false } as unknown as ReturnType< typeof lstatSync >; +// A regular-file stat carrying an explicit identity, for tests that swap the file +// at a tracked path between passes: a new inode (or device) models the different +// file a replacement drops there. +const identityStats = (deviceId: bigint, inode: bigint): ReturnType< + typeof lstatSync +> => { + return { + isFile: () => true, + dev: deviceId, + ino: inode, + } as unknown as ReturnType; +}; + vi.mock('@/libs/config.js', () => ({ config: { get: vi.fn() }, })); @@ -725,6 +762,310 @@ describe('writeMarkdown', () => { expect(secondPath).toBe(resolve(outputDirectory, 'test-title-2.md')); }); + it('writes a fresh file when a different regular file was dropped at the tracked path, then recovers onto it', () => { + // Issue #124: the record's own file is deleted and an unrelated regular + // file is dropped at the same path between passes (a new inode). Reusing it + // would settle — and under autoDelete, delete server-side — the record + // against a file that is not its content: silent data loss. The identity + // mismatch must refuse reuse so the record is written fresh first. + mockWriteFileSyncRejectingExistingPaths(); + const basePath = resolve(outputDirectory, 'test-title.md'); + const suffixedPath = resolve(outputDirectory, 'test-title-2.md'); + const writtenState = new Map(); + + writeMarkdown(mockRecord, 'suffix', new Map(), true, writtenState); + vi.mocked(writeFileSync).mockClear(); + // basePath now holds a foreign file (same device, new inode); the fresh + // suffixed file the record lands on gets its own identity so the record can + // be tracked and reused next pass rather than silently untracked. + vi.mocked(lstatSync).mockImplementation((path) => { + if (path === basePath) { + return identityStats(1n, 4242n); + } + + if (path === suffixedPath) { + return identityStats(1n, 7n); + } + + return undefined; + }); + const secondPath = writeMarkdown( + mockRecord, + 'suffix', + new Map(), + true, + writtenState, + ); + + // The record lands on a real suffixed file (basePath is still taken on + // disk) rather than being settled against the foreign file, its own content + // is written out, and it is now tracked against that new file. + expect(secondPath).toBe(suffixedPath); + expect(writeFileSync).toHaveBeenCalledWith( + suffixedPath, + mockRecord.content, + EXCLUSIVE_WRITE_OPTIONS, + ); + expect(writtenState.get(mockRecord.uuid)?.path).toBe(suffixedPath); + + // Third pass: the suffixed file is untouched, so the record reuses it rather + // than dropping yet another test-title-3.md duplicate. + const thirdPath = writeMarkdown( + mockRecord, + 'suffix', + new Map(), + true, + writtenState, + ); + expect(thirdPath).toBe(suffixedPath); + }); + + it('refuses reuse when the inode matches but the device id differs', () => { + // Inode numbers are unique only within one filesystem. If the vault sits on + // (or contains) a mount that is remounted, or a replacement file arrives + // from a different device, the inode number can collide with the tracked + // one. The device id disambiguates: the record wrote {deviceId: 1n, inode: 1n} + // (the first assigned identity); a same-inode file on device 2 is a + // different file, so reuse must be refused. + mockWriteFileSyncRejectingExistingPaths(); + const basePath = resolve(outputDirectory, 'test-title.md'); + const writtenState = new Map(); + + writeMarkdown(mockRecord, 'suffix', new Map(), true, writtenState); + vi.mocked(lstatSync).mockImplementation((path) => + path === basePath ? identityStats(2n, 1n) : undefined, + ); + const secondPath = writeMarkdown( + mockRecord, + 'suffix', + new Map(), + true, + writtenState, + ); + + expect(secondPath).toBe(resolve(outputDirectory, 'test-title-2.md')); + }); + + it('still reuses the file after an in-place vault edit, which moves mtime but not device or inode', () => { + // The regression guard for the whole design: an in-place edit (vim, echo >>) + // changes the bytes and the mtime but keeps the same device and inode. That + // must NOT count as a different file — matching on mtime (or on birthtime, + // which libuv aliases to the edit-moving ctime on Linux) would wrongly refuse + // reuse and drop a suffixed duplicate every pass. The edit itself is + // preserved by the existing content-hash check (server unchanged here, so no + // rewrite). + mockWriteFileSyncRejectingExistingPaths(); + const basePath = resolve(outputDirectory, 'test-title.md'); + const writtenState = new Map(); + + writeMarkdown(mockRecord, 'suffix', new Map(), true, writtenState); + vi.mocked(writeFileSync).mockClear(); + // Same device + inode as the first write (mtime is irrelevant to the check + // and not modelled); the user edited the bytes on disk. + vi.mocked(lstatSync).mockImplementation((path) => + path === basePath ? identityStats(1n, 1n) : undefined, + ); + vi.mocked(readFileSync).mockReturnValue('user edited this in Obsidian'); + const secondPath = writeMarkdown( + mockRecord, + 'suffix', + new Map(), + true, + writtenState, + ); + + // Reused (no suffixed duplicate) and the user's edit is left untouched. + expect(secondPath).toBe(basePath); + expect(writeFileSync).not.toHaveBeenCalled(); + }); + + it('reuses the file when its identity is unchanged between passes', () => { + // The complement of the rejection cases: an untouched file keeps its device + // and inode, so reuse proceeds and no suffixed duplicate is dropped. + mockWriteFileSyncRejectingExistingPaths(); + const basePath = resolve(outputDirectory, 'test-title.md'); + const writtenState = new Map(); + + const firstPath = writeMarkdown( + mockRecord, + 'suffix', + new Map(), + true, + writtenState, + ); + const secondPath = writeMarkdown( + mockRecord, + 'suffix', + new Map(), + true, + writtenState, + ); + + expect(firstPath).toBe(basePath); + expect(secondPath).toBe(basePath); + expect(writeFileSync).not.toHaveBeenCalledWith( + resolve(outputDirectory, 'test-title-2.md'), + expect.anything(), + expect.anything(), + ); + }); + + it('stores the on-disk identity for a written record so a later pass can verify it', () => { + mockWriteFileSyncRejectingExistingPaths(); + const writtenState = new Map(); + + writeMarkdown(mockRecord, 'suffix', new Map(), true, writtenState); + + expect(writtenState.get(mockRecord.uuid)?.identity).toEqual({ + deviceId: 1n, + inode: 1n, + }); + }); + + it('tracks a written record without an identity when the post-write stat fails, then reuses it via the existence fallback', () => { + // A transient stat failure right after the write (a backup/indexing tool + // briefly holding the path, a slow mount) means the identity can't be read. + // The record is still tracked, just without an identity, so the reuse check + // degrades to the existing-regular-file test next pass rather than dropping + // tracking — which would spawn a suffixed duplicate. + mockWriteFileSyncRejectingExistingPaths(); + const basePath = resolve(outputDirectory, 'test-title.md'); + const writtenState = new Map(); + + // The disk model is installed (so the base path exists as a regular file), + // but the post-write identity read is forced to fail once. + vi.mocked(lstatSync).mockReturnValueOnce(undefined); + const firstPath = writeMarkdown( + mockRecord, + 'suffix', + new Map(), + true, + writtenState, + ); + + expect(firstPath).toBe(basePath); + expect(writtenState.get(mockRecord.uuid)?.path).toBe(basePath); + expect(writtenState.get(mockRecord.uuid)?.identity).toBeUndefined(); + + // Next pass: identity is unverified, so eligibility falls back to "is the + // tracked path still a regular file?" — it is, so the record reuses it + // instead of dropping test-title-2.md. + vi.mocked(writeFileSync).mockClear(); + const secondPath = writeMarkdown( + mockRecord, + 'suffix', + new Map(), + true, + writtenState, + ); + + expect(secondPath).toBe(basePath); + expect(writeFileSync).not.toHaveBeenCalled(); + + // The reuse pass re-armed the guard by adopting the now-readable identity, + // so it no longer stays open for this record's lifetime. + expect(writtenState.get(mockRecord.uuid)?.identity).toEqual({ + deviceId: 1n, + inode: 1n, + }); + + // Third pass: a foreign file (new inode) is now at the tracked path. With the + // guard re-armed, reuse is refused and the record lands on a suffixed file + // rather than being settled against the foreign file. + vi.mocked(lstatSync).mockImplementation((path) => + path === basePath ? identityStats(1n, 8888n) : undefined, + ); + const thirdPath = writeMarkdown( + mockRecord, + 'suffix', + new Map(), + true, + writtenState, + ); + + expect(thirdPath).toBe(resolve(outputDirectory, 'test-title-2.md')); + }); + + it('refuses to settle a skip-strategy record against a foreign file at the tracked path', () => { + // Under `skip` a foreign file now occupies the base path. Reuse is refused + // (identity mismatch), and `skip` will not clobber an existing file, so the + // write is a no-op (null) and the record is left unsettled — safely still + // pending on the server — rather than deleted server-side against a file + // that is not its content (issue #124). This is `skip`'s normal + // occupied-slug contract, not a new stall: the record recovers once the + // foreign file is gone. + mockWriteFileSyncRejectingExistingPaths(); + const basePath = resolve(outputDirectory, 'test-title.md'); + const writtenState = new Map(); + + writeMarkdown(mockRecord, 'skip', new Map(), true, writtenState); + vi.mocked(rmSync).mockClear(); + vi.mocked(lstatSync).mockImplementation((path) => + path === basePath ? identityStats(1n, 4242n) : undefined, + ); + const secondPath = writeMarkdown( + mockRecord, + 'skip', + new Map(), + true, + writtenState, + ); + + // No path returned, so the caller does not settle the record; the foreign + // file is left untouched — `skip` only ever attempts an exclusive create + // (which fails EEXIST on the taken path) and never unlinks, so `rmSync` is + // not called. + expect(secondPath).toBeNull(); + expect(rmSync).not.toHaveBeenCalled(); + + // Recovery: the user removes the foreign file, freeing the path. Next pass + // the record writes fresh there and is tracked again — proving the refusal + // was a transient occupied-slot skip, not a permanent stall. + rmSync(basePath, { force: true }); + vi.mocked(lstatSync).mockReturnValue(undefined); + const thirdPath = writeMarkdown( + mockRecord, + 'skip', + new Map(), + true, + writtenState, + ); + + expect(thirdPath).toBe(basePath); + expect(writtenState.get(mockRecord.uuid)?.path).toBe(basePath); + }); + + it('clobbers a foreign file at the tracked path under the overwrite strategy, persisting the record', () => { + // `overwrite` opts into the newest record winning the path. Reuse is refused + // by the identity mismatch, but the fresh overwrite write still lands on the + // base path and replaces the foreign file with the record's own content, so + // the record is persisted before it settles — no data loss, consistent with + // the strategy the user chose. + mockWriteFileSyncRejectingExistingPaths(); + const basePath = resolve(outputDirectory, 'test-title.md'); + const writtenState = new Map(); + + writeMarkdown(mockRecord, 'overwrite', new Map(), true, writtenState); + vi.mocked(writeFileSync).mockClear(); + vi.mocked(lstatSync).mockImplementation((path) => + path === basePath ? identityStats(1n, 4242n) : undefined, + ); + const secondPath = writeMarkdown( + mockRecord, + 'overwrite', + new Map(), + true, + writtenState, + ); + + expect(secondPath).toBe(basePath); + expect(writeFileSync).toHaveBeenLastCalledWith( + basePath, + mockRecord.content, + EXCLUSIVE_WRITE_OPTIONS, + ); + }); + it('falls through to a fresh write when a different record claimed the tracked path after this record\'s file moved', () => { // A's file is moved out of the vault; next pass a different same-slug // record B claims the freed base path first. A's reuse must NOT overwrite