@afokapu/atdd-bun 0.10.114 → 0.10.116
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/integrity.json +2 -2
- package/package.json +1 -1
- package/src/hooks.ts +90 -24
package/integrity.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.10.
|
|
2
|
+
"version": "0.10.116",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"README.md": "8aefceaa24bee498f39081b2aaf4c0d5c54bc1ed9849c20e1d98d1427df56eab",
|
|
@@ -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": "c7dcf6ddfa33d7b0c293e5400da679ec339242917746fba5e5213f8a267b8522",
|
|
1273
1273
|
"src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
|
|
1274
1274
|
"src/integrity.ts": "f1ede7cb04924ae88e11917047277e264af6596b9759e381bd9c497cd34a797d",
|
|
1275
1275
|
"src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
|
package/package.json
CHANGED
package/src/hooks.ts
CHANGED
|
@@ -479,7 +479,7 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
|
|
|
479
479
|
if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), registries = staged.filter(path => isRegistry(cfg, path)), generated = staged.filter(isGeneratedView), counted = staged.length - registries.length - generated.length, lines = (await stagedLineStats(root)).filter(row => !isRegistry(cfg, row.path) && !isGeneratedView(row.path)).reduce((n, row) => n + row.added + row.removed, 0); const count = await stagedCountGate(root, staged, counted, cfg.max_staged_files); if (count.rejection) return bad(count.rejection); if (lines > cfg.max_staged_changed_lines) return bad(`staged changed lines ${lines} exceed ${cfg.max_staged_changed_lines}`); const ratchet = await ratchetCommitGate(root, staged, registries.length > 0); if (ratchet) return ratchet; const checked = cfg.require_traceability || registries.length || generated.length ? await validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; if (checked.ok && count.grant) await recordPendingException(root, count.grant); return checked; }
|
|
480
480
|
if (event === "commit-msg") { const deleted = (await files(root, ["diff", "--cached", "--name-only", "--diff-filter=D"])).length, stats = await stagedStats(root), lines = stats.reduce((n, row) => n + row.removed, 0), registryRemoved = stats.filter(row => isRegistry(cfg, row.path)).reduce((n, row) => n + row.removed - row.added, 0), message = args[0] && existsSync(args[0]) ? await readFile(args[0], "utf8") : "", approved = message.includes("[mass-delete-approved]"); if ((deleted > 50 || lines > 10_000) && !approved) return bad("mass delete requires [mass-delete-approved]"); return registryRemoved > cfg.max_registry_removed_lines && !approved ? bad(`registry removal of ${registryRemoved} net lines exceeds ${cfg.max_registry_removed_lines}; requires [mass-delete-approved]`) : { ok: true, message: "ok" }; }
|
|
481
481
|
if (event === "pre-push") { const reports: string[] = []; let exclusions: Awaited<ReturnType<typeof countExclusionTips>> | undefined; for (const row of stdin.split("\n").filter(Boolean).map(row => row.split(/\s+/))) { const [, local, remote, remoteSha] = row, target = remote?.replace("refs/heads/", ""); if (target && cfg.protected_branches.includes(target)) return bad(`protected branch ${target} is blocked`); if (!local) return bad("pre-push update has no local object id"); if (/^0+$/.test(local)) { const deletion = await deletionPushGate(root, remoteSha); if (!deletion.ok) return deletion; continue; } const isNew = !remoteSha || /^0+$/.test(remoteSha), range = isNew ? `${local}^..${local}` : `${remoteSha}..${local}`, shared = exclusions ??= await countExclusionTips(root, cfg.protected_branches, args[0]); if ("error" in shared) return bad(`could not list remote branch tips for the pushed range ${range}: ${shared.error}`); const counted = await git(root, ["rev-list", "--count", ...(shared.tips.length ? [isNew ? local : range, "--not", ...shared.tips] : [range])]); if (counted.code || !/^\d+$/.test(counted.out)) return bad(`could not count commits in pushed range ${range}: ${counted.err || counted.out}`); if (Number(counted.out) > cfg.max_commits_per_push) return bad(`commits per push ${counted.out} exceed ${cfg.max_commits_per_push}`); const ratchet = remote?.startsWith("refs/heads/") ? await ratchetPushGate(root, remoteSha ?? "", local, cfg.protected_branches, args[0]) : null; if (ratchet) { if (!ratchet.ok) return ratchet; reports.push(ratchet.message); continue; } const strict = await strictPushGate(root, local, range); if (!strict.ok) return strict; } return reports.length ? { ok: true, message: reports.length === 1 ? reports[0]! : `[${reports.join(",")}]` } : { ok: true, message: "validation clean" }; }
|
|
482
|
-
if (event === "post-commit") { await consumePendingException(root); const result = await validation(root, await files(root, ["show", "--pretty=format:", "--name-only", "HEAD"]), false)
|
|
482
|
+
if (event === "post-commit") { await consumePendingException(root); const result = await validation(root, await files(root, ["show", "--pretty=format:", "--name-only", "HEAD"]), false), advance = await advanceJjDeliveryBookmark(root, cfg), message = result.ok ? result.message : `advisory: ${result.message}`; return { ok: true, message: advance ? `${message}; ${advance}` : message }; }
|
|
483
483
|
return { ok: true, message: "ok" };
|
|
484
484
|
}
|
|
485
485
|
|
|
@@ -556,17 +556,72 @@ async function jjPrimaryOnBranch(primary: string, branch: string): Promise<boole
|
|
|
556
556
|
return (await lines("@-")).length === 1 && (await lines(`@- & (bookmarks(exact:${name}) | remote_bookmarks(exact:${name}, exact:"origin"))`)).length === 1;
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
559
|
+
const ADVANCE_ADVISORY = "delivery bookmark not advanced";
|
|
560
|
+
const workingCopyTemplate = 'commit_id ++ "\\n" ++ empty ++ "\\n" ++ parents.map(|p| p.commit_id()).join(",") ++ "\\n" ++ local_bookmarks.map(|b| if(b.conflict(), "conflict:", "") ++ b.name()).join("\\n")';
|
|
561
|
+
type WorkingCopy = { commit: string; empty: boolean; parents: string; bookmarks: string[] };
|
|
562
|
+
// `--ignore-working-copy` reads the recorded operation state without snapshotting; omit it to snapshot first.
|
|
563
|
+
async function jjWorkingCopy(root: string, ignoreWorkingCopy: boolean): Promise<WorkingCopy | null> {
|
|
564
|
+
const result = await jj(root, ["log", ...(ignoreWorkingCopy ? ["--ignore-working-copy"] : []), "--no-graph", "-r", "@", "-T", workingCopyTemplate]).catch(() => null);
|
|
565
|
+
if (!result || result.code) return null;
|
|
566
|
+
const [commit, empty, parents, ...bookmarks] = result.out.split("\n");
|
|
567
|
+
return { commit: commit ?? "", empty: empty === "true", parents: parents ?? "", bookmarks: bookmarks.filter(Boolean) };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// The one unambiguous delivery bookmark of a commit: exactly one local, non-conflicted, non-protected
|
|
571
|
+
// bookmark that is also the only Git-exported branch at that commit.
|
|
572
|
+
async function exactDeliveryBookmark(root: string, at: WorkingCopy, protectedBranches: string[]): Promise<{ name: string } | { error: string }> {
|
|
573
|
+
if (at.bookmarks.length !== 1) return { error: `the working-copy commit must carry exactly one bookmark; found ${at.bookmarks.length}` };
|
|
574
|
+
const name = at.bookmarks[0]!;
|
|
575
|
+
if (protectedBranches.includes(name)) return { error: `bookmark ${name} is protected` };
|
|
576
|
+
const exported = await branchForCommit(root, at.commit, []);
|
|
577
|
+
return "error" in exported || exported.branch !== name ? { error: `bookmark ${name} is conflicted or not the only exported branch at the working-copy commit` } : { name };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// A colocated JJ workspace keeps Git HEAD detached, so the named-branch condition is read from JJ: @ must
|
|
581
|
+
// carry the exact delivery bookmark. Reading without a snapshot keeps the hook from mutating the working copy.
|
|
562
582
|
async function jjDeliveryBookmarkAtWorkingCopy(root: string, protectedBranches: string[]): Promise<boolean> {
|
|
563
583
|
if (!existsSync(join(root, ".jj"))) return false;
|
|
564
|
-
const at = await
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
584
|
+
const at = await jjWorkingCopy(root, true);
|
|
585
|
+
return !!at && "name" in await exactDeliveryBookmark(root, at, protectedBranches);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// `git commit` in a colocated JJ workspace leaves the delivery bookmark on the old empty working-copy commit,
|
|
589
|
+
// a sibling of the validated commit, so `jj git push --bookmark` cannot publish it. Verify the validated commit
|
|
590
|
+
// and the previous working copy, returning the one bookmark to move or the reason it must stay.
|
|
591
|
+
async function advanceCandidate(root: string, protectedBranches: string[]): Promise<{ name: string; validated: string } | { error: string }> {
|
|
592
|
+
const [validated, ...gitParents] = (await git(root, ["rev-list", "--parents", "-n", "1", "HEAD"])).out.split(" ");
|
|
593
|
+
if (!validated || gitParents.length !== 1) return { error: "the validated HEAD must have exactly one parent" };
|
|
594
|
+
const old = await jjWorkingCopy(root, true);
|
|
595
|
+
if (!old || !old.empty || old.parents !== gitParents[0]) return { error: "the previous working-copy commit is not an empty sibling of the validated commit" };
|
|
596
|
+
const exact = await exactDeliveryBookmark(root, old, protectedBranches);
|
|
597
|
+
if ("error" in exact) return exact;
|
|
598
|
+
const now = await jjWorkingCopy(root, false);
|
|
599
|
+
return now?.empty && now.parents === validated ? { name: exact.name, validated } : { error: "the working copy is not an empty child of the validated commit" };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// The advance applies only to a detached colocated JJ workspace linked beneath the required worktree root.
|
|
603
|
+
async function isLinkedJjDeliveryWorkspace(root: string, cfg: HookPolicy): Promise<boolean> {
|
|
604
|
+
if (!cfg.worktrees.enabled || !existsSync(join(root, ".jj"))) return false;
|
|
605
|
+
const state = await layout(root, cfg.worktrees);
|
|
606
|
+
return !("error" in state) && !state.branch && state.current !== state.primary && inside(state.worktreeRoot, state.current);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Move the bookmark, then prove JJ and the Git ref both name exactly the validated commit; returns the failure or null.
|
|
610
|
+
async function moveBookmarkVerified(root: string, name: string, validated: string): Promise<string | null> {
|
|
611
|
+
const moved = await jj(root, ["bookmark", "set", name, "-r", validated, "--allow-backwards"]);
|
|
612
|
+
const targets = await jj(root, ["log", "--ignore-working-copy", "--no-graph", "-r", `bookmarks(exact:${JSON.stringify(name)})`, "-T", 'commit_id ++ "\\n"']);
|
|
613
|
+
const aligned = !moved.code && targets.out === validated && (await branchCommit(root, name)) === validated;
|
|
614
|
+
return aligned ? null : `bookmark ${name} did not end at the validated commit ${validated}: ${moved.err || targets.out}`;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// After the validated commit, move exactly the eligible delivery bookmark to it. Returns null when the policy
|
|
618
|
+
// does not apply, and an advisory (never a veto of the already-made commit) when ineligible; nothing moves then.
|
|
619
|
+
async function advanceJjDeliveryBookmark(root: string, cfg: HookPolicy): Promise<string | null> {
|
|
620
|
+
if (!await isLinkedJjDeliveryWorkspace(root, cfg)) return null;
|
|
621
|
+
const candidate = await advanceCandidate(root, cfg.protected_branches);
|
|
622
|
+
if ("error" in candidate) return `${ADVANCE_ADVISORY}: ${candidate.error}`;
|
|
623
|
+
const failure = await moveBookmarkVerified(root, candidate.name, candidate.validated);
|
|
624
|
+
return failure ? `${ADVANCE_ADVISORY}: ${failure}` : `advanced delivery bookmark ${candidate.name} to ${candidate.validated}`;
|
|
570
625
|
}
|
|
571
626
|
|
|
572
627
|
type Layout = { primary: string; worktreeRoot: string; current: string };
|
|
@@ -628,13 +683,12 @@ async function finishCandidate(state: Layout, cfg: HookPolicy): Promise<{ error:
|
|
|
628
683
|
const current = await currentCommit(state.current);
|
|
629
684
|
if (!current) return { error: "could not resolve the current Jujutsu revision" };
|
|
630
685
|
let delivery = current, found = await branchForCommit(state.current, current, cfg.protected_branches);
|
|
631
|
-
if ("error" in found && found.count === 0 && await mergedIntoPrimary(state, current, cfg)) return { branch: null, tip: current };
|
|
632
686
|
if ("error" in found) {
|
|
687
|
+
// The established delivery path comes first: an empty working copy above a bookmarked parent.
|
|
633
688
|
const changed = await jj(state.current, ["diff", "-r", "@", "--name-only"]);
|
|
634
689
|
const parent = await jj(state.current, ["log", "-r", "@-", "--no-graph", "-T", "commit_id"]);
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
if ("error" in parentFound) return found;
|
|
690
|
+
const parentFound = changed.code || changed.out || parent.code || !parent.out ? found : await branchForCommit(state.current, parent.out, cfg.protected_branches);
|
|
691
|
+
if ("error" in parentFound) return found.count === 0 && await chainToPrimary(state, current, cfg, true) === "merged" ? { branch: null, tip: current } : found;
|
|
638
692
|
delivery = parent.out;
|
|
639
693
|
found = parentFound;
|
|
640
694
|
}
|
|
@@ -656,21 +710,33 @@ async function jjRevision(root: string, commit: string): Promise<{ error: string
|
|
|
656
710
|
return { empty: emptiness === "empty", conflict: conflict === "conflict", parents };
|
|
657
711
|
}
|
|
658
712
|
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
713
|
+
// Walks from a tip toward the protected branch through empty single-parent changes. "merged" means
|
|
714
|
+
// the walk reached an ancestor of the protected branch, so there is nothing to deliver. Content,
|
|
715
|
+
// conflicts, or merge shapes outside that chain fail closed. With refFree, an unmerged change that
|
|
716
|
+
// carries any refs/heads bookmark (protected or not) also fails closed, so no unique ref is left behind.
|
|
717
|
+
type ChainOutcome = "merged" | "conflict" | "unmerged" | { error: string };
|
|
718
|
+
async function chainToPrimary(state: Layout, tip: string, cfg: HookPolicy, refFree = false): Promise<ChainOutcome> {
|
|
719
|
+
for (let commit = tip;;) {
|
|
665
720
|
const info = await jjRevision(state.primary, commit);
|
|
666
|
-
if ("error" in info) return info
|
|
667
|
-
if (info.conflict) return
|
|
668
|
-
if (await mergedIntoPrimary(state, commit, cfg)) return
|
|
669
|
-
if (!info.empty || info.parents.length !== 1) return
|
|
721
|
+
if ("error" in info) return info;
|
|
722
|
+
if (info.conflict) return "conflict";
|
|
723
|
+
if (await mergedIntoPrimary(state, commit, cfg)) return "merged";
|
|
724
|
+
if (!info.empty || info.parents.length !== 1) return "unmerged";
|
|
725
|
+
if (refFree) {
|
|
726
|
+
const refs = await git(state.primary, ["for-each-ref", "--format=%(refname)", "--points-at", commit, "refs/heads/"]);
|
|
727
|
+
if (refs.code || refs.out) return "unmerged";
|
|
728
|
+
}
|
|
670
729
|
commit = info.parents[0]!;
|
|
671
730
|
}
|
|
672
731
|
}
|
|
673
732
|
|
|
733
|
+
async function deliveryError(state: Layout, branch: string, tip: string, cfg: HookPolicy): Promise<string | null> {
|
|
734
|
+
const outcome = await chainToPrimary(state, tip, cfg);
|
|
735
|
+
if (outcome === "merged") return null;
|
|
736
|
+
if (outcome === "conflict") return `bookmark ${branch} has unresolved conflicts`;
|
|
737
|
+
return typeof outcome === "string" ? `branch ${branch} is not merged into ${cfg.worktrees.primary_branch} in the primary checkout` : outcome.error;
|
|
738
|
+
}
|
|
739
|
+
|
|
674
740
|
// The workspace name is never derived from the bookmark (bookmarks can be
|
|
675
741
|
// renamed). Resolve it from the JJ registry: exactly one registered workspace
|
|
676
742
|
// must resolve, by real path, to the current directory.
|