@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.10.113",
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": "7e1310502c3f8959fcfaad9f4e5747d146f4f8bb91a14a8722fa207bd2a48629",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.113",
3
+ "version": "0.10.115",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
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
- if (changed.code || changed.out || parent.code || !parent.out) return found;
623
- const parentFound = await branchForCommit(state.current, parent.out, cfg.protected_branches);
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
- // A tip is delivered when it is an ancestor of the protected branch, or when it
647
- // reaches such an ancestor only through empty single-parent changes (nothing to
648
- // deliver). Content, conflicts, or merge shapes outside that chain fail closed.
649
- async function deliveryError(state: Layout, branch: string, tip: string, cfg: HookPolicy): Promise<string | null> {
650
- let commit = tip;
651
- for (;;) {
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.error;
654
- if (info.conflict) return `bookmark ${branch} has unresolved conflicts`;
655
- if (await mergedIntoPrimary(state, commit, cfg)) return null;
656
- if (!info.empty || info.parents.length !== 1) return `branch ${branch} is not merged into ${cfg.worktrees.primary_branch} in the primary checkout`;
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.