dotmd-cli 0.71.1 → 0.71.3
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 +72 -5
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
|
@@ -596,7 +596,17 @@ function prepareMoveIndex(source, target, repoRoot, before, options = {}) {
|
|
|
596
596
|
}
|
|
597
597
|
testHooks.beforeGitIndexPrepare?.({ preparedPath, before, prepared: seed });
|
|
598
598
|
const env = { ...process.env, GIT_INDEX_FILE: preparedPath };
|
|
599
|
-
|
|
599
|
+
// A tracked source relocating into an ignored destination is exactly what
|
|
600
|
+
// `git mv` permits: ignore patterns govern NEW untracked paths, not the
|
|
601
|
+
// relocation of content git already tracks. Bare `git add` doesn't make that
|
|
602
|
+
// distinction and refuses ("paths are ignored by one of your .gitignore
|
|
603
|
+
// files"), which failed the whole transaction and rolled the move back — so
|
|
604
|
+
// `dotmd archive` was unusable in any repo that gitignores its docs root
|
|
605
|
+
// while force-tracking the docs inside it. Force only when the source was
|
|
606
|
+
// tracked; an untracked source stays subject to the ignore.
|
|
607
|
+
const sourceTracked = isTracked(paths[0], repoRoot);
|
|
608
|
+
const addArgs = sourceTracked ? ['add', '-f', '-A', '--', paths[1]] : ['add', '-A', '--', paths[1]];
|
|
609
|
+
const result = spawnSync('git', addArgs, {
|
|
600
610
|
cwd: repoRoot,
|
|
601
611
|
encoding: 'utf8',
|
|
602
612
|
env,
|
|
@@ -644,7 +654,40 @@ function prepareMoveIndex(source, target, repoRoot, before, options = {}) {
|
|
|
644
654
|
return prepared;
|
|
645
655
|
}
|
|
646
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
|
+
|
|
647
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) {
|
|
648
691
|
const { indexPath, indexDir, lockPath } = gitIndexLocations(repoRoot);
|
|
649
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.');
|
|
650
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.');
|
|
@@ -666,14 +709,14 @@ function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks
|
|
|
666
709
|
}
|
|
667
710
|
} catch { /* retain original durability error */ }
|
|
668
711
|
}
|
|
669
|
-
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 });
|
|
670
713
|
throw err;
|
|
671
714
|
}
|
|
672
715
|
let published = false;
|
|
673
716
|
try {
|
|
674
717
|
testHooks.afterGitIndexLock?.({ lockPath, expected, desired });
|
|
675
718
|
const current = captureIndexPath(indexPath);
|
|
676
|
-
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 });
|
|
677
720
|
testHooks.afterGitIndexCompare?.({ lockPath, current, desired });
|
|
678
721
|
if (desired.exists) {
|
|
679
722
|
// .git/index is routinely held open by concurrent git processes and IDE git
|
|
@@ -688,6 +731,7 @@ function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks
|
|
|
688
731
|
lockOwned = false;
|
|
689
732
|
}
|
|
690
733
|
published = true;
|
|
734
|
+
notePublished(true);
|
|
691
735
|
fsyncIndexDirectory(indexDir, testHooks, 'publication');
|
|
692
736
|
testHooks.afterGitIndexPublication?.({ indexPath, desired });
|
|
693
737
|
return desired;
|
|
@@ -707,7 +751,12 @@ function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks
|
|
|
707
751
|
}
|
|
708
752
|
|
|
709
753
|
export function stageMovePathsCas(source, target, repoRoot, before, options = {}) {
|
|
710
|
-
|
|
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); }
|
|
711
760
|
try { return publishIndexGeneration(repoRoot, before, prepared.generation, prepared, options.testHooks); }
|
|
712
761
|
finally {
|
|
713
762
|
if (prepared.work && prepared.work.path !== prepared.path) unlinkPrepared(prepared.work, options.testHooks, 'working-index-delete');
|
|
@@ -736,6 +785,17 @@ export function restoreGitIndexCas(before, ownedAfter, repoRoot, options = {}) {
|
|
|
736
785
|
}
|
|
737
786
|
}
|
|
738
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
|
+
|
|
739
799
|
export function reclaimPreparedGitIndex(manifestGitIndex, repoRoot, options = {}) {
|
|
740
800
|
const prepared = manifestGitIndex?.prepared;
|
|
741
801
|
if (!prepared) return { cleaned: false, retainedPaths: [] };
|
|
@@ -744,7 +804,14 @@ export function reclaimPreparedGitIndex(manifestGitIndex, repoRoot, options = {}
|
|
|
744
804
|
if (manifestGitIndex.before?.indexPath !== indexPath || prepared.generation?.indexPath !== indexPath) throw new Error('Recovery environment selects a different Git index than the abandoned transaction.');
|
|
745
805
|
if (path.dirname(prepared.tempPath) !== indexDir || !path.basename(prepared.tempPath).startsWith('.dotmd-index-')) throw new Error('Abandoned prepared Git index path is unsafe.');
|
|
746
806
|
if (!existsSync(prepared.path)) {
|
|
747
|
-
|
|
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.');
|
|
748
815
|
if (existsSync(prepared.tempPath)) retainedPaths.push(prepared.tempPath);
|
|
749
816
|
return { cleaned: false, retainedPaths };
|
|
750
817
|
}
|