@afokapu/atdd-bun 0.10.105 → 0.10.107
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/detectors/bun_interlocking_coverage/_shared/interlocking.mjs +30 -10
- package/integrity.json +4 -4
- package/lib/journey-chain.mjs +107 -0
- package/package.json +1 -1
- package/src/hooks.ts +42 -15
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// data (snake_case, plan/_trains/_interlockings/**); the e2e tests are Bun/TS under e2e/**.
|
|
9
9
|
|
|
10
10
|
import { excludedPath } from "../../../lib/scan.mjs";
|
|
11
|
-
import { directInterlockingExecutionProofs, entryDelegatedAction, provenJourneyAction } from "../../../lib/journey-chain.mjs";
|
|
11
|
+
import { directInterlockingExecutionProofs, entryDelegatedAction, provenJourneyAction, transparentCallbackAliases } from "../../../lib/journey-chain.mjs";
|
|
12
12
|
import { existsSync, readFileSync, statSync, readdirSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { dirname, join, resolve, sep } from "node:path";
|
|
14
14
|
|
|
@@ -529,7 +529,11 @@ function firstRebinding(text, name, executeArgument, returns) {
|
|
|
529
529
|
function resolutionBinding(after, resolution, executeArgument) {
|
|
530
530
|
const statement = after.slice(enclosingEnd(after) + 1);
|
|
531
531
|
const scope = statement.slice(0, enclosingEnd(statement));
|
|
532
|
-
return {
|
|
532
|
+
return {
|
|
533
|
+
scope,
|
|
534
|
+
offset: after.length - statement.length,
|
|
535
|
+
bound: scope.slice(0, firstRebinding(scope, resolution, executeArgument, topLevelReturns(scope))),
|
|
536
|
+
};
|
|
533
537
|
}
|
|
534
538
|
|
|
535
539
|
function trainExecutionForms(resolution) {
|
|
@@ -541,15 +545,20 @@ function trainExecutionForms(resolution) {
|
|
|
541
545
|
return { legacy, constructed, declaration: `${constructed}${r}\\s*(?:,|\\))` };
|
|
542
546
|
}
|
|
543
547
|
|
|
544
|
-
|
|
548
|
+
// `aliases` are the transparent callback-return aliases of this resolution's execution (see
|
|
549
|
+
// transparentCallbackAliases); only one declared inside the resolution's binding names the execution.
|
|
550
|
+
function resolutionTrainExecution(after, resolution, aliases = [], base = 0) {
|
|
545
551
|
const { legacy, constructed, declaration } = trainExecutionForms(resolution);
|
|
546
|
-
const { scope, bound } = resolutionBinding(after, resolution, constructed);
|
|
552
|
+
const { scope, bound, offset } = resolutionBinding(after, resolution, constructed);
|
|
547
553
|
const assignment = `\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*(?:await\\s+)?`;
|
|
548
554
|
const assigned = new RegExp(`${assignment}${legacy}`).exec(after) ?? new RegExp(`${assignment}${declaration}`).exec(bound);
|
|
549
555
|
const returns = `\\breturn\\s+(?:await\\s+)?`;
|
|
556
|
+
const first = base + offset;
|
|
557
|
+
const alias = aliases.find((candidate) => candidate.start >= first && candidate.start < first + bound.length);
|
|
550
558
|
return {
|
|
551
559
|
execution: assigned?.[1] ?? null,
|
|
552
|
-
|
|
560
|
+
alias: alias?.alias ?? null,
|
|
561
|
+
found: Boolean(assigned || alias || new RegExp(legacy).test(after) || new RegExp(declaration).test(bound)),
|
|
553
562
|
returned: new RegExp(`${returns}${legacy}`).test(after) || returnsMatch(scope, declaration, bound.length) ||
|
|
554
563
|
Boolean(assigned && new RegExp(`${returns}${escaped(assigned[1])}\\s*;?`).test(after)),
|
|
555
564
|
};
|
|
@@ -566,12 +575,23 @@ export function productionExecutionProofs(text, file, croot) {
|
|
|
566
575
|
const direct = new RegExp(`new\\s+${PROD_INTERLOCKING}\\b`).test(receiver);
|
|
567
576
|
const named = [...receivers].some((name) => receiver.trim().endsWith(name));
|
|
568
577
|
if (!direct && !named) continue;
|
|
569
|
-
const
|
|
578
|
+
const base = (m.index ?? 0) + whole.length;
|
|
579
|
+
const proof = resolutionTrainExecution(text.slice(base), resolution,
|
|
580
|
+
file ? transparentCallbackAliases(text, file, resolution) : [], base);
|
|
570
581
|
if (proof.found) proofs.push({ resolution, execution: proof.execution });
|
|
582
|
+
if (proof.alias && proof.alias !== proof.execution) proofs.push({ resolution, execution: proof.alias });
|
|
571
583
|
}
|
|
572
584
|
return proofs;
|
|
573
585
|
}
|
|
574
586
|
|
|
587
|
+
// A member read of a binding: `.` or the optional-chain `?.`. Calls and writes are never matched here.
|
|
588
|
+
const MEMBER = "\\??\\.";
|
|
589
|
+
|
|
590
|
+
// Name of the const/let/var bound to `<execution>.trace` (or `<execution>?.trace`), or undefined.
|
|
591
|
+
function boundTraceName(text, execution) {
|
|
592
|
+
return new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*(?:await\\s+)?${escaped(execution)}${MEMBER}trace\\b`).exec(text)?.[1];
|
|
593
|
+
}
|
|
594
|
+
|
|
575
595
|
export function assertsExpression(text, expression, expected = null) {
|
|
576
596
|
const pat = new RegExp(`\\bexpect\\s*\\(\\s*(?:await\\s+)?${expression}\\s*\\)\\s*\\.(?:to(?:Be|BeDefined|Equal|StrictEqual|Contain|ContainEqual|MatchObject|Match|BeTruthy)|not\\.to(?:Be|BeDefined|Equal|StrictEqual|Contain|ContainEqual|MatchObject|Match))\\b([\\s\\S]{0,220})`);
|
|
577
597
|
const m = pat.exec(text);
|
|
@@ -591,8 +611,8 @@ export function hasProductionExecutionProof(text, file, croot) {
|
|
|
591
611
|
if (assertsExpression(text, `${escaped(resolution)}\\.(?:routeId|trainId|selectedTrainId)`) ||
|
|
592
612
|
(execution && assertsExpression(text, escaped(execution)))) return true;
|
|
593
613
|
if (!execution) return false;
|
|
594
|
-
const trace =
|
|
595
|
-
return Boolean(trace && REQUIRED_TRACE_FIELDS.some(([field]) => assertsExpression(text, `${escaped(trace)}
|
|
614
|
+
const trace = boundTraceName(text, execution);
|
|
615
|
+
return Boolean(trace && REQUIRED_TRACE_FIELDS.some(([field]) => assertsExpression(text, `${escaped(trace)}${MEMBER}${field}`)));
|
|
596
616
|
});
|
|
597
617
|
}
|
|
598
618
|
|
|
@@ -617,13 +637,13 @@ export function traceHasProductionProvenance(text, file, croot) {
|
|
|
617
637
|
return productionExecutionProofs(text, file, croot).some(({ execution, direct }) => {
|
|
618
638
|
if (direct) return direct.traceBound && REQUIRED_TRACE_FIELDS.every(([field]) => direct.traceFields.includes(field));
|
|
619
639
|
if (!execution) return false;
|
|
620
|
-
const trace =
|
|
640
|
+
const trace = boundTraceName(text, execution);
|
|
621
641
|
return Boolean(trace);
|
|
622
642
|
});
|
|
623
643
|
}
|
|
624
644
|
|
|
625
645
|
export function assertedTraceFields(text, traceName = "trace") {
|
|
626
|
-
return REQUIRED_TRACE_FIELDS.filter(([field]) => !assertsExpression(text, `${escaped(traceName)}
|
|
646
|
+
return REQUIRED_TRACE_FIELDS.filter(([field]) => !assertsExpression(text, `${escaped(traceName)}${MEMBER}${field}`)).map(([field]) => field);
|
|
627
647
|
}
|
|
628
648
|
|
|
629
649
|
// A smoke path has a real Station Master call and observes that call's returned value; separately
|
package/integrity.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.10.
|
|
2
|
+
"version": "0.10.107",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"README.md": "8aefceaa24bee498f39081b2aaf4c0d5c54bc1ed9849c20e1d98d1427df56eab",
|
|
@@ -384,7 +384,7 @@
|
|
|
384
384
|
"detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/server.ts": "d5bc34c5b1b2b0a511e0c4604ce6f9c77491dca9aee994be720540f3052babc1",
|
|
385
385
|
"detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/src/trains/interlocking.ts": "9bcfdb4ecff6efd1f658b9fd587342615179924e25468c75daa07c2626d4d8ab",
|
|
386
386
|
"detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/src/trains/runner.ts": "0e4b885e50f12cc2a8c8f6740bf83e7b2e7137a28e1e143586e9a8550ea5bd45",
|
|
387
|
-
"detectors/bun_interlocking_coverage/_shared/interlocking.mjs": "
|
|
387
|
+
"detectors/bun_interlocking_coverage/_shared/interlocking.mjs": "f6a716c4ef612a0f1bf7b612587c4af94d8e6aaa89ecdb8acee7cc977609bf43",
|
|
388
388
|
"detectors/bun_interlocking_coverage/atdd.implementation.yaml": "f160517a8138ba3ca05e3d130e6fbfb796e643376f25743fdef4fc210196525b",
|
|
389
389
|
"detectors/bun_interlocking_coverage/checks/_map.json": "0a218ea319cc6258af7bd721c2154cb9561fe67e80d80ef32d08fc77bc9403c3",
|
|
390
390
|
"detectors/bun_interlocking_coverage/checks/interlocking_production_runner.mjs": "97224d46f0c4c3259f15e41f90bb7f9f5a4e651d4facd41f1e037cac919be500",
|
|
@@ -1033,7 +1033,7 @@
|
|
|
1033
1033
|
"lib/frontend.mjs": "d6d9b29c3d7f2d162cda03bb34b20a3224a82887b6be3db7cab375510913c9a3",
|
|
1034
1034
|
"lib/imports.mjs": "3db4979269488fa26f6e7aefded6ece9c646be6587a10e249821a5a208109962",
|
|
1035
1035
|
"lib/interlocking.mjs": "d0803d080ac7d7477c51be70112df1e85213d339fd3c6b71f75f26145fa8accd",
|
|
1036
|
-
"lib/journey-chain.mjs": "
|
|
1036
|
+
"lib/journey-chain.mjs": "bede441e5d61ea950ed5b76bbb6357ef0d2057cb29053ed32df1faab33ad9ac1",
|
|
1037
1037
|
"lib/scan.mjs": "b6973e411ecd14c548a055c871e01e948f002b772042136be8401f38d998e687",
|
|
1038
1038
|
"planner-nodes/ENFORCEMENT_SCOPE.yaml": "47aa9c78a4bb1754c6b435f5e056038baa04a24cd48df2c205db2bc08a3a3cac",
|
|
1039
1039
|
"planner-nodes/nodes/planner.acceptance.abstract-fields-required.convention.yaml": "bd565693f0ae861f0561ad2f7a91a8c55f9a209c3acd59c5750cfcffd96a85d8",
|
|
@@ -1269,7 +1269,7 @@
|
|
|
1269
1269
|
"src/cli.ts": "395638ab87565bb10c2beb35988ff1acf686924d13515795156a2e8893b3da88",
|
|
1270
1270
|
"src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
|
|
1271
1271
|
"src/enforce.ts": "c4811abe63efe4568f3cf26e21a152d1537e120cb3ce471daca32367cf6e2cda",
|
|
1272
|
-
"src/hooks.ts": "
|
|
1272
|
+
"src/hooks.ts": "7e3dd5518701d2f3399bee919a68e801588822b173a648ed738c05c4a57231f1",
|
|
1273
1273
|
"src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
|
|
1274
1274
|
"src/integrity.ts": "f1ede7cb04924ae88e11917047277e264af6596b9759e381bd9c497cd34a797d",
|
|
1275
1275
|
"src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
|
package/lib/journey-chain.mjs
CHANGED
|
@@ -767,6 +767,113 @@ function awaitedCall(expression) {
|
|
|
767
767
|
return value && ts.isAwaitExpression(value) ? unwrap(value.expression) : null;
|
|
768
768
|
}
|
|
769
769
|
|
|
770
|
+
// Every `return` of `fn` (catch and `finally` bodies included, nested functions excluded), as expressions.
|
|
771
|
+
function everyReturn(fn) {
|
|
772
|
+
if (!fn.body) return null;
|
|
773
|
+
if (!ts.isBlock(fn.body)) return [fn.body];
|
|
774
|
+
const returns = [];
|
|
775
|
+
walk(fn.body, (node) => { if (ts.isReturnStatement(node)) returns.push(node.expression ?? null); }, false);
|
|
776
|
+
return returns;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// True when `fn` has live returns and EVERY return (live or not, catch/finally included) satisfies `accept`.
|
|
780
|
+
function alwaysReturns(fn, accept) {
|
|
781
|
+
const all = everyReturn(fn), live = liveReturns(fn);
|
|
782
|
+
return Boolean(all?.length && live?.length && all.every(accept));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// True when `node` assigns to or increments/decrements the binding `symbol`.
|
|
786
|
+
function writesSymbol(ctx, node, symbol) {
|
|
787
|
+
const target = ts.isBinaryExpression(node) && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
788
|
+
node.operatorToken.kind <= ts.SyntaxKind.LastAssignment ? node.left
|
|
789
|
+
: (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
|
790
|
+
(node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken) ? node.operand : null;
|
|
791
|
+
return Boolean(target && ts.isIdentifier(unwrap(target)) && symbolOf(ctx, unwrap(target)) === symbol);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// The function bound to `identifier`: a declaration, or a `const` arrow/function expression.
|
|
795
|
+
function localFunction(ctx, identifier) {
|
|
796
|
+
const declaration = declarationOf(ctx, identifier);
|
|
797
|
+
if (!declaration) return null;
|
|
798
|
+
if (ts.isFunctionDeclaration(declaration) && declaration.body) return declaration;
|
|
799
|
+
const initializer = constInitializer(ctx, identifier);
|
|
800
|
+
const value = initializer && unwrap(initializer);
|
|
801
|
+
return value && (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) ? value : null;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// A wrapper whose every return, on every path, IS the unchanged result of its callback parameter:
|
|
805
|
+
// `return work()` / `return await work()`. Anything that replaces, conditions, copies or discards the
|
|
806
|
+
// callback's value (spread, ternary, comma, a second return value, a returning catch) is not transparent.
|
|
807
|
+
function transparentWrapper(ctx, fn) {
|
|
808
|
+
const param = fn.parameters[0];
|
|
809
|
+
if (!param || !ts.isIdentifier(param.name) || param.dotDotDotToken || fn.asteriskToken) return false;
|
|
810
|
+
const symbol = symbolOf(ctx, param.name);
|
|
811
|
+
if (!symbol) return false;
|
|
812
|
+
const isWork = (expression) => {
|
|
813
|
+
const call = expression && unwrap(expression);
|
|
814
|
+
return Boolean(call && ts.isCallExpression(call) && call.arguments.length === 0 &&
|
|
815
|
+
ts.isIdentifier(unwrap(call.expression)) && symbolOf(ctx, unwrap(call.expression)) === symbol);
|
|
816
|
+
};
|
|
817
|
+
if (!alwaysReturns(fn, isWork)) return false;
|
|
818
|
+
let rebound = false;
|
|
819
|
+
walk(fn.body, (node) => { rebound ||= writesSymbol(ctx, node, symbol); });
|
|
820
|
+
return !rebound;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// `const <alias> = await <wrapper>(<callback>)` where the local wrapper is transparent and every return of
|
|
824
|
+
// the zero-parameter callback IS the production execution of `resolution` (the call itself, or a `const`
|
|
825
|
+
// bound to it), so `<alias>` IS that execution. Returns [{ alias, start }] in source order.
|
|
826
|
+
// Production forms: `new TrainRunner(<resolution>.trainId|selectedTrainId).execute(...)` and
|
|
827
|
+
// `new TrainRunner(<resolution>.trainPath, ...).execute(<resolution>, ...)`.
|
|
828
|
+
export function transparentCallbackAliases(text, file, resolution) {
|
|
829
|
+
if (!text || !file || !resolution) return [];
|
|
830
|
+
const ctx = analyze(file, text);
|
|
831
|
+
const aliases = [];
|
|
832
|
+
const isResolutionMember = (node, names) => {
|
|
833
|
+
node = node && unwrap(node);
|
|
834
|
+
return Boolean(node && ts.isPropertyAccessExpression(node) && names.includes(node.name.text) &&
|
|
835
|
+
ts.isIdentifier(node.expression) && node.expression.text === resolution && declarationOf(ctx, node.expression));
|
|
836
|
+
};
|
|
837
|
+
const isExecution = (node) => {
|
|
838
|
+
const call = unwrap(node);
|
|
839
|
+
if (!call || !ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression) ||
|
|
840
|
+
call.expression.name.text !== "execute") return false;
|
|
841
|
+
const construction = unwrap(call.expression.expression);
|
|
842
|
+
if (!construction || !ts.isNewExpression(construction) || !ts.isIdentifier(construction.expression) ||
|
|
843
|
+
construction.expression.text !== "TrainRunner" || !construction.arguments?.length) return false;
|
|
844
|
+
const [first] = construction.arguments;
|
|
845
|
+
if (isResolutionMember(first, ["trainId", "selectedTrainId"])) return construction.arguments.length === 1;
|
|
846
|
+
const executed = call.arguments[0] && unwrap(call.arguments[0]);
|
|
847
|
+
return isResolutionMember(first, ["trainPath"]) && Boolean(executed && ts.isIdentifier(executed) &&
|
|
848
|
+
executed.text === resolution && declarationOf(ctx, executed));
|
|
849
|
+
};
|
|
850
|
+
const isIdentity = (expression, seen = new Set()) => {
|
|
851
|
+
const node = expression && unwrap(expression);
|
|
852
|
+
if (!node) return false;
|
|
853
|
+
if (ts.isCallExpression(node)) return isExecution(node);
|
|
854
|
+
if (!ts.isIdentifier(node)) return false;
|
|
855
|
+
const initializer = constInitializer(ctx, node);
|
|
856
|
+
if (!initializer || seen.has(initializer)) return false;
|
|
857
|
+
seen.add(initializer);
|
|
858
|
+
return isIdentity(initializer, seen);
|
|
859
|
+
};
|
|
860
|
+
walk(ctx.source, (node) => {
|
|
861
|
+
if (!ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || !node.initializer ||
|
|
862
|
+
!(ts.isVariableDeclarationList(node.parent) && node.parent.flags & ts.NodeFlags.Const) ||
|
|
863
|
+
dead(node, ctx.source) || insideCatch(node, ctx.source)) return;
|
|
864
|
+
const awaited = awaitedCall(node.initializer);
|
|
865
|
+
if (!awaited || !ts.isCallExpression(awaited) || !ts.isIdentifier(awaited.expression) || awaited.arguments.length !== 1) return;
|
|
866
|
+
const callback = unwrap(awaited.arguments[0]);
|
|
867
|
+
if (!callback || !(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) ||
|
|
868
|
+
callback.parameters.length > 0 || callback.asteriskToken) return;
|
|
869
|
+
const wrapper = localFunction(ctx, awaited.expression);
|
|
870
|
+
if (!wrapper || !transparentWrapper(ctx, wrapper)) return;
|
|
871
|
+
if (!alwaysReturns(callback, (expression) => isIdentity(expression))) return;
|
|
872
|
+
aliases.push({ alias: node.name.text, start: node.getStart(ctx.source) });
|
|
873
|
+
});
|
|
874
|
+
return aliases;
|
|
875
|
+
}
|
|
876
|
+
|
|
770
877
|
function expectIdentifier(ctx, node) {
|
|
771
878
|
if (!ts.isIdentifier(node) || node.text !== "expect") return false;
|
|
772
879
|
const declaration = declarationOf(ctx, node);
|
package/package.json
CHANGED
package/src/hooks.ts
CHANGED
|
@@ -471,7 +471,7 @@ async function createWorkspace(state: Layout, target: { leaf: string; target: st
|
|
|
471
471
|
return bad(bookmark.err || bookmark.out || `bookmark ${branch} was not exported to the colocated Git repository`);
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
-
async function finishCandidate(state: Layout, cfg: HookPolicy): Promise<{ error: string } | { branch: string; tip: string
|
|
474
|
+
async function finishCandidate(state: Layout, cfg: HookPolicy): Promise<{ error: string } | { branch: string; tip: string }> {
|
|
475
475
|
if (state.current === state.primary || !inside(state.worktreeRoot, state.current)) return { error: `finish must run from a linked workspace beneath ${state.worktreeRoot}` };
|
|
476
476
|
const conflict = await noConflicts(state.current);
|
|
477
477
|
if (conflict.error !== undefined) return conflict;
|
|
@@ -489,23 +489,50 @@ async function finishCandidate(state: Layout, cfg: HookPolicy): Promise<{ error:
|
|
|
489
489
|
}
|
|
490
490
|
const tip = await branchCommit(state.primary, found.branch);
|
|
491
491
|
if (!tip || tip !== delivery) return { error: `bookmark ${found.branch} must point at the delivery revision before finish` };
|
|
492
|
-
|
|
493
|
-
return { branch: found.branch, tip
|
|
492
|
+
const undelivered = await deliveryError(state, found.branch, tip, cfg);
|
|
493
|
+
return undelivered ? { error: undelivered } : { branch: found.branch, tip };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function jjRevision(root: string, commit: string): Promise<{ error: string } | { empty: boolean; conflict: boolean; parents: string[] }> {
|
|
497
|
+
const info = await jj(root, ["log", "-r", commit, "--no-graph", "-T", 'if(empty, "empty", "content") ++ " " ++ if(conflict, "conflict", "clean") ++ " " ++ parents.map(|p| p.commit_id()).join(" ")']);
|
|
498
|
+
if (info.code || !info.out) return { error: `could not inspect Jujutsu revision ${commit}: ${info.err || info.out}` };
|
|
499
|
+
const [emptiness, conflict, ...parents] = info.out.split(" ");
|
|
500
|
+
return { empty: emptiness === "empty", conflict: conflict === "conflict", parents };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// A tip is delivered when it is an ancestor of the protected branch, or when it
|
|
504
|
+
// reaches such an ancestor only through empty single-parent changes (nothing to
|
|
505
|
+
// deliver). Content, conflicts, or merge shapes outside that chain fail closed.
|
|
506
|
+
async function deliveryError(state: Layout, branch: string, tip: string, cfg: HookPolicy): Promise<string | null> {
|
|
507
|
+
let commit = tip;
|
|
508
|
+
for (;;) {
|
|
509
|
+
const info = await jjRevision(state.primary, commit);
|
|
510
|
+
if ("error" in info) return info.error;
|
|
511
|
+
if (info.conflict) return `bookmark ${branch} has unresolved conflicts`;
|
|
512
|
+
if (!(await git(state.primary, ["merge-base", "--is-ancestor", commit, cfg.worktrees.primary_branch])).code) return null;
|
|
513
|
+
if (!info.empty || info.parents.length !== 1) return `branch ${branch} is not merged into ${cfg.worktrees.primary_branch} in the primary checkout`;
|
|
514
|
+
commit = info.parents[0]!;
|
|
515
|
+
}
|
|
494
516
|
}
|
|
495
517
|
|
|
496
|
-
// The
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
518
|
+
// The workspace name is never derived from the bookmark (bookmarks can be
|
|
519
|
+
// renamed). Resolve it from the JJ registry: exactly one registered workspace
|
|
520
|
+
// must resolve, by real path, to the current directory.
|
|
521
|
+
async function resolveWorkspace(state: Layout): Promise<{ error: string } | { name: string }> {
|
|
522
|
+
const listed = await jj(state.primary, ["workspace", "list", "-T", 'name ++ "\\n"']);
|
|
523
|
+
if (listed.code) return { error: `could not list Jujutsu workspaces: ${listed.err || listed.out}` };
|
|
524
|
+
const matches: string[] = [];
|
|
525
|
+
for (const name of listed.out.split("\n").filter(Boolean)) {
|
|
526
|
+
const recorded = await jj(state.primary, ["workspace", "root", "--name", name]);
|
|
527
|
+
if (!recorded.code && await realpath(recorded.out).catch(() => null) === state.current) matches.push(name);
|
|
528
|
+
}
|
|
529
|
+
return matches.length === 1 ? { name: matches[0]! } : { error: `${state.current} is not registered as exactly one Jujutsu workspace; found ${matches.length}` };
|
|
503
530
|
}
|
|
504
531
|
|
|
505
532
|
async function deleteBookmark(state: Layout, candidate: { branch: string; tip: string }, cfg: HookPolicy) {
|
|
506
533
|
const now = await branchCommit(state.primary, candidate.branch);
|
|
507
|
-
const merged = !(await
|
|
508
|
-
if (
|
|
534
|
+
const merged = now === candidate.tip && !(await deliveryError(state, candidate.branch, candidate.tip, cfg));
|
|
535
|
+
if (!merged) return `workspace removed, but bookmark ${candidate.branch} was not deleted: its tip changed or is no longer merged into ${cfg.worktrees.primary_branch}`;
|
|
509
536
|
const deleted = await jj(state.primary, ["bookmark", "delete", candidate.branch]);
|
|
510
537
|
return deleted.code ? `workspace removed, but bookmark deletion failed: ${deleted.err || deleted.out}` : null;
|
|
511
538
|
}
|
|
@@ -531,9 +558,9 @@ async function finishJjWorktree(root: string, deleteBranch: boolean, cfg: HookPo
|
|
|
531
558
|
if ("error" in state) return bad(state.error);
|
|
532
559
|
const candidate = await finishCandidate(state, cfg);
|
|
533
560
|
if ("error" in candidate) return bad(candidate.error);
|
|
534
|
-
const
|
|
535
|
-
if (
|
|
536
|
-
const removed = await jj(state.primary, ["workspace", "remove",
|
|
561
|
+
const workspace = await resolveWorkspace(state);
|
|
562
|
+
if ("error" in workspace) return bad(workspace.error);
|
|
563
|
+
const removed = await jj(state.primary, ["workspace", "remove", workspace.name]);
|
|
537
564
|
if (removed.code) return bad(removed.err || removed.out);
|
|
538
565
|
if (!deleteBranch) return { ok: true, message: `removed merged Jujutsu workspace ${state.current}` };
|
|
539
566
|
const deletionError = await deleteBookmark(state, candidate, cfg);
|