@afokapu/atdd-bun 0.10.113 → 0.10.115
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 +40 -16
package/integrity.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.10.
|
|
2
|
+
"version": "0.10.115",
|
|
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": "70094d7bb7afbddcf145d77b3003e3487c8a6d39066952251a18b5487594c310",
|
|
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
|
@@ -470,12 +470,12 @@ async function primaryPolicy(root: string): Promise<PrimaryPolicy> { const found
|
|
|
470
470
|
const disabledWorktreePolicy = (source: PrimaryPolicy["source"]) => bad(source ? `worktree policy is disabled in primary checkout ${source.path} at ${source.commit}; update the primary checkout and try again` : "worktree policy is disabled");
|
|
471
471
|
export type WorktreeLayout = { primary: string; worktreeRoot: string; current: string; branch: string };
|
|
472
472
|
async function layout(root: string, cfg: WorktreePolicy): Promise<WorktreeLayout | { error: string }> { const current = await realpath(resolve(root)), commonRoot = await primaryRoot(current); if (!commonRoot) return { error: "not inside a Git worktree" }; const primary = await realpath(commonRoot), expectedPrimary = cfg.primary_directory === "." ? primary : join(dirname(primary), cfg.primary_directory); if (primary !== expectedPrimary) return { error: `primary checkout must be ${expectedPrimary}, not ${primary}` }; const branch = (await git(primary, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out; if (branch !== cfg.primary_branch && !(await jjPrimaryOnBranch(primary, cfg.primary_branch))) return { error: `primary checkout ${primary} must be on ${cfg.primary_branch}, not ${branch || "a detached HEAD"}` }; return { primary, worktreeRoot: resolve(primary, cfg.root.replaceAll("{repo}", basename(primary))), current, branch: (await git(current, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out }; }
|
|
473
|
-
async function worktreeCommitPolicy(root: string, cfg: WorktreePolicy): Promise<string | null> { if (!cfg.enabled || !cfg.require_linked_worktree) return null; const state = await layout(root, cfg); if ("error" in state) return state.error; if (state.current === state.primary) return `commits must use a linked worktree beneath ${state.worktreeRoot}; run: atdd-bun worktree start <branch>`; if (!inside(state.worktreeRoot, state.current)) return `linked worktree ${state.current} is outside required root ${state.worktreeRoot}`; if (!state.branch) return "commits require a named branch, not a detached HEAD"; return null; }
|
|
473
|
+
async function worktreeCommitPolicy(root: string, cfg: WorktreePolicy, protectedBranches: string[]): Promise<string | null> { if (!cfg.enabled || !cfg.require_linked_worktree) return null; const state = await layout(root, cfg); if ("error" in state) return state.error; if (state.current === state.primary) return `commits must use a linked worktree beneath ${state.worktreeRoot}; run: atdd-bun worktree start <branch>`; if (!inside(state.worktreeRoot, state.current)) return `linked worktree ${state.current} is outside required root ${state.worktreeRoot}`; if (!state.branch && !(await jjDeliveryBookmarkAtWorkingCopy(state.current, protectedBranches))) return "commits require a named branch, not a detached HEAD"; return null; }
|
|
474
474
|
|
|
475
475
|
export async function runHook(event: HookEvent, root = process.cwd(), args: string[] = [], stdin = "") {
|
|
476
476
|
const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
|
|
477
477
|
if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
|
|
478
|
-
if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
|
|
478
|
+
if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees, cfg.protected_branches); if (violation) return bad(violation); }
|
|
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" }; }
|
|
@@ -556,6 +556,19 @@ 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
|
+
// A colocated JJ workspace keeps Git HEAD detached, so the named-branch condition is read from JJ:
|
|
560
|
+
// @ must carry exactly one local, non-conflicted bookmark, and that same name must be the only
|
|
561
|
+
// Git-exported branch at @ and not protected. `--ignore-working-copy` keeps the hook from snapshotting.
|
|
562
|
+
async function jjDeliveryBookmarkAtWorkingCopy(root: string, protectedBranches: string[]): Promise<boolean> {
|
|
563
|
+
if (!existsSync(join(root, ".jj"))) return false;
|
|
564
|
+
const at = await jj(root, ["log", "--ignore-working-copy", "--no-graph", "-r", "@", "-T", 'commit_id ++ "\\n" ++ local_bookmarks.map(|b| if(b.conflict(), "conflict:", "") ++ b.name()).join("\\n")']).catch(() => null);
|
|
565
|
+
if (!at || at.code) return false;
|
|
566
|
+
const [commit, ...bookmarks] = at.out.split("\n");
|
|
567
|
+
if (!commit || bookmarks.length !== 1) return false;
|
|
568
|
+
const exported = await branchForCommit(root, commit, []);
|
|
569
|
+
return !("error" in exported) && exported.branch === bookmarks[0] && !protectedBranches.includes(exported.branch);
|
|
570
|
+
}
|
|
571
|
+
|
|
559
572
|
type Layout = { primary: string; worktreeRoot: string; current: string };
|
|
560
573
|
async function jjLayout(root: string, cfg: HookPolicy): Promise<Layout | { error: string }> {
|
|
561
574
|
const current = await realpath(resolve(root));
|
|
@@ -615,13 +628,12 @@ async function finishCandidate(state: Layout, cfg: HookPolicy): Promise<{ error:
|
|
|
615
628
|
const current = await currentCommit(state.current);
|
|
616
629
|
if (!current) return { error: "could not resolve the current Jujutsu revision" };
|
|
617
630
|
let delivery = current, found = await branchForCommit(state.current, current, cfg.protected_branches);
|
|
618
|
-
if ("error" in found && found.count === 0 && await mergedIntoPrimary(state, current, cfg)) return { branch: null, tip: current };
|
|
619
631
|
if ("error" in found) {
|
|
632
|
+
// The established delivery path comes first: an empty working copy above a bookmarked parent.
|
|
620
633
|
const changed = await jj(state.current, ["diff", "-r", "@", "--name-only"]);
|
|
621
634
|
const parent = await jj(state.current, ["log", "-r", "@-", "--no-graph", "-T", "commit_id"]);
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
if ("error" in parentFound) return found;
|
|
635
|
+
const parentFound = changed.code || changed.out || parent.code || !parent.out ? found : await branchForCommit(state.current, parent.out, cfg.protected_branches);
|
|
636
|
+
if ("error" in parentFound) return found.count === 0 && await chainToPrimary(state, current, cfg, true) === "merged" ? { branch: null, tip: current } : found;
|
|
625
637
|
delivery = parent.out;
|
|
626
638
|
found = parentFound;
|
|
627
639
|
}
|
|
@@ -643,21 +655,33 @@ async function jjRevision(root: string, commit: string): Promise<{ error: string
|
|
|
643
655
|
return { empty: emptiness === "empty", conflict: conflict === "conflict", parents };
|
|
644
656
|
}
|
|
645
657
|
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
658
|
+
// Walks from a tip toward the protected branch through empty single-parent changes. "merged" means
|
|
659
|
+
// the walk reached an ancestor of the protected branch, so there is nothing to deliver. Content,
|
|
660
|
+
// conflicts, or merge shapes outside that chain fail closed. With refFree, an unmerged change that
|
|
661
|
+
// carries any refs/heads bookmark (protected or not) also fails closed, so no unique ref is left behind.
|
|
662
|
+
type ChainOutcome = "merged" | "conflict" | "unmerged" | { error: string };
|
|
663
|
+
async function chainToPrimary(state: Layout, tip: string, cfg: HookPolicy, refFree = false): Promise<ChainOutcome> {
|
|
664
|
+
for (let commit = tip;;) {
|
|
652
665
|
const info = await jjRevision(state.primary, commit);
|
|
653
|
-
if ("error" in info) return info
|
|
654
|
-
if (info.conflict) return
|
|
655
|
-
if (await mergedIntoPrimary(state, commit, cfg)) return
|
|
656
|
-
if (!info.empty || info.parents.length !== 1) return
|
|
666
|
+
if ("error" in info) return info;
|
|
667
|
+
if (info.conflict) return "conflict";
|
|
668
|
+
if (await mergedIntoPrimary(state, commit, cfg)) return "merged";
|
|
669
|
+
if (!info.empty || info.parents.length !== 1) return "unmerged";
|
|
670
|
+
if (refFree) {
|
|
671
|
+
const refs = await git(state.primary, ["for-each-ref", "--format=%(refname)", "--points-at", commit, "refs/heads/"]);
|
|
672
|
+
if (refs.code || refs.out) return "unmerged";
|
|
673
|
+
}
|
|
657
674
|
commit = info.parents[0]!;
|
|
658
675
|
}
|
|
659
676
|
}
|
|
660
677
|
|
|
678
|
+
async function deliveryError(state: Layout, branch: string, tip: string, cfg: HookPolicy): Promise<string | null> {
|
|
679
|
+
const outcome = await chainToPrimary(state, tip, cfg);
|
|
680
|
+
if (outcome === "merged") return null;
|
|
681
|
+
if (outcome === "conflict") return `bookmark ${branch} has unresolved conflicts`;
|
|
682
|
+
return typeof outcome === "string" ? `branch ${branch} is not merged into ${cfg.worktrees.primary_branch} in the primary checkout` : outcome.error;
|
|
683
|
+
}
|
|
684
|
+
|
|
661
685
|
// The workspace name is never derived from the bookmark (bookmarks can be
|
|
662
686
|
// renamed). Resolve it from the JJ registry: exactly one registered workspace
|
|
663
687
|
// must resolve, by real path, to the current directory.
|