wowbagger 0.1.0-alpha.9 → 0.5.0

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +509 -0
  2. package/README.md +272 -136
  3. package/docs/adapter-contract.md +1 -1
  4. package/docs/host-contract.md +7 -1
  5. package/docs/mutation-contract.md +354 -82
  6. package/docs/work-claim-contract.md +466 -88
  7. package/package.json +2 -2
  8. package/schemas/core-capabilities-response.json +1 -1
  9. package/schemas/core-envelope.json +4 -3
  10. package/schemas/index.json +18 -0
  11. package/schemas/ledger-repair-proposal.json +170 -0
  12. package/schemas/ledger-repair-request.json +61 -0
  13. package/schemas/ledger-repair-response.json +90 -0
  14. package/schemas/report-config-v1.json +4 -0
  15. package/schemas/report-config-v2.json +5 -0
  16. package/skills/wowbagger/SKILL.md +242 -59
  17. package/src/adapter/core-probe.js +3 -4
  18. package/src/adapter/process-outcome.js +8 -1
  19. package/src/claim-capabilities.js +3 -3
  20. package/src/claim-coordinator.js +61 -12
  21. package/src/claim-journal.js +212 -7
  22. package/src/claim-prospective.js +1 -28
  23. package/src/claim-publication.js +412 -78
  24. package/src/claim-request.js +9 -0
  25. package/src/claim-store.js +9 -4
  26. package/src/cli.js +302 -56
  27. package/src/extensions.js +1 -0
  28. package/src/git-autocommit.js +106 -43
  29. package/src/git-reconciliation.js +74 -19
  30. package/src/git-worktrees.js +73 -0
  31. package/src/instrumentation.js +1 -0
  32. package/src/launch.js +2 -2
  33. package/src/ledger-repair.js +1170 -0
  34. package/src/mutation.js +73 -15
  35. package/src/reconciliation-classifier.js +117 -0
  36. package/src/report-evidence.js +158 -41
  37. package/src/report-graph.js +201 -73
  38. package/src/report-html.js +358 -155
  39. package/src/report-impact.js +106 -0
  40. package/src/report-selection.js +97 -0
  41. package/src/report-sequencing.js +4 -4
  42. package/src/report-svg.js +74 -20
  43. package/src/report-view.js +14 -1
  44. package/src/report.js +109 -23
  45. package/src/version-drift.js +98 -0
  46. package/src/worktree-identity.js +165 -0
