dotmd-cli 0.71.2 → 0.71.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.71.2",
3
+ "version": "0.71.4",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@ import { createHash, randomUUID } from 'node:crypto';
21
21
  import { execFileSync } from 'node:child_process';
22
22
  import os from 'node:os';
23
23
  import path from 'node:path';
24
- import { captureGitIndexGeneration, reclaimPreparedGitIndex, restoreGitIndexCas, sameGitIndexGeneration, stageMovePathsCas } from './git.mjs';
24
+ import { captureGitIndexGeneration, gitIndexProvablyUnpublished, gitIndexPublicationRaced, reclaimPreparedGitIndex, restoreGitIndexCas, sameGitIndexGeneration, stageMovePathsCas } from './git.mjs';
25
25
  import { authorizeManagedDestination, authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
26
26
  import { commitRename } from './durable-rename.mjs';
27
27
 
@@ -32,6 +32,13 @@ let tempSequence = 0;
32
32
  // retry budget spent while the lock is held has to fit well inside this.
33
33
  export const MUTATION_LOCK_TIMEOUT_MS = 2000;
34
34
 
35
+ // Bounded re-stage budget for a Git index publication that provably lost a
36
+ // race. Sized like durable-rename's retry: total backoff (25 + 50ms) plus the
37
+ // re-prepare has to stay well inside MUTATION_LOCK_TIMEOUT_MS, since peers are
38
+ // waiting on the participant locks this loop holds.
39
+ export const GIT_INDEX_STAGE_ATTEMPTS = 3;
40
+ const GIT_INDEX_STAGE_BACKOFF_MS = 25;
41
+
35
42
  export function processStartIdentity(pid) {
36
43
  try {
37
44
  const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
@@ -815,6 +822,26 @@ function cleanupCreatedDirectories(transaction, options) {
815
822
  return complete;
816
823
  }
817
824
 
825
+ // The Git index snapshot a move stages against is captured before the content
826
+ // phase, which in a repo with thousands of docs takes seconds. Anything that
827
+ // rewrites `.git/index` in that window — including a bare `git status`, which
828
+ // rewrites it to refresh its stat cache — used to fail the publication CAS and
829
+ // abort the whole move. Re-base on the generation that is current at staging
830
+ // time instead: nothing of ours has reached the index yet, so our staging
831
+ // belongs on top of whatever landed meanwhile, which is exactly what a plain
832
+ // `git mv` would produce. The manifest records the new base durably BEFORE any
833
+ // index work, so recovery of a crash mid-stage still restores what it must.
834
+ function rebaseGitIndexSnapshot(transaction, repoRoot, options) {
835
+ const current = captureGitIndexGeneration(repoRoot);
836
+ if (current.indexPath !== transaction.manifest.gitIndex.before.indexPath) {
837
+ throw new MutationConflictError('The selected Git index changed while the move was in flight; staging was not attempted.');
838
+ }
839
+ if (sameGitIndexGeneration(current, transaction.manifest.gitIndex.before)) return;
840
+ transaction.manifest.gitIndex.before = current;
841
+ durableJson(transaction.manifestPath, transaction.manifest, options);
842
+ options.testHooks?.afterTransactionPhase?.('git-index-rebase', { manifestPath: transaction.manifestPath, manifest: transaction.manifest });
843
+ }
844
+
818
845
  function setMoveManifestPhase(transaction, phase, options, detail = null) {
819
846
  transaction.manifest.phase = phase;
820
847
  if (detail !== null) transaction.manifest.detail = detail;
@@ -963,7 +990,6 @@ export function withPathLocks(filePaths, options, callback) {
963
990
  let madeLock = false;
964
991
  try {
965
992
  mkdirSync(lockPath);
966
- fsyncDirectory(lockRoot, options, 'lock-directory-create');
967
993
  madeLock = true;
968
994
  const ownerTemp = path.join(lockPath, `.owner-${token}.tmp`);
969
995
  writeFileSync(ownerTemp, JSON.stringify({
@@ -973,7 +999,17 @@ export function withPathLocks(filePaths, options, callback) {
973
999
  path: canonical,
974
1000
  }) + '\n', { flag: 'wx' });
975
1001
  renameSync(ownerTemp, path.join(lockPath, 'owner.json'));
976
- fsyncDirectory(lockPath, options, 'lock-owner-publish');
1002
+ // Deliberately not flushed. Every reader of owner.json — the token
1003
+ // check below, a peer's reclaim check — reads it back through the
1004
+ // page cache, which needs no flush. Flushing the lock directory
1005
+ // never made the record survive a crash either: a directory fsync
1006
+ // orders the entry, not the file's bytes, so all it could do was
1007
+ // make an entry outlive the contents it names. That shape is the
1008
+ // worst one available: an ownerless lock is never auto-reclaimed
1009
+ // (liveness is unverifiable without an owner), so it wedges the repo
1010
+ // until someone deletes it by hand, whereas an unflushed lock simply
1011
+ // does not survive the crash — which is the correct post-crash state
1012
+ // for a lock nobody holds. It also cost a real disk flush per lock.
977
1013
  if (lockOwner(lockPath)?.token !== token) {
978
1014
  throw new MutationConflictError(`Mutation lock ownership changed while claiming ${canonical}.`);
979
1015
  }
@@ -995,16 +1031,29 @@ export function withPathLocks(filePaths, options, callback) {
995
1031
  }
996
1032
  }
997
1033
  }
1034
+ // One flush for the whole set, not one per lock. Mutual exclusion comes
1035
+ // from mkdir's atomicity, which is in-memory and needs no flush; this
1036
+ // fsync only has to make every lock durable before the callback mutates
1037
+ // anything, and one flush of the lock root does that for all of them. Per
1038
+ // lock it cost a full directory flush each (~6ms on APFS), so a reference
1039
+ // sweep that locks every doc in a large repo spent MINUTES here — long
1040
+ // enough that a concurrent `git status` would routinely invalidate the
1041
+ // transaction's Git index CAS mid-move.
1042
+ if (acquired.length) fsyncDirectory(lockRoot, options, 'lock-directory-create');
998
1043
  return callback();
999
1044
  } finally {
1045
+ let released = false;
1000
1046
  for (const { lockPath, token } of acquired.reverse()) {
1001
1047
  try {
1002
1048
  if (lockOwner(lockPath)?.token === token) {
1003
1049
  removeLockDirectory(lockPath, lockRoot);
1004
- fsyncDirectory(lockRoot, options, 'lock-directory-delete');
1050
+ released = true;
1005
1051
  }
1006
1052
  } catch { /* preserve original error */ }
1007
1053
  }
1054
+ // Same batching on release. A lock directory that outlives a crash is
1055
+ // reclaimed by liveness check, so flushing each removal buys nothing.
1056
+ if (released) try { fsyncDirectory(lockRoot, options, 'lock-directory-delete'); } catch { /* preserve original error */ }
1008
1057
  }
1009
1058
  }
1010
1059
 
@@ -1358,7 +1407,6 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1358
1407
  testHooks?.afterMovePublish?.({ backup, sourcePath, targetPath });
1359
1408
 
1360
1409
  if (options.gitMove && transaction.manifest.gitIndex.before) {
1361
- finalizeAttempted = true;
1362
1410
  const gitHooks = {
1363
1411
  ...options.testHooks,
1364
1412
  afterGitIndexArtifact: info => {
@@ -1401,13 +1449,40 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1401
1449
  options.testHooks?.afterGitIndexPublication?.(info);
1402
1450
  },
1403
1451
  };
1404
- transaction.manifest.gitIndex.ownedAfter = stageMovePathsCas(
1405
- transaction.manifest.gitMove.source,
1406
- transaction.manifest.gitMove.target,
1407
- repoRoot,
1408
- transaction.manifest.gitIndex.before,
1409
- { testHooks: gitHooks, artifactPath: transaction.gitPreparedArtifact },
1410
- );
1452
+ for (let attempt = 0; ; attempt++) {
1453
+ rebaseGitIndexSnapshot(transaction, repoRoot, options);
1454
+ finalizeAttempted = true;
1455
+ try {
1456
+ transaction.manifest.gitIndex.ownedAfter = stageMovePathsCas(
1457
+ transaction.manifest.gitMove.source,
1458
+ transaction.manifest.gitMove.target,
1459
+ repoRoot,
1460
+ transaction.manifest.gitIndex.before,
1461
+ { testHooks: gitHooks, artifactPath: transaction.gitPreparedArtifact },
1462
+ );
1463
+ break;
1464
+ } catch (err) {
1465
+ // A raced attempt published nothing, so re-staging on the
1466
+ // generation that beat us is safe — and it is what the user asked
1467
+ // for. Only contention is retried; a refused `git add` is a verdict
1468
+ // and retrying it just multiplies the failure. The budget is small
1469
+ // on purpose: these attempts run while every participant path is
1470
+ // locked, and peers only wait MUTATION_LOCK_TIMEOUT_MS.
1471
+ if (attempt >= GIT_INDEX_STAGE_ATTEMPTS - 1 || !gitIndexPublicationRaced(err)) throw err;
1472
+ // Give up on the ORIGINAL error if the scratch state cannot be
1473
+ // tidied: it is the one carrying proof that nothing was published,
1474
+ // which is what lets the rollback finish instead of retaining the
1475
+ // transaction for manual repair.
1476
+ let reclaimed;
1477
+ try { reclaimed = reclaimPreparedGitIndex(transaction.manifest.gitIndex, repoRoot, options); }
1478
+ catch { throw err; }
1479
+ if (reclaimed.retainedPaths.length) throw err;
1480
+ transaction.manifest.gitIndex.prepared = null;
1481
+ durableJson(transaction.manifestPath, transaction.manifest, options);
1482
+ options.testHooks?.afterTransactionPhase?.('git-index-stage-retry', { manifestPath: transaction.manifestPath, manifest: transaction.manifest, attempt });
1483
+ Atomics.wait(sleepBuffer, 0, 0, GIT_INDEX_STAGE_BACKOFF_MS * (attempt + 1));
1484
+ }
1485
+ }
1411
1486
  durableJson(transaction.manifestPath, transaction.manifest, options);
1412
1487
  } else if (finalize) {
1413
1488
  finalizeAttempted = true;
@@ -1495,6 +1570,15 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1495
1570
  },
1496
1571
  },
1497
1572
  });
