From e8e1ad16e7e95f469c62729acee461172348f977 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Sat, 3 Oct 2026 15:29:42 +0300 Subject: [PATCH 1/5] fix(pstack): worktree-audit never buckets unpushed or closed-unmerged work as safe Three problems in worktree-audit.sh (cursor/plugins#470): - a PR in any non-open state satisfied `$pr != "-"`, so a CLOSED (unmerged) PR made the worktree `safe`; - the computed `remote` column (ahead/no-remote/detached) was never read by the bucket logic, so unpushed commits could be bucketed `safe`; - the header called the script read-only although it runs `git fetch`. Only ancestry of origin/main or a MERGED PR now counts as proof the work exists elsewhere. Worktrees whose HEAD is ahead of / absent from the remote or detached and not on origin/main go to a new `hold-unpushed` bucket, ranked right after hold-wip and hold-open-pr. A CLOSED PR falls through to `review`. The header now states the one write the script performs, and the playbook documents the new bucket in step 1 (steps 4-5 are left untouched to avoid conflicting with #459). Verified on a synthetic repo with a stubbed gh: ahead1+CLOSED and detached off-main -> hold-unpushed; pushed+MERGED, pushed+CLOSED-on-main and detached on origin/main -> safe; pushed+CLOSED with commits not on main -> review. Fixes cursor/plugins#470 --- .../poteto-mode/playbooks/worktree-cleanup.md | 2 +- .../poteto-mode/scripts/worktree-audit.sh | 21 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index 45e1af693..2bcb7670e 100644 --- a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -1,6 +1,6 @@ ### Worktree and simulator cleanup -1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. The transcript scan is slow, so background it. +1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. 4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/pstack/skills/poteto-mode/scripts/worktree-audit.sh index 47e08919d..31898619f 100755 --- a/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Read-only worktree prune audit. Classifies every git worktree by size, merge -# state, uncommitted work, remote/PR state, and the most recent chat that -# operated in it. Emits a table sorted by size with a suggested bucket. Never -# deletes anything; deletion stays a human-gated step in the playbook. +# Worktree prune audit. Classifies every git worktree by size, merge state, +# uncommitted work, remote/PR state, and the most recent chat that operated in +# it. Emits a table sorted by size with a suggested bucket. Never deletes +# anything; deletion stays a human-gated step in the playbook. The only write +# is the best-effort fetch of the base ref for the merge check. # # Usage: worktree-audit.sh [-b|--base ] [repo-path] # repo-path defaults to the current repo. @@ -119,10 +120,18 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; fi recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no) + # Commits that exist only in this worktree (ahead of the remote, no remote + # branch, or detached) are gone with it. Only ancestry of the base or a + # MERGED PR proves the work lives elsewhere; a CLOSED PR proves nothing. + case "$pr" in */merged) pr_merged=yes ;; *) pr_merged=no ;; esac + case "$remote" in ahead*|no-remote|detached) unpushed=yes ;; *) unpushed=no ;; esac + [ "$merged" = YES ] && unpushed=no + case "$dirty" in wip:*) bucket=hold-wip ;; *) case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) - if [ "$recent" = yes ]; then bucket=verify-recent-chat - elif [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe + if [ "$unpushed" = yes ]; then bucket=hold-unpushed + elif [ "$recent" = yes ]; then bucket=verify-recent-chat + elif [ "$merged" = YES ] || [ "$pr_merged" = yes ]; then bucket=safe else bucket=review; fi ;; esac ;; esac From 7ec0a12084470031d048e43f6a9fda20fb998562 Mon Sep 17 00:00:00 2001 From: Helio Pinheiro Date: Mon, 12 Oct 2026 14:51:37 -0300 Subject: [PATCH 2/5] test(pstack): cover unpushed worktree audit decisions Deletion advice must distinguish preserved work from local-only commits. Exercise the audit CLI in an isolated repository with closed PRs, squash merges, a behind branch, and a detached head. --- .../skills/poteto-mode/scripts/package.json | 2 +- .../scripts/worktree-audit.fixture.ts | 83 +++++++++++++++++ .../scripts/worktree-audit.test.ts | 91 +++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 pstack/skills/poteto-mode/scripts/worktree-audit.fixture.ts create mode 100644 pstack/skills/poteto-mode/scripts/worktree-audit.test.ts diff --git a/pstack/skills/poteto-mode/scripts/package.json b/pstack/skills/poteto-mode/scripts/package.json index c8839d74d..5b1f38161 100644 --- a/pstack/skills/poteto-mode/scripts/package.json +++ b/pstack/skills/poteto-mode/scripts/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "bun test watch-pr check-plan.test.ts", + "test": "bun test watch-pr check-plan.test.ts worktree-audit", "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict" }, "dependencies": { diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.fixture.ts b/pstack/skills/poteto-mode/scripts/worktree-audit.fixture.ts new file mode 100644 index 000000000..19b327d07 --- /dev/null +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.fixture.ts @@ -0,0 +1,83 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +type PullRequest = { + number: number; + state: string; + headRefName: string; + headRefOid: string; +}; + +type AuditRow = { + merged: string; + dirty: string; + remote: string; + pr: string; + lastChat: string; + bucket: string; +}; + +export function createAuditFixture() { + const root = mkdtempSync(join(tmpdir(), "worktree-audit-test-")); + const home = join(root, "home"); + const bin = join(root, "bin"); + const origin = join(root, "origin.git"); + const repo = join(root, "repo"); + const prs = join(root, "prs.json"); + const env = { + ...process.env, + HOME: home, + GIT_CONFIG_GLOBAL: join(root, "gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + PATH: `${bin}:${process.env.PATH}`, + AUDIT_FIXTURE_PRS: prs, + }; + const cleanup = () => rmSync(root, { recursive: true, force: true }); + const run = (directory: string, command: string, args: string[]) => { + const result = spawnSync(command, args, { cwd: directory, env, encoding: "utf8" }); + if (result.status !== 0) + throw new Error(`${command} ${args.join(" ")} failed in ${directory}: ${result.error ?? result.stderr}`); + return result.stdout.trim(); + }; + const git = (directory: string, ...args: string[]) => run(directory, "git", args); + const setPrs = (value: PullRequest[]) => writeFileSync(prs, JSON.stringify(value)); + + try { + mkdirSync(home); + mkdirSync(bin); + setPrs([]); + writeFileSync(join(bin, "gh"), '#!/bin/sh\ncat "$AUDIT_FIXTURE_PRS"\n', { mode: 0o755 }); + git(root, "config", "--global", "user.name", "Audit Test"); + git(root, "config", "--global", "user.email", "audit@example.test"); + git(root, "config", "--global", "init.defaultBranch", "main"); + git(root, "init", "--bare", origin); + git(root, "clone", origin, repo); + writeFileSync(join(repo, "base"), "base\n"); + git(repo, "add", "base"); + git(repo, "commit", "-m", "base"); + git(repo, "push", "origin", "main"); + git(repo, "remote", "set-head", "origin", "main"); + } catch (error) { + cleanup(); + throw error; + } + + const audit = (extraEnv: NodeJS.ProcessEnv = {}) => { + const result = spawnSync("bash", [join(import.meta.dir, "worktree-audit.sh"), repo], { + cwd: repo, + env: { ...env, ...extraEnv }, + encoding: "utf8", + }); + const rows: Record = {}; + for (const line of result.stdout.trim().split("\n")) { + if (!line || line.startsWith("# base:") || line.startsWith("SIZE\t")) continue; + const [, , merged, dirty, remote, pr, lastChat, bucket, worktree] = line.split("\t"); + rows[basename(worktree)] = { merged, dirty, remote, pr, lastChat, bucket }; + } + return { status: result.status, stderr: result.stderr, rows }; + }; + + return { root, home, origin, repo, env, run, git, setPrs, audit, cleanup }; +} diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts b/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts new file mode 100644 index 000000000..cc8c040ef --- /dev/null +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts @@ -0,0 +1,91 @@ +import { rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, expect, test } from "bun:test"; +import { createAuditFixture } from "./worktree-audit.fixture"; + +const fixture = createAuditFixture(); +afterAll(fixture.cleanup); +const { root, repo, origin, git } = fixture; +const worktree = (branch: string) => { + const directory = join(root, branch); + git(repo, "worktree", "add", "-b", branch, directory, "main"); + return directory; +}; +const commit = (directory: string, file: string) => { + writeFileSync(join(directory, file), `${file}\n`); + git(directory, "add", file); + git(directory, "commit", "-m", file); + return git(directory, "rev-parse", "HEAD"); +}; +const pushed = (branch: string) => { + const directory = worktree(branch); + const head = commit(directory, branch); + git(directory, "push", "origin", branch); + return { directory, head }; +}; + +const closedUnpushed = pushed("closed-unpushed"); +commit(closedUnpushed.directory, "local-only"); +const closedPushed = pushed("closed-pushed"); +const behind = pushed("behind"); +const other = join(root, "other"); +git(root, "clone", origin, other); +git(other, "checkout", "behind"); +commit(other, "remote-only"); +git(other, "push", "origin", "behind"); +git(behind.directory, "fetch", "origin"); + +const squashPruned = pushed("squash-pruned"); +const squashKept = pushed("squash-kept"); +const mergedThenMore = pushed("merged-then-more"); +for (const branch of ["squash-pruned", "squash-kept", "merged-then-more"]) { + git(repo, "merge", "--squash", branch); + git(repo, "commit", "-m", `Squash ${branch}`); +} +git(repo, "push", "origin", "main"); +git(repo, "push", "origin", "--delete", "squash-pruned", "merged-then-more"); +git(repo, "fetch", "--prune", "origin"); +commit(mergedThenMore.directory, "after-merge"); + +const detached = join(root, "detached-off-base"); +git(repo, "worktree", "add", "--detach", detached, "main"); +commit(detached, "detached-only"); + +const gone = join(root, "detached-gone"); +git(repo, "worktree", "add", "--detach", gone, "main"); +commit(gone, "gone-only"); +rmSync(gone, { recursive: true, force: true }); + +const reused = pushed("reused-name"); +git(repo, "push", "origin", "--delete", "reused-name"); +git(repo, "fetch", "--prune", "origin"); + +fixture.setPrs([ + { number: 8, state: "CLOSED", headRefName: "reused-name", headRefOid: "0".repeat(40) }, + { number: 7, state: "MERGED", headRefName: "reused-name", headRefOid: reused.head }, + { number: 1, state: "CLOSED", headRefName: "closed-unpushed", headRefOid: closedUnpushed.head }, + { number: 2, state: "CLOSED", headRefName: "closed-pushed", headRefOid: closedPushed.head }, + { number: 4, state: "MERGED", headRefName: "squash-pruned", headRefOid: squashPruned.head }, + { number: 5, state: "MERGED", headRefName: "squash-kept", headRefOid: squashKept.head }, + { number: 6, state: "MERGED", headRefName: "merged-then-more", headRefOid: mergedThenMore.head }, +]); +const result = fixture.audit(); + +const expectations = [ + ["closed-unpushed", "ahead1", "#1/CLOSED", "hold-unpushed"], + ["closed-pushed", "pushed", "#2/CLOSED", "review"], + ["behind", "behind", "-", "review"], + ["squash-pruned", "no-remote", "#4/MERGED", "safe"], + ["squash-kept", "pushed", "#5/MERGED", "safe"], + ["merged-then-more", "no-remote", "#6/MERGED", "hold-unpushed"], + ["detached-off-base", "detached", "-", "hold-unpushed"], + ["detached-gone", "detached", "-", "hold-unpushed"], + ["reused-name", "no-remote", "#7/MERGED", "safe"], +]; +for (const [worktree, remote, pr, bucket] of expectations) { + test(`worktree audit: ${worktree}`, () => { + expect(result.status).toBe(0); + expect(result.rows[worktree]).toMatchObject({ remote, pr, bucket }); + if (worktree === "squash-pruned") expect(result.rows[worktree].merged).toBe("no"); + }); +} From c8fdd2d10112b0a0abb85a52f1d39ead6802367d Mon Sep 17 00:00:00 2001 From: Helio Pinheiro Date: Mon, 12 Oct 2026 14:53:27 -0300 Subject: [PATCH 3/5] fix(pstack): recognize work preserved on remotes and PR heads A behind branch has no local-only commits. Deleted origin branches and fork branches can still have their exact head preserved by a PR. Compare that head rather than treating every missing remote as unpushed, while holding commits added after a merge. --- .../poteto-mode/playbooks/worktree-cleanup.md | 2 +- .../poteto-mode/scripts/worktree-audit.sh | 22 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index 2bcb7670e..6212b9d39 100644 --- a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -1,6 +1,6 @@ ### Worktree and simulator cleanup -1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. +1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. 4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/pstack/skills/poteto-mode/scripts/worktree-audit.sh index 31898619f..c7bb2017b 100755 --- a/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -63,10 +63,10 @@ prs=$(mktemp) if command -v origin >/dev/null 2>&1 \ && raw=$(origin pr list --author "@me" --state all --limit 1000 \ --json number,status,headRef 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef}]' > "$prs" 2>/dev/null + printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef, head_oid: ""}]' > "$prs" 2>/dev/null elif raw=$(gh pr list --author "@me" --state all --limit 1000 \ - --json number,state,headRefName 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName}]' > "$prs" 2>/dev/null + --json number,state,headRefName,headRefOid 2>/dev/null); then + printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName, head_oid: (.headRefOid // "")}]' > "$prs" 2>/dev/null fi [ -s "$prs" ] || echo "[]" > "$prs" @@ -103,10 +103,15 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; [ "$(git -C "$wt" rev-parse "origin/$branch" 2>/dev/null)" = "$head" ] \ && remote=pushed \ || remote="ahead$(git -C "$wt" rev-list --count "origin/$branch..HEAD" 2>/dev/null)" + [ "$remote" = ahead0 ] && remote=behind else remote=no-remote; fi - pr=$([ -n "$branch" ] && jq -r --arg b "$branch" \ - '.[] | select(.head==$b) | "#\(.number)/\(.state)"' "$prs" 2>/dev/null | head -1) + # Several PRs can share a branch name; prefer the one whose head is HEAD. + pr_record=$([ -n "$branch" ] && jq -r --arg b "$branch" --arg h "$head" \ + '[.[] | select(.head==$b)] | (map(select(.head_oid==$h)) + .)[0] + | select(.) | "#\(.number)/\(.state)\t\(.head_oid // "")"' "$prs" 2>/dev/null) + pr=${pr_record%%$'\t'*} + pr_head=${pr_record#*$'\t'} [ -z "$pr" ] && pr="-" # Most recent chat whose transcript operated in this worktree. Match path @@ -120,12 +125,13 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; fi recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no) - # Commits that exist only in this worktree (ahead of the remote, no remote - # branch, or detached) are gone with it. Only ancestry of the base or a - # MERGED PR proves the work lives elsewhere; a CLOSED PR proves nothing. + # An exact PR head proves the commits were pushed even if origin has no + # branch (deleted after merging, or hosted on a fork). A closed PR still + # proves nothing about whether those commits were merged. case "$pr" in */merged) pr_merged=yes ;; *) pr_merged=no ;; esac case "$remote" in ahead*|no-remote|detached) unpushed=yes ;; *) unpushed=no ;; esac [ "$merged" = YES ] && unpushed=no + [ -n "$pr_head" ] && [ "$head" = "$pr_head" ] && unpushed=no case "$dirty" in wip:*) bucket=hold-wip ;; *) case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) From 4aedd61b4a58643fecd383f6f5f6f9a7bfd7af97 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Wed, 14 Oct 2026 13:10:16 +0300 Subject: [PATCH 4/5] test(pstack): expect lowercase PR states after the rebase onto #613 main (#613) normalises the PR list to {number, state, head} with a lowercase state and prefers the origin CLI over gh. The audit now carries head_oid through that list (gh provides it, origin's record leaves it empty) so heliowap's PR-head check still works, and the fixture expectations use the lowercase states the script prints. --- .../poteto-mode/scripts/worktree-audit.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts b/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts index cc8c040ef..cd35a0d9f 100644 --- a/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts @@ -72,15 +72,15 @@ fixture.setPrs([ const result = fixture.audit(); const expectations = [ - ["closed-unpushed", "ahead1", "#1/CLOSED", "hold-unpushed"], - ["closed-pushed", "pushed", "#2/CLOSED", "review"], + ["closed-unpushed", "ahead1", "#1/closed", "hold-unpushed"], + ["closed-pushed", "pushed", "#2/closed", "review"], ["behind", "behind", "-", "review"], - ["squash-pruned", "no-remote", "#4/MERGED", "safe"], - ["squash-kept", "pushed", "#5/MERGED", "safe"], - ["merged-then-more", "no-remote", "#6/MERGED", "hold-unpushed"], + ["squash-pruned", "no-remote", "#4/merged", "safe"], + ["squash-kept", "pushed", "#5/merged", "safe"], + ["merged-then-more", "no-remote", "#6/merged", "hold-unpushed"], ["detached-off-base", "detached", "-", "hold-unpushed"], ["detached-gone", "detached", "-", "hold-unpushed"], - ["reused-name", "no-remote", "#7/MERGED", "safe"], + ["reused-name", "no-remote", "#7/merged", "safe"], ]; for (const [worktree, remote, pr, bucket] of expectations) { test(`worktree audit: ${worktree}`, () => { From 41da1b68dceed425651f7e69bc58b2c0896ef0f3 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Wed, 28 Oct 2026 07:00:05 +0300 Subject: [PATCH 5/5] chore(pstack): regenerate the Claude Code harness tree for the worktree-audit changes --- .../poteto-mode/playbooks/worktree-cleanup.md | 2 +- .../skills/poteto-mode/scripts/package.json | 2 +- .../scripts/worktree-audit.fixture.ts | 83 +++++++++++++++++ .../poteto-mode/scripts/worktree-audit.sh | 37 +++++--- .../scripts/worktree-audit.test.ts | 91 +++++++++++++++++++ 5 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.fixture.ts create mode 100644 harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts diff --git a/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md b/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md index 45e1af693..6212b9d39 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -1,6 +1,6 @@ ### Worktree and simulator cleanup -1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. The transcript scan is slow, so background it. +1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. 4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/package.json b/harness/pstack/claude-code/skills/poteto-mode/scripts/package.json index c8839d74d..5b1f38161 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/package.json +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "bun test watch-pr check-plan.test.ts", + "test": "bun test watch-pr check-plan.test.ts worktree-audit", "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict" }, "dependencies": { diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.fixture.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.fixture.ts new file mode 100644 index 000000000..19b327d07 --- /dev/null +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.fixture.ts @@ -0,0 +1,83 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +type PullRequest = { + number: number; + state: string; + headRefName: string; + headRefOid: string; +}; + +type AuditRow = { + merged: string; + dirty: string; + remote: string; + pr: string; + lastChat: string; + bucket: string; +}; + +export function createAuditFixture() { + const root = mkdtempSync(join(tmpdir(), "worktree-audit-test-")); + const home = join(root, "home"); + const bin = join(root, "bin"); + const origin = join(root, "origin.git"); + const repo = join(root, "repo"); + const prs = join(root, "prs.json"); + const env = { + ...process.env, + HOME: home, + GIT_CONFIG_GLOBAL: join(root, "gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + PATH: `${bin}:${process.env.PATH}`, + AUDIT_FIXTURE_PRS: prs, + }; + const cleanup = () => rmSync(root, { recursive: true, force: true }); + const run = (directory: string, command: string, args: string[]) => { + const result = spawnSync(command, args, { cwd: directory, env, encoding: "utf8" }); + if (result.status !== 0) + throw new Error(`${command} ${args.join(" ")} failed in ${directory}: ${result.error ?? result.stderr}`); + return result.stdout.trim(); + }; + const git = (directory: string, ...args: string[]) => run(directory, "git", args); + const setPrs = (value: PullRequest[]) => writeFileSync(prs, JSON.stringify(value)); + + try { + mkdirSync(home); + mkdirSync(bin); + setPrs([]); + writeFileSync(join(bin, "gh"), '#!/bin/sh\ncat "$AUDIT_FIXTURE_PRS"\n', { mode: 0o755 }); + git(root, "config", "--global", "user.name", "Audit Test"); + git(root, "config", "--global", "user.email", "audit@example.test"); + git(root, "config", "--global", "init.defaultBranch", "main"); + git(root, "init", "--bare", origin); + git(root, "clone", origin, repo); + writeFileSync(join(repo, "base"), "base\n"); + git(repo, "add", "base"); + git(repo, "commit", "-m", "base"); + git(repo, "push", "origin", "main"); + git(repo, "remote", "set-head", "origin", "main"); + } catch (error) { + cleanup(); + throw error; + } + + const audit = (extraEnv: NodeJS.ProcessEnv = {}) => { + const result = spawnSync("bash", [join(import.meta.dir, "worktree-audit.sh"), repo], { + cwd: repo, + env: { ...env, ...extraEnv }, + encoding: "utf8", + }); + const rows: Record = {}; + for (const line of result.stdout.trim().split("\n")) { + if (!line || line.startsWith("# base:") || line.startsWith("SIZE\t")) continue; + const [, , merged, dirty, remote, pr, lastChat, bucket, worktree] = line.split("\t"); + rows[basename(worktree)] = { merged, dirty, remote, pr, lastChat, bucket }; + } + return { status: result.status, stderr: result.stderr, rows }; + }; + + return { root, home, origin, repo, env, run, git, setPrs, audit, cleanup }; +} diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh index 47e08919d..c7bb2017b 100755 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Read-only worktree prune audit. Classifies every git worktree by size, merge -# state, uncommitted work, remote/PR state, and the most recent chat that -# operated in it. Emits a table sorted by size with a suggested bucket. Never -# deletes anything; deletion stays a human-gated step in the playbook. +# Worktree prune audit. Classifies every git worktree by size, merge state, +# uncommitted work, remote/PR state, and the most recent chat that operated in +# it. Emits a table sorted by size with a suggested bucket. Never deletes +# anything; deletion stays a human-gated step in the playbook. The only write +# is the best-effort fetch of the base ref for the merge check. # # Usage: worktree-audit.sh [-b|--base ] [repo-path] # repo-path defaults to the current repo. @@ -62,10 +63,10 @@ prs=$(mktemp) if command -v origin >/dev/null 2>&1 \ && raw=$(origin pr list --author "@me" --state all --limit 1000 \ --json number,status,headRef 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef}]' > "$prs" 2>/dev/null + printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef, head_oid: ""}]' > "$prs" 2>/dev/null elif raw=$(gh pr list --author "@me" --state all --limit 1000 \ - --json number,state,headRefName 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName}]' > "$prs" 2>/dev/null + --json number,state,headRefName,headRefOid 2>/dev/null); then + printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName, head_oid: (.headRefOid // "")}]' > "$prs" 2>/dev/null fi [ -s "$prs" ] || echo "[]" > "$prs" @@ -102,10 +103,15 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; [ "$(git -C "$wt" rev-parse "origin/$branch" 2>/dev/null)" = "$head" ] \ && remote=pushed \ || remote="ahead$(git -C "$wt" rev-list --count "origin/$branch..HEAD" 2>/dev/null)" + [ "$remote" = ahead0 ] && remote=behind else remote=no-remote; fi - pr=$([ -n "$branch" ] && jq -r --arg b "$branch" \ - '.[] | select(.head==$b) | "#\(.number)/\(.state)"' "$prs" 2>/dev/null | head -1) + # Several PRs can share a branch name; prefer the one whose head is HEAD. + pr_record=$([ -n "$branch" ] && jq -r --arg b "$branch" --arg h "$head" \ + '[.[] | select(.head==$b)] | (map(select(.head_oid==$h)) + .)[0] + | select(.) | "#\(.number)/\(.state)\t\(.head_oid // "")"' "$prs" 2>/dev/null) + pr=${pr_record%%$'\t'*} + pr_head=${pr_record#*$'\t'} [ -z "$pr" ] && pr="-" # Most recent chat whose transcript operated in this worktree. Match path @@ -119,10 +125,19 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; fi recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no) + # An exact PR head proves the commits were pushed even if origin has no + # branch (deleted after merging, or hosted on a fork). A closed PR still + # proves nothing about whether those commits were merged. + case "$pr" in */merged) pr_merged=yes ;; *) pr_merged=no ;; esac + case "$remote" in ahead*|no-remote|detached) unpushed=yes ;; *) unpushed=no ;; esac + [ "$merged" = YES ] && unpushed=no + [ -n "$pr_head" ] && [ "$head" = "$pr_head" ] && unpushed=no + case "$dirty" in wip:*) bucket=hold-wip ;; *) case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) - if [ "$recent" = yes ]; then bucket=verify-recent-chat - elif [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe + if [ "$unpushed" = yes ]; then bucket=hold-unpushed + elif [ "$recent" = yes ]; then bucket=verify-recent-chat + elif [ "$merged" = YES ] || [ "$pr_merged" = yes ]; then bucket=safe else bucket=review; fi ;; esac ;; esac diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts new file mode 100644 index 000000000..cd35a0d9f --- /dev/null +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts @@ -0,0 +1,91 @@ +import { rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, expect, test } from "bun:test"; +import { createAuditFixture } from "./worktree-audit.fixture"; + +const fixture = createAuditFixture(); +afterAll(fixture.cleanup); +const { root, repo, origin, git } = fixture; +const worktree = (branch: string) => { + const directory = join(root, branch); + git(repo, "worktree", "add", "-b", branch, directory, "main"); + return directory; +}; +const commit = (directory: string, file: string) => { + writeFileSync(join(directory, file), `${file}\n`); + git(directory, "add", file); + git(directory, "commit", "-m", file); + return git(directory, "rev-parse", "HEAD"); +}; +const pushed = (branch: string) => { + const directory = worktree(branch); + const head = commit(directory, branch); + git(directory, "push", "origin", branch); + return { directory, head }; +}; + +const closedUnpushed = pushed("closed-unpushed"); +commit(closedUnpushed.directory, "local-only"); +const closedPushed = pushed("closed-pushed"); +const behind = pushed("behind"); +const other = join(root, "other"); +git(root, "clone", origin, other); +git(other, "checkout", "behind"); +commit(other, "remote-only"); +git(other, "push", "origin", "behind"); +git(behind.directory, "fetch", "origin"); + +const squashPruned = pushed("squash-pruned"); +const squashKept = pushed("squash-kept"); +const mergedThenMore = pushed("merged-then-more"); +for (const branch of ["squash-pruned", "squash-kept", "merged-then-more"]) { + git(repo, "merge", "--squash", branch); + git(repo, "commit", "-m", `Squash ${branch}`); +} +git(repo, "push", "origin", "main"); +git(repo, "push", "origin", "--delete", "squash-pruned", "merged-then-more"); +git(repo, "fetch", "--prune", "origin"); +commit(mergedThenMore.directory, "after-merge"); + +const detached = join(root, "detached-off-base"); +git(repo, "worktree", "add", "--detach", detached, "main"); +commit(detached, "detached-only"); + +const gone = join(root, "detached-gone"); +git(repo, "worktree", "add", "--detach", gone, "main"); +commit(gone, "gone-only"); +rmSync(gone, { recursive: true, force: true }); + +const reused = pushed("reused-name"); +git(repo, "push", "origin", "--delete", "reused-name"); +git(repo, "fetch", "--prune", "origin"); + +fixture.setPrs([ + { number: 8, state: "CLOSED", headRefName: "reused-name", headRefOid: "0".repeat(40) }, + { number: 7, state: "MERGED", headRefName: "reused-name", headRefOid: reused.head }, + { number: 1, state: "CLOSED", headRefName: "closed-unpushed", headRefOid: closedUnpushed.head }, + { number: 2, state: "CLOSED", headRefName: "closed-pushed", headRefOid: closedPushed.head }, + { number: 4, state: "MERGED", headRefName: "squash-pruned", headRefOid: squashPruned.head }, + { number: 5, state: "MERGED", headRefName: "squash-kept", headRefOid: squashKept.head }, + { number: 6, state: "MERGED", headRefName: "merged-then-more", headRefOid: mergedThenMore.head }, +]); +const result = fixture.audit(); + +const expectations = [ + ["closed-unpushed", "ahead1", "#1/closed", "hold-unpushed"], + ["closed-pushed", "pushed", "#2/closed", "review"], + ["behind", "behind", "-", "review"], + ["squash-pruned", "no-remote", "#4/merged", "safe"], + ["squash-kept", "pushed", "#5/merged", "safe"], + ["merged-then-more", "no-remote", "#6/merged", "hold-unpushed"], + ["detached-off-base", "detached", "-", "hold-unpushed"], + ["detached-gone", "detached", "-", "hold-unpushed"], + ["reused-name", "no-remote", "#7/merged", "safe"], +]; +for (const [worktree, remote, pr, bucket] of expectations) { + test(`worktree audit: ${worktree}`, () => { + expect(result.status).toBe(0); + expect(result.rows[worktree]).toMatchObject({ remote, pr, bucket }); + if (worktree === "squash-pruned") expect(result.rows[worktree].merged).toBe("no"); + }); +}