@@ -50,6 +50,8 @@ const GIT_ENVIRONMENT = Object.fromEntries(
50
50
  const COMMIT_SUBJECT_VERBS = {
51
51
  create: 'create',
52
52
  transition: 'transition',
53
+ 'parent-migrate': 'parent-migrate',
54
+ snooze: 'snooze',
53
55
  patch: 'patch',
54
56
  'publish-claimed': 'publish claimed',
55
57
  };
@@ -117,17 +119,28 @@ async function finalize({
117
119
  }) {
118
120
  const { root, prefix } = placement;
119
121
  const logPath = ledgerRelative(ledgerDirectory, claimReconcileLogPath(path.resolve(ledgerDirectory), namespace));
120
- const journalOwned = command !== 'create';
122
+ // Every mutation, create included, appends its own journal entries and
123
+ // rebuilds the derived reconciliation log, so every commit set carries that
124
+ // log alongside the item.
125
+ //
126
+ // Log dirt that predates the invocation is a separate question. A mutation on
127
+ // an existing item rebuilds residue its own prior claim operations left in
128
+ // this checkout; a create allocates a fresh identity and has no standing to
129
+ // absorb bytes that were already there, so it refuses instead.
130
+ const absorbsPriorLogDirt = command !== 'create';
121
131
 
122
- // Preflight, before any Wowbagger write. Any staged path anywhere and any
123
- // dirty path under the ledger refuses: a broad commit would otherwise absorb
124
- // foreign work, and this profile has no temporary index to protect it.
132
+ // Preflight, before any Wowbagger write. Any staged path and every dirty
133
+ // ledger path except an absorbable derived log refuse: a broad commit would
134
+ // otherwise take in foreign work.
125
135
  const before = await inspectWorktree(root, prefix);
126
136
  if (before.staged.length > 0) {
127
137
  return shape.preflightFailed('staged-paths-present', { staged_paths: bounded(before.staged) });
128
138
  }
129
- if (before.dirtyLedger.length > 0) {
130
- return shape.preflightFailed('ledger-not-clean', { dirty_paths: bounded(before.dirtyLedger) });
139
+ const foreignDirtyBefore = before.dirtyLedger.filter((entry) => (
140
+ !(absorbsPriorLogDirt && entry === logPath)
141
+ ));
142
+ if (foreignDirtyBefore.length > 0) {
143
+ return shape.preflightFailed('ledger-not-clean', { dirty_paths: bounded(foreignDirtyBefore) });
131
144
  }
132
145
  if (!await hasCommitIdentity(root)) {
133
146
  return shape.preflightFailed('identity-unavailable', {});
@@ -141,24 +154,32 @@ async function finalize({
141
154
  //
142
155
  // A nonzero claim-verify is exactly the unsafe claim state, so this one check
143
156
  // is the whole gate. An invalid ledger is deliberately NOT re-checked here:
144
- // every one of the four mutations already refuses it with ledger-invalid and
145
- // state unchanged, and duplicating that would add a branch no fixture can
146
- // distinguish from the mutation's own refusal.
147
- const verified = await verifyClaimJournal({ ledgerDirectory, gitCommonDir, namespace, targetItemId });
157
+ // every one of the auto-commit mutations already refuses it with
158
+ // ledger-invalid and state unchanged, and duplicating that would add a
159
+ // branch no fixture can distinguish from the mutation's own refusal.
160
+ const verified = await verifyClaimJournal({
161
+ ledgerDirectory,
162
+ gitCommonDir,
163
+ namespace,
164
+ targetItemId,
165
+ writeLogOnUnsafe: false,
166
+ writeLogWhenEmpty: false,
167
+ });
148
168
  if (verified.exit !== 0 || verified.stdout.ok !== true) {
169
+ const details = claimVerificationFailureDetails(verified);
149
170
  return shape.preflightFailed('claim-state-unreconciled', {
150
- claim_verify_code: verified.stdout.error?.code ?? null,
151
- findings: verified.stdout.result?.findings ?? [],
171
+ ...details,
172
+ retryable: details.claim_verify_reason === 'claim-store-locked',
152
173
  });
153
174
  }
154
175
 
155
- // Reconciliation rewrites the tracked log. Only a command that will commit
156
- // that log may proceed with it dirty; create commits the item alone.
176
+ // Reconciliation rewrites the tracked log. Every command here will commit
177
+ // that log, so its own rewrite is never stray; anything else is.
157
178
  const afterVerify = await inspectWorktree(root, prefix);
158
179
  if (afterVerify.staged.length > 0) {
159
180
  return shape.preflightFailed('staged-paths-present', { staged_paths: bounded(afterVerify.staged) });
160
181
  }
161
- const strayBefore = afterVerify.dirtyLedger.filter((entry) => !(journalOwned && entry === logPath));
182
+ const strayBefore = afterVerify.dirtyLedger.filter((entry) => entry !== logPath);
162
183
  if (strayBefore.length > 0) {
163
184
  return shape.preflightFailed('ledger-not-clean', { dirty_paths: bounded(strayBefore) });
164
185
  }
@@ -194,10 +215,13 @@ async function finalize({
194
215
  });
195
216
  }
196
217
  published.revision = identity.revision;
197
-
198
- const commitSet = [published.path, ...(journalOwned ? [logPath] : [])].sort(compareText);
218
+ const itemChanged = await itemDiffersFromHead(root, prefix, published.path, published.revision);
219
+ const commitSet = [
220
+ ...(itemChanged ? [published.path] : []),
221
+ logPath,
222
+ ].sort(compareText);
199
223
  const subject = commitSubject(command, published.number, published.id);
200
- const witness = journalOwned ? shape.terminalWitness(outcome, published) : null;
224
+ const witness = shape.terminalWitness(outcome, published);
201
225
  const context = {
202
226
  command,
203
227
  head,
@@ -216,7 +240,6 @@ async function finalize({
216
240
  context,
217
241
  commitSet,
218
242
  digests,
219
- journalOwned,
220
243
  ledgerDirectory,
221
244
  logPath,
222
245
  ownArtifacts: shape.recoveryArtifactPaths(outcome),
@@ -271,6 +294,7 @@ async function finalize({
271
294
  gitCommonDir,
272
295
  namespace,
273
296
  targetItemId: published.id,
297
+ writeLogWhenEmpty: true,
274
298
  });
275
299
  const reconciliation = reconciliationFailureReason(reconciled, commit.commit, context.operationId, published.id);
276
300
  if (reconciliation) {
@@ -279,8 +303,7 @@ async function finalize({
279
303
  published_revision: published.revision,
280
304
  git_commit: commit.commit,
281
305
  commit_paths: commitSet,
282
- reason: reconciliation.reason,
283
- findings: reconciliation.findings,
306
+ ...reconciliation,
284
307
  });
285
308
  }
286
309
  return shape.decorate(outcome, evidence);
@@ -289,7 +312,7 @@ async function finalize({
289
312
  // Every check that must hold between publication and staging. Returning a
290
313
  // reason here means the item is published and no Git write has happened yet.
291
314
  async function prepareCommitSet({
292
- context, commitSet, digests, journalOwned, ledgerDirectory, logPath, ownArtifacts, prefix, root, witness,
315
+ context, commitSet, digests, ledgerDirectory, logPath, ownArtifacts, prefix, root, witness,
293
316
  }) {
294
317
  let itemBytes;
295
318
  try {
@@ -314,7 +337,6 @@ async function prepareCommitSet({
314
337
  ));
315
338
  if (stray.length > 0) return { stage: 'prepare-commit-set', reason: 'tree-changed' };
316
339
 
317
- if (!journalOwned) return null;
318
340
  let logBytes;
319
341
  try {
320
342
  logBytes = await readFile(path.join(path.resolve(ledgerDirectory), logPath));
@@ -388,12 +410,27 @@ async function verifyCommit(root, commit, parent, subject, gitPaths, digests, pr
388
410
  return { ok: true, reason: null };
389
411
  }
390
412
 
413
+ function claimVerificationFailureDetails(verified) {
414
+ const error = verified.stdout.error;
415
+ const findings = verified.stdout.result?.findings ?? error?.details?.findings;
416
+ return {
417
+ ...(error?.code ? { claim_verify_code: error.code } : {}),
418
+ ...(error?.details?.reason ? { claim_verify_reason: error.details.reason } : {}),
419
+ // Why the claim store could not be read travels with the refusal: an
420
+ // operator told only that a store is unreadable cannot find the two
421
+ // worktrees answering to one identity.
422
+ ...(error?.details?.identity_diagnostic
423
+ ? { identity_diagnostic: error.details.identity_diagnostic }
424
+ : {}),
425
+ ...(findings ? { findings } : {}),
426
+ };
427
+ }
428
+
391
429
  function reconciliationFailureReason(verified, commit, operationId, itemId) {
392
430
  if (verified.exit !== 0 || verified.stdout.ok !== true) {
393
- return { reason: 'claim-verify-refused', findings: verified.stdout.result?.findings ?? [] };
431
+ return { reason: 'claim-verify-refused', ...claimVerificationFailureDetails(verified) };
394
432
  }
395
433
  const result = verified.stdout.result;
396
- if (result.findings.length > 0) return { reason: 'claim-findings-present', findings: result.findings };
397
434
  if (result.ledger_validation.valid !== true) return { reason: 'ledger-invalid', findings: [] };
398
435
  if (operationId === null) return null;
399
436
  const row = result.publications.find((entry) => (
@@ -509,6 +546,13 @@ async function locateItem(ledgerDirectory, itemId) {
509
546
  };
510
547
  }
511
548
 
549
+ // An auto-commit republishing the bytes HEAD already carries has no item change
550
+ // to stage, so the item joins the commit set only when it actually differs.
551
+ async function itemDiffersFromHead(root, prefix, itemPath, publishedRevision) {
552
+ const observed = await git(root, ['show', `HEAD:${prefix}${itemPath}`]);
553
+ return observed.code !== 0 || revisionFor(observed.stdout) !== publishedRevision;
554
+ }
555
+
512
556
  // The one test-only seam in this module: it pauses between item publication and
513
557
  // staging so a fixture can prove that a late foreign change, a moved HEAD, or a
514
558
  // held index becomes a named failure instead of being absorbed into the commit.
@@ -633,7 +677,7 @@ function coreShape(command) {
633
677
  identityDetails: (identity) => ({ id: identity.id }),
634
678
  operationId: () => null,
635
679
  preflightFailed: (reason, details) => coreError('auto-commit-preflight-failed',
636
- 'Auto-commit refused before the mutation ran.', 'unchanged', 4, { reason, ...details }),
680
+ 'Auto-commit refused before the mutation ran.', 'unchanged', 4, preflightDetails(reason, details)),
637
681
  publishedIdentity: (outcome) => (outcome.ok === true
638
682
  ? { id: outcome.item.id, revision: outcome.item.revision }
639
683
  : { id: outcome.error.details.id, revision: outcome.error.details.revision ?? null }),
@@ -660,6 +704,12 @@ function coreError(code, message, state, exit, details) {
660
704
  return { ok: false, exit, state, error: { code, message, details } };
661
705
  }
662
706
 
707
+ // Both shapes report the same preflight refusal. Mutex contention is retryable
708
+ // here; an underlying claim-store lock overrides this default in `details`.
709
+ function preflightDetails(reason, details) {
710
+ return { reason, retryable: reason === 'mutex-held', ...details };
711
+ }
712
+
663
713
  // Every envelope this shape builds keeps the claimed-publication domain and its
664
714
  // top-level operation_id, so a failure answers where the success would have.
665
715
  function publicationShape(operationId) {
@@ -697,7 +747,7 @@ function publicationShape(operationId) {
697
747
  }),
698
748
  operationId: (outcome) => outcome.stdout.operation_id ?? null,
699
749
  preflightFailed: (reason, details) => error('auto-commit-preflight-failed',
700
- 'Auto-commit refused before the mutation ran.', 'unchanged', 4, { reason, ...details }),
750
+ 'Auto-commit refused before the mutation ran.', 'unchanged', 4, preflightDetails(reason, details)),
701
751
  publishedIdentity: (outcome) => ({
702
752
  id: outcome.stdout.result?.item_id ?? outcome.stdout.error?.details?.item_id,
703
753
  revision: outcome.stdout.result?.committed_revision ?? null,
@@ -808,7 +858,6 @@ export async function finalizeFromRecoveryToken({ ledgerDirectory, token }) {
808
858
 
809
859
  async function runFinalize({ gitCommonDir, ledgerDirectory, namespace, payload, placement }) {
810
860
  const { root, prefix } = placement;
811
- const journalOwned = payload.command !== 'create';
812
861
  const logPath = ledgerRelative(ledgerDirectory, claimReconcileLogPath(path.resolve(ledgerDirectory), namespace));
813
862
  const witness = payload.terminal_witness ?? null;
814
863
 
@@ -820,27 +869,43 @@ async function runFinalize({ gitCommonDir, ledgerDirectory, namespace, payload,
820
869
  } catch {
821
870
  return finalizeRefused('item-not-readable', {});
822
871
  }
823
- const commitSet = [derived.path, ...(journalOwned ? [logPath] : [])].sort(compareText);
824
- const tokenPaths = payload.commit_set.map((entry) => entry.path);
825
872
  const subject = commitSubject(payload.command, derived.number, derived.id);
873
+ const digests = new Map(payload.commit_set.map((entry) => [entry.path, entry.sha256]));
874
+ digests.set(derived.path, payload.published_revision);
875
+ const head = await headOid(root);
876
+ if (head === null) return finalizeRefused('unborn-head', {});
877
+ const headUnchanged = head === payload.pre_commit_head;
878
+
879
+ const tokenPaths = payload.commit_set.map((entry) => entry.path);
880
+ let commitSet;
881
+ if (headUnchanged) {
882
+ const itemChanged = await itemDiffersFromHead(root, prefix, derived.path, payload.published_revision);
883
+ commitSet = [
884
+ ...(itemChanged ? [derived.path] : []),
885
+ logPath,
886
+ ].sort(compareText);
887
+ } else {
888
+ commitSet = [...tokenPaths].sort(compareText);
889
+ }
890
+ // A token is only ever issued by the auto-commit path above, where every
891
+ // command owns its reconciliation log, so recovery accepts the same two
892
+ // shapes for every command.
893
+ const allowedCommitSets = [[logPath], [derived.path, logPath]];
894
+ const tokenMatchesMutation = allowedCommitSets.some((allowed) => sameSet(tokenPaths, allowed));
826
895
  if (payload.item_path !== derived.path
827
- || !sameSet(tokenPaths, commitSet)
896
+ || !tokenMatchesMutation
897
+ || (headUnchanged && !sameSet(tokenPaths, commitSet))
828
898
  || payload.message !== subject) {
829
899
  return finalizeRefused('commit-set-mismatch', {
830
900
  expected_path: derived.path,
831
901
  commit_paths: commitSet,
832
902
  });
833
903
  }
834
-
835
- const digests = new Map(payload.commit_set.map((entry) => [entry.path, entry.sha256]));
836
- digests.set(derived.path, payload.published_revision);
837
904
  const gitPaths = commitSet.map((entry) => `${prefix}${entry}`);
838
- const head = await headOid(root);
839
- if (head === null) return finalizeRefused('unborn-head', {});
840
905
 
841
906
  // The commit may already exist: a lost response is the same condition as a
842
907
  // failed commit, and both recover through this one command.
843
- if (head !== payload.pre_commit_head) {
908
+ if (!headUnchanged) {
844
909
  const verification = await verifyCommit(
845
910
  root, head, payload.pre_commit_head, subject, gitPaths, digests, prefix, witness,
846
911
  );
@@ -851,7 +916,7 @@ async function runFinalize({ gitCommonDir, ledgerDirectory, namespace, payload,
851
916
  }
852
917
 
853
918
  const refusal = await finalizePreconditions({
854
- commitSet, derived, digests, gitPaths, journalOwned, ledgerDirectory, logPath, payload, prefix, root, witness,
919
+ commitSet, derived, digests, gitPaths, ledgerDirectory, logPath, payload, prefix, root, witness,
855
920
  });
856
921
  if (refusal) return finalizeRefused(refusal.reason, refusal.details);
857
922
 
@@ -893,7 +958,7 @@ async function runFinalize({ gitCommonDir, ledgerDirectory, namespace, payload,
893
958
  }
894
959
 
895
960
  async function finalizePreconditions({
896
- commitSet, derived, digests, gitPaths, journalOwned, ledgerDirectory, logPath, payload, prefix, root, witness,
961
+ commitSet, derived, digests, gitPaths, ledgerDirectory, logPath, payload, prefix, root, witness,
897
962
  }) {
898
963
  let itemBytes;
899
964
  try {
@@ -914,7 +979,6 @@ async function finalizePreconditions({
914
979
  }
915
980
  const stray = observed.dirtyLedger.filter((entry) => !commitSet.includes(entry));
916
981
  if (stray.length > 0) return { reason: 'ledger-not-clean', details: { dirty_paths: bounded(stray) } };
917
- if (!journalOwned) return null;
918
982
  let logBytes;
919
983
  try {
920
984
  logBytes = await readFile(path.join(path.resolve(ledgerDirectory), logPath));
@@ -951,8 +1015,7 @@ async function finalizeVerified({
951
1015
  ...(operationId === null ? {} : { operation_id: operationId }),
952
1016
  git_commit: commit,
953
1017
  commit_paths: commitSet,
954
- reason: reconciliation.reason,
955
- findings: reconciliation.findings,
1018
+ ...reconciliation,
956
1019
  });
957
1020
  }
958
1021
  return {
@@ -978,7 +1041,7 @@ async function finalizeVerified({
978
1041
 
979
1042
  function commitFailedInWorkClaim(payload, commitSet, digests, failure) {
980
1043
  return workClaimError('git-commit-failed',
981
- 'The item is published, but its Git commit was not established.', 'unchanged', 6, {
1044
+ 'The item is published, but its Git commit was not established.', 'committed', 6, {
982
1045
  ledger_namespace: payload.ledger_namespace,
983
1046
  item_id: payload.item_id,
984
1047
  published_revision: payload.published_revision,
@@ -2,6 +2,7 @@ import { execFile, spawn } from 'node:child_process';
2
2
  import { realpath } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
+ import { listWorktrees } from './git-worktrees.js';
5
6
  import { recordCount, timePhase } from './instrumentation.js';
6
7
  import { revisionFor } from './mutation.js';
7
8
 
@@ -63,30 +64,84 @@ export async function readGitTreeFile(ledgerDirectory, treeish, relativePath) {
63
64
  return gitBuffer(root, ['show', `${treeish}:${gitPath}`]);
64
65
  }
65
66
 
66
- // Which local ref carries the revision the journal expects. A finding may name
67
- // only ownership it can prove from reachable history, so a revision no ref
68
- // contains stays unattributed rather than guessed from the item path.
67
+ // Which active worktree carries the revision the journal expects. Reachability
68
+ // alone proves nothing about ownership: a tag, a remote-tracking ref, or a
69
+ // branch nobody has checked out names no worktree that could publish anything.
70
+ // So the evidence is the worktree roster, this worktree first, and a revision
71
+ // reachable only outside it stays unowned rather than attributed to a ref.
72
+ //
73
+ // Returns `{ kind, ref?, commit? }`, where `kind` is one of:
74
+ // `current` this worktree's own history reaches the revision;
75
+ // `named-sibling` a live sibling worktree on a branch reaches it;
76
+ // `reachable-unowned` Git reaches it, but no live named worktree does;
77
+ // `unreachable` no reachable commit carries the expected bytes.
69
78
  export async function findRevisionOwner(ledgerDirectory, itemPath, expectedRevision) {
70
79
  const { root, relativeLedger } = await resolveWorktreeLedger(ledgerDirectory);
71
80
  const gitPath = toGitPath(path.join(relativeLedger, itemPath));
72
- for (const commit of gitLines(await gitText(root, ['rev-list', '--all', '--', gitPath]))) {
73
- let bytes;
74
- try {
75
- bytes = await gitBuffer(root, ['show', `${commit}:${gitPath}`]);
76
- } catch {
77
- // The path does not exist in this commit, so it cannot carry the revision.
78
- continue;
81
+ const carrierIn = revisionCarrier(root, gitPath, expectedRevision);
82
+ const current = await carrierIn(await pathHistory(root, 'HEAD', gitPath));
83
+ if (current) return { kind: 'current', commit: current };
84
+ const live = (await listWorktrees(root)).filter((record) => (
85
+ !record.bare && !record.prunable && record.head !== null
86
+ ));
87
+ // One expected revision can sit in several live worktrees at once. Branch ref
88
+ // then path is the only ordering both stable across runs and independent of
89
+ // the order Git happened to register the worktrees in.
90
+ for (const record of live.filter((entry) => entry.branch !== null).sort(byBranchThenPath)) {
91
+ const commit = await carrierIn(await pathHistory(root, record.head, gitPath));
92
+ if (commit) return { kind: 'named-sibling', ref: record.branch, commit };
93
+ }
94
+ // A detached worktree is a real writer with no ref to wait on, so it proves
95
+ // reachability and nothing else. So does any remaining ref: a tag, a
96
+ // remote-tracking ref, or a branch no worktree has checked out.
97
+ for (const record of live.filter((entry) => entry.branch === null)) {
98
+ const commit = await carrierIn(await pathHistory(root, record.head, gitPath));
99
+ if (commit) return { kind: 'reachable-unowned', commit };
100
+ }
101
+ const reachable = await carrierIn(await pathHistory(root, '--all', gitPath));
102
+ return reachable ? { kind: 'reachable-unowned', commit: reachable } : { kind: 'unreachable' };
103
+ }
104
+
105
+ // The first commit of a history whose item bytes are the expected revision, or
106
+ // null. Sibling histories overlap almost entirely and every answer costs a
107
+ // `git show`, so one commit is read at most once per lookup.
108
+ function revisionCarrier(root, gitPath, expectedRevision) {
109
+ const answered = new Map();
110
+ return async (commits) => {
111
+ for (const commit of commits) {
112
+ if (!answered.has(commit)) {
113
+ answered.set(commit, await carriesRevision(root, commit, gitPath, expectedRevision));
114
+ }
115
+ if (answered.get(commit)) return commit;
79
116
  }
80
- if (revisionFor(bytes) !== expectedRevision) continue;
81
- const [ownerRef] = gitLines(await gitText(root, [
82
- 'for-each-ref',
83
- '--contains',
84
- commit,
85
- '--format=%(refname)',
86
- ])).sort();
87
- return ownerRef ? { owner_ref: ownerRef, owner_commit: commit } : { owner_unavailable: true };
117
+ return null;
118
+ };
119
+ }
120
+
121
+ async function carriesRevision(root, commit, gitPath, expectedRevision) {
122
+ try {
123
+ const bytes = await gitBuffer(root, ['show', `${commit}:${gitPath}`]);
124
+ return revisionFor(bytes) === expectedRevision;
125
+ } catch {
126
+ // The path does not exist in this commit, so it cannot carry the revision.
127
+ return false;
88
128
  }
89
- return { owner_unavailable: true };
129
+ }
130
+
131
+ // The commits one revision range reaches that touch the item path, newest
132
+ // first. An unborn `HEAD` names no commit, so a worktree Git cannot resolve
133
+ // reaches nothing.
134
+ async function pathHistory(root, revisionRange, gitPath) {
135
+ try {
136
+ return gitLines(await gitText(root, ['rev-list', revisionRange, '--', gitPath]));
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+
142
+ function byBranchThenPath(a, b) {
143
+ if (a.branch !== b.branch) return a.branch < b.branch ? -1 : 1;
144
+ return a.path < b.path ? -1 : 1;
90
145
  }
91
146
 
92
147
  async function gitText(cwd, argumentsList) {
@@ -0,0 +1,73 @@
1
+ import { execFile } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { promisify } from 'node:util';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+ // Git answers `GIT_DIR` and its siblings before it answers `cwd`, so an
7
+ // inherited value would report a repository the caller never named.
8
+ const GIT_ENVIRONMENT = Object.fromEntries(
9
+ Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
10
+ );
11
+
12
+ // Every worktree of one repository owns a private Git directory: the common
13
+ // directory itself for the main worktree, `<common>/worktrees/<name>` for a
14
+ // linked one. Anything a single worktree must not share with its siblings
15
+ // belongs there. Git resolves it; nothing here parses a `.git` file.
16
+ export async function resolvePrivateGitDir(directory) {
17
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--absolute-git-dir'], {
18
+ cwd: path.resolve(directory),
19
+ encoding: 'utf8',
20
+ env: GIT_ENVIRONMENT,
21
+ maxBuffer: 1024 * 1024,
22
+ });
23
+ return stdout.trim();
24
+ }
25
+
26
+ // The registered worktrees of one repository, as Git itself reports them.
27
+ // `--porcelain -z` terminates every attribute with NUL and separates records
28
+ // with one extra NUL, so a worktree path holding a newline still parses; the
29
+ // newline-delimited spelling cannot promise that. Git emits `worktree <path>`
30
+ // first, then `HEAD <oid>` and either `branch <ref>` or `detached`, plus the
31
+ // bare, locked, and prunable markers where they apply. An attribute before the
32
+ // first `worktree` line cannot exist, and one that does belongs to no record.
33
+ export async function listWorktrees(directory) {
34
+ const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain', '-z'], {
35
+ cwd: path.resolve(directory),
36
+ encoding: 'utf8',
37
+ env: GIT_ENVIRONMENT,
38
+ maxBuffer: 16 * 1024 * 1024,
39
+ });
40
+ const worktrees = [];
41
+ let record = null;
42
+ for (const field of stdout.split('\0')) {
43
+ if (field === '') {
44
+ if (record) worktrees.push(record);
45
+ record = null;
46
+ continue;
47
+ }
48
+ const boundary = field.indexOf(' ');
49
+ const name = boundary === -1 ? field : field.slice(0, boundary);
50
+ const value = boundary === -1 ? '' : field.slice(boundary + 1);
51
+ if (name === 'worktree') {
52
+ record = {
53
+ path: path.normalize(value),
54
+ head: null,
55
+ branch: null,
56
+ detached: false,
57
+ bare: false,
58
+ locked: false,
59
+ prunable: false,
60
+ };
61
+ continue;
62
+ }
63
+ if (!record) continue;
64
+ if (name === 'HEAD') record.head = value;
65
+ else if (name === 'branch') record.branch = value;
66
+ else if (name === 'detached') record.detached = true;
67
+ else if (name === 'bare') record.bare = true;
68
+ else if (name === 'locked') record.locked = true;
69
+ else if (name === 'prunable') record.prunable = true;
70
+ }
71
+ if (record) worktrees.push(record);
72
+ return worktrees;
73
+ }
@@ -16,6 +16,7 @@ const COUNTER_NAMES = [
16
16
  'item_lock_fsyncs',
17
17
  'item_lock_releases',
18
18
  'namespace_lock_acquisitions',
19
+ 'worktree_identity_lock_acquisitions',
19
20
  'head_tree_entries',
20
21
  'head_blobs_read',
21
22
  'head_bytes_read',
package/src/launch.js CHANGED
@@ -17,8 +17,8 @@ import { fileURLToPath } from 'node:url';
17
17
 
18
18
  // The engine floor the package declares. A host that resolves its own Node
19
19
  // executable rather than reusing `process.execPath` must check it, because a
20
- // Node 18 executable will fail somewhere inside the core rather than at launch.
21
- export const MINIMUM_NODE_MAJOR = 20;
20
+ // Node 22 executable will fail somewhere inside the core rather than at launch.
21
+ export const MINIMUM_NODE_MAJOR = 24;
22
22
 
23
23
  // The absolute path of the core script. It is resolved from this module's own
24
24
  // URL, so it is correct for a global install, a local install, a workspace