1573
+ } else if (gitIndexProvablyUnpublished(err)) {
1574
+ // Staging failed before `.git/index` was replaced, so there is
1575
+ // nothing of ours to restore. The index may still differ from our
1576
+ // snapshot — that is another process's staging, and preserving it
1577
+ // IS the correct outcome. Treating that difference as a failed
1578
+ // rollback (as this used to) retained the transaction as
1579
+ // failed-manual, which then blocked EVERY later mutation in the
1580
+ // repo until `dotmd doctor --transactions --apply` cleared it, for
1581
+ // a move that had already rolled back completely.
1498
1582
  } else if (!sameGitIndexGeneration(captureGitIndexGeneration(repoRoot), transaction.manifest.gitIndex.before)) {
1499
1583
  throw new Error('Git finalize did not publish its prepared generation, but the real index changed; current staging was preserved.');
1500
1584
  }
package/src/git.mjs CHANGED
@@ -654,7 +654,40 @@ function prepareMoveIndex(source, target, repoRoot, before, options = {}) {
654
654
  return prepared;
655
655
  }
656
656
 
657
+ // A failure that provably happened before `.git/index` was replaced. Rollback
658
+ // has to tell this apart from "we may already have published": in the latter
659
+ // case it must restore the index and, failing that, retain the transaction for
660
+ // manual repair; in this one there is nothing of ours in the index to undo, so
661
+ // insisting on a restore turns someone else's concurrent `git add` into a
662
+ // wedged repo. `race` additionally means the failure was contention, not a
663
+ // verdict — the same call can succeed on a fresh generation.
664
+ function markUnpublishedIndexError(err, { race = false } = {}) {
665
+ if (err && typeof err === 'object') {
666
+ err.gitIndexPublished = false;
667
+ if (race) err.gitIndexRace = true;
668
+ }
669
+ return err;
670
+ }
671
+
672
+ export function gitIndexProvablyUnpublished(err) {
673
+ return err?.gitIndexPublished === false;
674
+ }
675
+
676
+ export function gitIndexPublicationRaced(err) {
677
+ return err?.gitIndexPublished === false && err?.gitIndexRace === true;
678
+ }
679
+
657
680
  function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks = {}) {
681
+ let published = false;
682
+ try {
683
+ return publishIndexGenerationLocked(repoRoot, expected, desired, prepared, testHooks, state => { published = state; });
684
+ } catch (err) {
685
+ if (!published) markUnpublishedIndexError(err, { race: Boolean(err?.gitIndexRace) });
686
+ throw err;
687
+ }
688
+ }
689
+
690
+ function publishIndexGenerationLocked(repoRoot, expected, desired, prepared, testHooks, notePublished) {
658
691
  const { indexPath, indexDir, lockPath } = gitIndexLocations(repoRoot);
659
692
  if (expected.indexPath !== indexPath || desired.indexPath !== indexPath) throw new Error('Selected Git index changed since the transaction snapshot; recovery refused to target a different index.');
660
693
  if (!prepared || prepared.state !== 'prepared' || path.dirname(prepared.tempPath) !== indexDir || !path.basename(prepared.tempPath).startsWith('.dotmd-index-') || !preparedMatches(prepared)) throw new Error('Prepared Git index ownership could not be verified.');
@@ -676,14 +709,14 @@ function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks
676
709
  }
