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.
- package/CHANGELOG.md +509 -0
- package/README.md +272 -136
- package/docs/adapter-contract.md +1 -1
- package/docs/host-contract.md +7 -1
- package/docs/mutation-contract.md +354 -82
- package/docs/work-claim-contract.md +466 -88
- package/package.json +2 -2
- package/schemas/core-capabilities-response.json +1 -1
- package/schemas/core-envelope.json +4 -3
- package/schemas/index.json +18 -0
- package/schemas/ledger-repair-proposal.json +170 -0
- package/schemas/ledger-repair-request.json +61 -0
- package/schemas/ledger-repair-response.json +90 -0
- package/schemas/report-config-v1.json +4 -0
- package/schemas/report-config-v2.json +5 -0
- package/skills/wowbagger/SKILL.md +242 -59
- package/src/adapter/core-probe.js +3 -4
- package/src/adapter/process-outcome.js +8 -1
- package/src/claim-capabilities.js +3 -3
- package/src/claim-coordinator.js +61 -12
- package/src/claim-journal.js +212 -7
- package/src/claim-prospective.js +1 -28
- package/src/claim-publication.js +412 -78
- package/src/claim-request.js +9 -0
- package/src/claim-store.js +9 -4
- package/src/cli.js +302 -56
- package/src/extensions.js +1 -0
- package/src/git-autocommit.js +106 -43
- package/src/git-reconciliation.js +74 -19
- package/src/git-worktrees.js +73 -0
- package/src/instrumentation.js +1 -0
- package/src/launch.js +2 -2
- package/src/ledger-repair.js +1170 -0
- package/src/mutation.js +73 -15
- package/src/reconciliation-classifier.js +117 -0
- package/src/report-evidence.js +158 -41
- package/src/report-graph.js +201 -73
- package/src/report-html.js +358 -155
- package/src/report-impact.js +106 -0
- package/src/report-selection.js +97 -0
- package/src/report-sequencing.js +4 -4
- package/src/report-svg.js +74 -20
- package/src/report-view.js +14 -1
- package/src/report.js +109 -23
- package/src/version-drift.js +98 -0
- package/src/worktree-identity.js +165 -0
package/src/git-autocommit.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
123
|
-
//
|
|
124
|
-
//
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
145
|
-
// state unchanged, and duplicating that would add a
|
|
146
|
-
// distinguish from the mutation's own refusal.
|
|
147
|
-
const verified = await verifyClaimJournal({
|
|
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
|
-
|
|
151
|
-
|
|
171
|
+
...details,
|
|
172
|
+
retryable: details.claim_verify_reason === 'claim-store-locked',
|
|
152
173
|
});
|
|
153
174
|
}
|
|
154
175
|
|
|
155
|
-
// Reconciliation rewrites the tracked log.
|
|
156
|
-
// that log
|
|
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) =>
|
|
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 = [
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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',
|
|
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,
|
|
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,
|
|
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
|
-
|| !
|
|
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 (
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.', '
|
|
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
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
])
|
|
87
|
-
return
|
|
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
|
-
|
|
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
|
+
}
|
package/src/instrumentation.js
CHANGED
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
|
|
21
|
-
export const MINIMUM_NODE_MAJOR =
|
|
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
|