@afokapu/atdd-flow 0.3.82 → 0.3.83

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.82",
3
+ "version": "0.3.83",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,54 @@
1
+ // URN: component:flow:terminal-seat-retirement:retire-merge-proof:backend:integration
2
+ // Tested-By:
3
+ // - test:atdd-flow:terminal-seat-retire-merge-proof
4
+ // Runtime: bun
5
+ // Purpose: Independent merge proof for retiring a driver that worktree finish refuses.
6
+
7
+ import { type Seat, runOutput } from "./core";
8
+ import { isAncestor } from "./vcs";
9
+
10
+ type PullRequest = { state?: string; headRefOid?: string; mergeCommit?: { oid?: string } | null };
11
+
12
+ /** A clean worktree is required; only generated hook files may be untracked, as `worktree finish` allows. */
13
+ async function worktreeIsClean(worktree: string) {
14
+ const status = await runOutput(["git", "-C", worktree, "status", "--porcelain"]);
15
+ return status.split("\n").filter(Boolean).every((line) => line.endsWith(" .githooks/") || line.endsWith(" .githooks"));
16
+ }
17
+
18
+ /** No uncommitted work, and the worktree is on the exact branch tip being proven merged. */
19
+ async function worktreeIsAtTip(worktree: string, tip: string) {
20
+ return await worktreeIsClean(worktree) && await runOutput(["git", "-C", worktree, "rev-parse", "HEAD"]) === tip;
21
+ }
22
+
23
+ /** Merged PRs for the branch, from GitHub; an unavailable or malformed answer is no evidence. */
24
+ async function mergedPullRequests(repository: string, branch: string): Promise<PullRequest[]> {
25
+ try {
26
+ const listed = JSON.parse(await runOutput(["gh", "pr", "list", "--head", branch, "--state", "merged", "--limit", "20", "--json", "state,headRefOid,mergeCommit"], repository));
27
+ return Array.isArray(listed) ? listed : [];
28
+ } catch { return []; }
29
+ }
30
+
31
+ /** A merged PR whose head is exactly the branch tip and whose merge (or squash) commit is on the primary branch. */
32
+ async function squashMergeProof(repository: string, branch: string, tip: string, primary: string) {
33
+ for (const pull of await mergedPullRequests(repository, branch)) {
34
+ const merge = pull.mergeCommit?.oid;
35
+ if (pull.state === "MERGED" && pull.headRefOid === tip && merge && await isAncestor(repository, merge, primary)) return merge;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ /**
41
+ * Proves a retiring driver has no unmerged work without `worktree finish`: the worktree is clean and at the branch tip, and that tip
42
+ * is reachable from the primary branch or is the head of a merged PR whose merge commit is on it. Returns the evidence, or undefined.
43
+ * Nothing is removed or deleted: the caller retains the worktree and branch.
44
+ */
45
+ export async function terminalMergeProof(repository: string, owner: Seat): Promise<string | undefined> {
46
+ try {
47
+ const primary = await runOutput(["git", "-C", repository, "branch", "--show-current"]);
48
+ const tip = await runOutput(["git", "-C", repository, "rev-parse", "--verify", `refs/heads/${owner.branch}`]);
49
+ if (!primary || !await worktreeIsAtTip(owner.worktree, tip)) return undefined;
50
+ if (await isAncestor(repository, tip, primary)) return `branch tip ${tip} is reachable from ${primary}`;
51
+ const merge = await squashMergeProof(repository, owner.branch, tip, primary);
52
+ return merge ? `branch tip ${tip} is the head of a merged PR whose merge commit ${merge} is on ${primary}` : undefined;
53
+ } catch { return undefined; }
54
+ }
@@ -0,0 +1,49 @@
1
+ // URN: component:flow:terminal-seat-retirement:retire-merge-proof:backend:integration
2
+ // Tested-By:
3
+ // - test:atdd-flow:terminal-seat-retire-merge-proof
4
+ // Runtime: bun
5
+ // Purpose: Independent merge proof for retiring a terminal driver that worktree finish refuses.
6
+
7
+ import { type Seat, runOutput } from "./core";
8
+ import { isAncestor } from "./vcs";
9
+
10
+ type PullRequest = { state?: string; headRefOid?: string; mergeCommit?: { oid?: string } | null };
11
+
12
+ /** A clean worktree is required; only generated hook files may be untracked, as `worktree finish` allows. */
13
+ async function worktreeIsClean(worktree: string) {
14
+ const status = await runOutput(["git", "-C", worktree, "status", "--porcelain"]);
15
+ return status.split("\n").filter(Boolean).every((line) => line.endsWith(" .githooks/") || line.endsWith(" .githooks"));
16
+ }
17
+
18
+ /** Merged PRs for the branch, from GitHub; an unavailable or malformed answer is no evidence. */
19
+ async function mergedPullRequests(repository: string, branch: string): Promise<PullRequest[]> {
20
+ try {
21
+ const listed = JSON.parse(await runOutput(["gh", "pr", "list", "--head", branch, "--state", "merged", "--limit", "20", "--json", "state,headRefOid,mergeCommit"], repository));
22
+ return Array.isArray(listed) ? listed : [];
23
+ } catch { return []; }
24
+ }
25
+
26
+ /** A merged PR whose head is exactly the branch tip and whose merge (or squash) commit is on the primary branch. */
27
+ async function squashMergeProof(repository: string, branch: string, tip: string, primary: string) {
28
+ for (const pull of await mergedPullRequests(repository, branch)) {
29
+ const merge = pull.mergeCommit?.oid;
30
+ if (pull.state === "MERGED" && pull.headRefOid === tip && merge && await isAncestor(repository, merge, primary)) return merge;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ /**
36
+ * Proves a retiring driver has no unmerged work without `worktree finish`: the worktree is clean and at the branch tip, and that tip
37
+ * is reachable from the primary branch or is the head of a merged PR whose merge commit is on it. Returns the evidence, or undefined.
38
+ * Nothing is removed or deleted: the caller retains the worktree and branch.
39
+ */
40
+ export async function terminalMergeProof(repository: string, owner: Seat): Promise<string | undefined> {
41
+ try {
42
+ const primary = await runOutput(["git", "-C", repository, "branch", "--show-current"]);
43
+ const tip = await runOutput(["git", "-C", repository, "rev-parse", "--verify", `refs/heads/${owner.branch}`]);
44
+ if (!primary || !await worktreeIsClean(owner.worktree) || await runOutput(["git", "-C", owner.worktree, "rev-parse", "HEAD"]) !== tip) return undefined;
45
+ if (await isAncestor(repository, tip, primary)) return `branch tip ${tip} is reachable from ${primary}`;
46
+ const merge = await squashMergeProof(repository, owner.branch, tip, primary);
47
+ return merge ? `branch tip ${tip} is the head of a merged PR whose merge commit ${merge} is on ${primary}` : undefined;
48
+ } catch { return undefined; }
49
+ }
package/src/tasks.ts CHANGED
@@ -3,6 +3,7 @@ import { basename, dirname, join, resolve } from "node:path";
3
3
  import {
4
4
  type Checkpoint, type Seat, atomicYaml, canonicalAddress, exists, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
5
5
  } from "./core";
6
+ import { terminalMergeProof } from "./retire-merge-proof";
6
7
  import { operatorAuthorizationPayload } from "./task-supersede-authorization";
7
8
  import { branchCommit, currentCommit, isAncestor, vcsMode, type VcsMode } from "./vcs";
8
9
 
@@ -258,6 +259,21 @@ async function retirementBun(root: string, projectName: string) {
258
259
  return [required(Bun.which("atdd-bun") ?? undefined, "atdd-bun in the primary checkout node_modules/.bin or on PATH")];
259
260
  }
260
261
 
262
+ /**
263
+ * ATDD Bun removes a clean merged worktree and its branch. When it refuses (a squash-merged or post-JJ branch it cannot prove
264
+ * merged), retirement proceeds only on independent merge proof; the worktree and branch are then retained and the refusal stands otherwise.
265
+ */
266
+ async function finishOrProveMerged(root: string, projectName: string, owner: Seat) {
267
+ const config = await project(root, projectName);
268
+ try {
269
+ return await runOutput([...await retirementBun(root, projectName), "worktree", "finish", "--delete-branch", "--vcs", vcsMode(config)], owner.worktree);
270
+ } catch (error) {
271
+ const proof = config.repository && await exists(owner.worktree) ? await terminalMergeProof(config.repository, owner) : undefined;
272
+ if (!proof) throw error;
273
+ return `Worktree finish refused (${(error as Error).message}); merge proof: ${proof}; worktree ${owner.worktree} and branch ${owner.branch} retained.`;
274
+ }
275
+ }
276
+
261
277
  async function retireDriver(root: string, projectName: string, assignee: string, id: string, allowAbsentWorktree = false, allowPrimaryCheckout = false) {
262
278
  const unfinished = (await allTasks(root, projectName))
263
279
  .filter((entry) => entry.id !== id && (entry.task.assignee === assignee || entry.task.coordinator === assignee) && !terminal(entry.task))
@@ -271,7 +287,7 @@ async function retireDriver(root: string, projectName: string, assignee: string,
271
287
  const primaryCheckout = allowPrimaryCheckout && primary && await canonicalPath(owner.worktree) === await canonicalPath(primary);
272
288
  const summary = (primaryCheckout ? `Primary checkout ${owner.worktree} retained; retired without worktree finish.` : undefined)
273
289
  ?? (allowAbsentWorktree ? await absentUnregisteredWorktree(root, projectName, owner) : undefined)
274
- ?? await runOutput([...await retirementBun(root, projectName), "worktree", "finish", "--delete-branch", "--vcs", vcsMode(await project(root, projectName))], owner.worktree);
290
+ ?? await finishOrProveMerged(root, projectName, owner);
275
291
  owner.retired = { task: `${projectName}/${id}`, completed_at: now(), summary };
276
292
  await atomicYaml(paths(root).seatFile(assignee), owner);
277
293
  const checkpoint: Checkpoint = {