677
710
  } catch { /* retain original durability error */ }
678
711
  }
679
- if (err?.code === 'EEXIST') throw new Error('Git index is locked by another process; transaction index publication was not attempted.');
712
+ if (err?.code === 'EEXIST') throw markUnpublishedIndexError(new Error('Git index is locked by another process; transaction index publication was not attempted.'), { race: true });
680
713
  throw err;
681
714
  }
682
715
  let published = false;
683
716
  try {
684
717
  testHooks.afterGitIndexLock?.({ lockPath, expected, desired });
685
718
  const current = captureIndexPath(indexPath);
686
- if (!sameGitIndexGeneration(current, expected)) throw new Error('Git index changed before transaction publication; current staging was preserved.');
719
+ if (!sameGitIndexGeneration(current, expected)) throw markUnpublishedIndexError(new Error('Git index changed before transaction publication; current staging was preserved.'), { race: true });
687
720
  testHooks.afterGitIndexCompare?.({ lockPath, current, desired });
688
721
  if (desired.exists) {
689
722
  // .git/index is routinely held open by concurrent git processes and IDE git
@@ -698,6 +731,7 @@ function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks
698
731
  lockOwned = false;
699
732
  }
700
733
  published = true;
734
+ notePublished(true);
701
735
  fsyncIndexDirectory(indexDir, testHooks, 'publication');
702
736
  testHooks.afterGitIndexPublication?.({ indexPath, desired });
703
737
  return desired;
@@ -717,7 +751,12 @@ function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks
717
751
  }
