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 +1 -1
- package/src/atomic-mutation.mjs +96 -12
- package/src/git.mjs +61 -4
- package/src/reference-planner.mjs +118 -16
package/package.json
CHANGED
package/src/atomic-mutation.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1405
|
-
transaction
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
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, {
|