dotmd-cli 0.71.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.71.2",
3
+ "version": "0.71.3",
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
  }