718
752
 
719
753
  export function stageMovePathsCas(source, target, repoRoot, before, options = {}) {
720
- const prepared = prepareMoveIndex(source, target, repoRoot, before, options);
754
+ let prepared;
755
+ // Preparation writes only to transaction-owned scratch indexes, so anything
756
+ // that fails here — a refused `git add`, a failing clean filter — leaves the
757
+ // real index untouched by definition.
758
+ try { prepared = prepareMoveIndex(source, target, repoRoot, before, options); }
759
+ catch (err) { throw markUnpublishedIndexError(err); }
721
760
  try { return publishIndexGeneration(repoRoot, before, prepared.generation, prepared, options.testHooks); }
722
761
  finally {
723
762
  if (prepared.work && prepared.work.path !== prepared.path) unlinkPrepared(prepared.work, options.testHooks, 'working-index-delete');
@@ -746,6 +785,17 @@ export function restoreGitIndexCas(before, ownedAfter, repoRoot, options = {}) {
746
785
  }
747
786
  }
748
787
 
788
+ // `.git/index.lock` is only ever ours as a hard link to the prepared index, so
789
+ // the recorded publication inode identifies it even after the artifact itself
790
+ // is unlinked.
791
+ function lockIsOurs(lockPath, prepared) {
792
+ const publication = prepared.work ?? prepared;
793
+ try {
794
+ const lock = lstatSync(lockPath);
795
+ return lock.isFile() && !lock.isSymbolicLink() && lock.dev === publication.dev && lock.ino === publication.ino;
796
+ } catch { return false; }
797
+ }
798
+
749
799
  export function reclaimPreparedGitIndex(manifestGitIndex, repoRoot, options = {}) {
750
800
  const prepared = manifestGitIndex?.prepared;
751
801
  if (!prepared) return { cleaned: false, retainedPaths: [] };
@@ -754,7 +804,14 @@ export function reclaimPreparedGitIndex(manifestGitIndex, repoRoot, options = {}
754
804
  if (manifestGitIndex.before?.indexPath !== indexPath || prepared.generation?.indexPath !== indexPath) throw new Error('Recovery environment selects a different Git index than the abandoned transaction.');
755
805
  if (path.dirname(prepared.tempPath) !== indexDir || !path.basename(prepared.tempPath).startsWith('.dotmd-index-')) throw new Error('Abandoned prepared Git index path is unsafe.');
756
806
  if (!existsSync(prepared.path)) {
757
- if (existsSync(lockPath)) throw new Error('Git index lock exists without its recorded prepared inode; it was preserved.');
807
+ // The lock is only ever ours as a hard link to the publication inode, so a
808
+ // lock that does not carry that inode cannot be ours — it belongs to a live
809
+ // `git` (a plain `git status` takes index.lock to rewrite its stat cache).
810
+ // Leaving it is right; treating it as damage was not. That throw travelled
811
+ // up as a failed rollback and retained the transaction, so a few hundred
812
+ // milliseconds of ordinary Git activity bricked every later mutation in the
813
+ // repo until `doctor --transactions --apply` ran.
814
+ if (existsSync(lockPath) && lockIsOurs(lockPath, prepared)) throw new Error('A transaction-owned Git index lock outlived its prepared artifact; it was preserved.');
758
815
  if (existsSync(prepared.tempPath)) retainedPaths.push(prepared.tempPath);
759
816
  return { cleaned: false, retainedPaths };
760
817
  }
@@ -1,10 +1,21 @@
1
- import { realpathSync } from 'node:fs';
1
+ import { realpathSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter } from './frontmatter.mjs';
4
4
 
5
5
  function slash(value) { return value.split(path.sep).join('/'); }
6
6
 
7
- function canonicalExisting(filePath) {
7
+ function canonicalExisting(filePath, memo = null) {
8
+ const key = memo ? path.resolve(filePath) : null;
9
+ if (memo) {
10
+ const hit = memo.get(key);
11
+ if (hit !== undefined) return hit;
12
+ }
13
+ const identity = resolveCanonical(filePath);
14
+ if (memo) memo.set(key, identity);
15
+ return identity;
16
+ }
17
+
18
+ function resolveCanonical(filePath) {
8
19
  try { return realpathSync(filePath); }
9
20
  catch {
10
21
  const suffix = [];
@@ -36,31 +47,118 @@ export function configuredReferenceFields(config) {
36
47
  ])];
37
48
  }
38
49
 
50
+ // Marks a case fold shared by two corpus documents. On a case-sensitive
51
+ // filesystem those are genuinely two files, and nothing may collapse them.
52
+ const AMBIGUOUS_FOLD = Symbol('ambiguous case fold');
53
+
54
+ // The set carries four indexes beside the identities themselves:
55
+ // canonical — memoized path resolution. Resolving one reference costs two
56
+ // `canonicalExisting` calls, and the repository-relative spelling usually
57
+ // does NOT exist, which sends it up the tree doing a realpath per ancestor.
58
+ // Over a corpus-wide sweep that is tens of thousands of syscalls across a
59
+ // few hundred distinct paths, and it dominated the reference rewrite. The
60
+ // memo lives here so it is scoped to one sweep and cannot outlive a
61
+ // mutation — `dotmd bulk` builds a fresh set per move. Within a sweep it is
62
+ // also more consistent than re-resolving per token, which could observe the
63
+ // filesystem changing midway.
64
+ // paths — identity to the spelling the corpus used.
65
+ // names — identity to every basename it answers to (symlink aliases).
66
+ // folded — case fold to identity, for filesystems that fold case.
39
67
  export function createReferenceIdentitySet(filePaths) {
40
68
  const identities = new Set();
41
69
  identities.paths = new Map();
42
- for (const filePath of filePaths) {
43
- const identity = canonicalExisting(filePath);
44
- identities.add(identity);
45
- identities.paths.set(identity, path.resolve(filePath));
46
- }
70
+ identities.canonical = new Map();
71
+ identities.names = new Map();
72
+ identities.folded = new Map();
73
+ identities.symlinked = false;
74
+ for (const filePath of filePaths) registerIdentity(identities, filePath);
47
75
  return identities;
48
76
  }
49
77
 
78
+ function registerIdentity(identities, filePath) {
79
+ const resolved = path.resolve(filePath);
80
+ const identity = canonicalExisting(filePath, identities.canonical);
81
+ // A corpus path whose realpath differs is a symlink, so one document can be
82
+ // reachable under more than one name. `names` records every one of them —
83
+ // and `symlinked` warns the candidate prefilter that names are not a
84
+ // reliable signal in this repo at all.
85
+ if (identity !== resolved) identities.symlinked = true;
86
+ identities.add(identity);
87
+ identities.paths.set(identity, resolved);
88
+ let names = identities.names.get(identity);
89
+ if (!names) identities.names.set(identity, names = new Set());
90
+ names.add(path.basename(resolved).toLowerCase());
91
+ const key = identity.toLowerCase();
92
+ const seen = identities.folded.get(key);
93
+ identities.folded.set(key, seen === undefined || seen === identity ? identity : AMBIGUOUS_FOLD);
94
+ return identity;
95
+ }
96
+
97
+ // Can this document possibly hold a reference to `oldIdentity`? Every token the
98
+ // rewriter will touch has to survive `/\.md$/i` and then resolve to the moved
99
+ // file, so the document must spell one of that file's names somewhere. Checking
100
+ // that first skips the fence-aware walk for the ~99% of a corpus that never
101
+ // mentions the file — the measured difference on a 2,000-doc repo is 950ms of
102
+ // rewriting versus 120ms.
103
+ //
104
+ // The comparison mirrors the resolver: case-folded (a case-insensitive
105
+ // filesystem resolves `FOO.MD` to `foo.md`, and folding can only over-include),
106
+ // and with the same backslash escapes unwound, so `my\ plan.md` still matches
107
+ // `my plan.md`. Percent-encoding needs no handling — the resolver does not
108
+ // decode it either, so `foo%20bar.md` never resolves in the first place.
109
+ //
110
+ // The boundary: a symlink whose name differs from its target's. Aliases inside
111
+ // the corpus are covered by `names`; a symlink that is NOT itself a collected
112
+ // doc is not, so any repo that symlinks docs at all fails open to the full walk.
113
+ function mayReferenceIdentity(content, oldIdentity, oldPath, identities) {
114
+ if (!identities?.names || identities.symlinked) return true;
115
+ const names = identities.names.get(oldIdentity);
116
+ const haystack = (content.includes('\\') ? content.replace(/\\([\s()[\]<>])/g, '$1') : content).toLowerCase();
117
+ if (haystack.includes(path.basename(oldPath).toLowerCase())) return true;
118
+ if (names) for (const name of names) if (haystack.includes(name)) return true;
119
+ return false;
120
+ }
121
+
50
122
  // Both interpretations are evaluated. A local document wins only when the
51
123
  // repo-relative spelling is absent or names the same identity; disagreement is
52
124
  // rejected rather than guessed.
53
125
  export function resolveReferenceIdentity(token, documentPath, repoRoot, identities) {
54
126
  if (!token || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(token)) return null;
55
127
  const clean = token.replace(/[?#].*$/, '').replace(/\\([\s()[\]<>])/g, '$1');
56
- const local = canonicalExisting(path.resolve(path.dirname(documentPath), clean));
57
- const repository = canonicalExisting(path.resolve(repoRoot, clean.replace(/^\/+/, '')));
58
- const localExists = identities.has(local);
59
- const repositoryExists = identities.has(repository);
60
- if (localExists && repositoryExists && local !== repository) {
128
+ const memo = identities?.canonical ?? null;
129
+ const local = matchIdentity(canonicalExisting(path.resolve(path.dirname(documentPath), clean), memo), identities);
130
+ const repository = matchIdentity(canonicalExisting(path.resolve(repoRoot, clean.replace(/^\/+/, '')), memo), identities);
131
+ if (local && repository && local !== repository) {
61
132
  throw new AmbiguousReferenceError(token, documentPath, local, repository);
62
133
  }
63
- return localExists ? local : (repositoryExists ? repository : null);
134
+ return local ?? repository ?? null;
135
+ }
136
+
137
+ // `realpath` resolves symlinks but NOT case: on a case-insensitive filesystem
138
+ // `realpath("CASING.MD")` hands back the caller's spelling, so an exact compare
139
+ // misses a link that names a real document. Validation disagreed — it resolves
140
+ // with `existsSync`, which does not care about case — so `dotmd check` called
141
+ // such a link fine, a move silently left it pointing at the old path, and only
142
+ // THEN did check call it broken.
143
+ //
144
+ // The tie-break is the inode, not a guess about the filesystem: same device and
145
+ // inode means the two spellings are one file, which is only ever true where the
146
+ // filesystem itself folds case. On a case-sensitive filesystem `Foo.md` and
147
+ // `foo.md` are separate inodes and stay separate here, and two corpus documents
148
+ // that differ only by case poison their shared fold so neither is guessed at.
149
+ function matchIdentity(candidate, identities) {
150
+ if (identities.has(candidate)) return candidate;
151
+ const folded = identities.folded?.get(candidate.toLowerCase());
152
+ if (folded === undefined || folded === AMBIGUOUS_FOLD) return null;
153
+ return sameFileOnDisk(candidate, folded) ? folded : null;
154
+ }
155
+
156
+ function sameFileOnDisk(left, right) {
157
+ try {
158
+ const a = statSync(left);
159
+ const b = statSync(right);
160
+ return a.dev === b.dev && a.ino === b.ino && a.ino !== 0;
161
+ } catch { return false; }
64
162
  }
65
163
 
66
164
  function rewriteToken(token, sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll, format = 'plain') {
@@ -264,7 +362,10 @@ export function rewriteDocumentReferences(content, {
264
362
  }) {
265
363
  const { frontmatter, body } = extractFrontmatter(content);
266
364
  if (!frontmatter) return content;
267
- const oldIdentity = canonicalExisting(oldPath);
365
+ const oldIdentity = canonicalExisting(oldPath, identities?.canonical ?? null);
366
+ // `rebaseAll` rewrites every reference the document holds because the
367
+ // document itself moved, so no single name can gate it.
368
+ if (!rebaseAll && !mayReferenceIdentity(content, oldIdentity, oldPath, identities)) return content;
268
369
  const args = [sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll];
269
370
  const nextFrontmatter = rewriteFrontmatter(frontmatter, referenceFields, args);
270
371
  const nextBody = rewriteMarkdown(body, args);
@@ -273,8 +374,9 @@ export function rewriteDocumentReferences(content, {
273
374
 
274
375
  export function planReferenceMove({ documents, oldPath, newPath, repoRoot, referenceFields = [] }) {
275
376
  const identities = createReferenceIdentitySet(documents.map(document => document.path));
276
- const oldIdentity = canonicalExisting(oldPath);
277
- identities.add(oldIdentity);
377
+ // Register rather than bare-add: the case fold and name index have to know
378
+ // about the moved document too, or a differently-cased link to it misses.
379
+ const oldIdentity = registerIdentity(identities, oldPath);
278
380
  const source = documents.find(document => path.resolve(document.path) === path.resolve(oldPath));
279
381
  if (!source) throw new Error(`Reference move plan is missing source content: ${oldPath}`);
280
382
  const movedContent = rewriteDocumentReferences(source.content, {