diff --git a/.claude/settings.json b/.claude/settings.json index 71e7387..ffd5940 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1,18 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bun ./prune-worktrees.ts", + "timeout": 30, + "statusMessage": "Pruning stale agent worktrees" + } + ] + } + ], "PreToolUse": [ { "matcher": "Bash", diff --git a/prune-worktrees.ts b/prune-worktrees.ts new file mode 100644 index 0000000..84669d9 --- /dev/null +++ b/prune-worktrees.ts @@ -0,0 +1,95 @@ +#!/usr/bin/env bun +/** + * Removes agent worktrees under `.claude/worktrees/` that have nothing left in + * them, and runs on `SessionStart` (see `.claude/settings.json`). + * + * Claude Code creates one of these per `isolation: "worktree"` subagent and is + * meant to clean it up when the copy comes back unchanged. One survived and sat + * at 276MB — 195MB of it a second `node_modules`, 74MB a second `site/` — for a + * branch that had already been merged. Nothing points at these directories + * (`.gitignore:42` keeps them out of the repo entirely), so the only thing that + * would ever notice is `du`. + * + * The bar for removing one is deliberately high, because the alternative is + * deleting work that only exists there: + * + * - the working tree is clean, tracked *and* untracked (`--porcelain` with + * `-unormal`, so a stray file someone dropped in counts as work), and + * - the branch it is on has no commits the default branch lacks. + * + * Anything else is left alone and reported, which is the case worth seeing: a + * worktree with real work in it is not clutter, it is something you forgot. + * + * `git worktree prune` is not this. It only forgets administrative files whose + * directory has already been deleted by hand — the 276MB directory is exactly + * the case it walks past. + */ + +import { $ } from "bun"; + +const WORKTREE_ROOT = ".claude/worktrees"; + +type Worktree = { path: string; branch: string | null }; + +/** `git worktree list --porcelain` as records; the first is always the main checkout. */ +async function listWorktrees(): Promise { + const out = await $`git worktree list --porcelain`.text(); + const worktrees: Worktree[] = []; + for (const block of out.trim().split("\n\n")) { + const path = block.match(/^worktree (.+)$/m)?.[1]; + if (!path) continue; + const ref = block.match(/^branch (.+)$/m)?.[1] ?? null; + worktrees.push({ path, branch: ref?.replace(/^refs\/heads\//, "") ?? null }); + } + return worktrees; +} + +async function defaultBranch(): Promise { + for (const candidate of ["main", "master"]) { + if (await $`git show-ref --verify --quiet refs/heads/${candidate}`.nothrow().then((r) => r.exitCode === 0)) { + return candidate; + } + } + return "HEAD"; +} + +async function isClean(path: string): Promise { + const status = await $`git -C ${path} status --porcelain -unormal`.nothrow().text(); + return status.trim() === ""; +} + +/** True when `branch` holds no commit that `base` does not already have. */ +async function isMerged(branch: string, base: string): Promise { + const ahead = await $`git rev-list --count ${base}..${branch}`.nothrow().text(); + return ahead.trim() === "0"; +} + +const base = await defaultBranch(); +const [, ...agentWorktrees] = await listWorktrees(); +const kept: string[] = []; + +for (const { path, branch } of agentWorktrees) { + if (!path.includes(WORKTREE_ROOT)) continue; + + const reason = !(await isClean(path)) + ? "uncommitted changes" + : branch && !(await isMerged(branch, base)) + ? `commits not in ${base}` + : null; + + if (reason) { + kept.push(`${path} (${reason})`); + continue; + } + + const removed = await $`git worktree remove ${path}`.nothrow(); + if (removed.exitCode !== 0) { + kept.push(`${path} (git declined to remove it)`); + continue; + } + console.log(`pruned stale agent worktree: ${path}`); +} + +if (kept.length > 0) { + console.log(`agent worktrees left in place:\n ${kept.join("\n ")}`); +} diff --git a/site/public/terminal.png b/site/public/terminal.png index 88655a1..cdf79a5 100644 Binary files a/site/public/terminal.png and b/site/public/terminal.png differ diff --git a/tui/src/app.overlay.test.tsx b/tui/src/app.overlay.test.tsx index 818d11c..04b05a1 100644 --- a/tui/src/app.overlay.test.tsx +++ b/tui/src/app.overlay.test.tsx @@ -105,6 +105,21 @@ function mount() { return { stdout, stdin, unmount: () => instance.unmount() }; } +/** + * Wait for the frame to contain what the test is about, rather than for a fixed + * duration. The 1000-item transcript took 83–117ms to draw against the old 120ms + * sleep, so a loaded CI runner read a half-drawn frame and went red. + */ +async function waitForFrame(app: { stdout: Capture }, needle: string): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (app.stdout.text().includes(needle)) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`no ${JSON.stringify(needle)} in the frame after 10s`); +} + +/** Only for asserting something did *not* happen, where there is no frame to wait for. */ const settle = () => new Promise((resolve) => setTimeout(resolve, 120)); const TRANSCRIPT = "explain the readme"; @@ -118,7 +133,7 @@ describe("dialogs float over the app", () => { test("keeps the transcript on screen behind the model picker", async () => { const app = mount(); store.openModelPicker(); - await settle(); + await waitForFrame(app, "Select model"); // Before, the dialog replaced the main content and the screen went black. expect(app.stdout.text()).toContain(TRANSCRIPT); @@ -133,7 +148,7 @@ describe("dialogs float over the app", () => { command: "bun test", toolName: "run_tests", }); - await settle(); + await waitForFrame(app, "Run tests"); expect(app.stdout.text()).toContain(TRANSCRIPT); expect(app.stdout.text()).toContain("Run tests"); @@ -146,7 +161,7 @@ describe("dialogs float over the app", () => { test("keeps the transcript on screen behind a question", async () => { const app = mount(); const answer = store.setPendingQuestion({ id: "q-1", questions: ["Which database?"] }); - await settle(); + await waitForFrame(app, "Which database?"); expect(app.stdout.text()).toContain(TRANSCRIPT); expect(app.stdout.text()).toContain("Which database?"); @@ -159,7 +174,7 @@ describe("dialogs float over the app", () => { test("draws the background faded and the panel lit", async () => { const app = mount(); store.openModelPicker(); - await settle(); + await waitForFrame(app, "Select model"); const emitted = app.stdout.foregrounds(); // Both palettes on screen at once is the whole point: dimmed behind, lit in @@ -171,7 +186,7 @@ describe("dialogs float over the app", () => { test("uses only the lit palette when no dialog is open", async () => { const app = mount(); - await settle(); + await waitForFrame(app, TRANSCRIPT); const emitted = app.stdout.foregrounds(); expect(emitted).toContain(colors.textBase); @@ -228,7 +243,9 @@ describe("a chord never answers the diff", () => { test("Ctrl+A leaves the edit pending instead of applying it", async () => { const app = mount(); leaveUnanswered("ctrl-a"); - await settle(); + // The diff has to be on screen before the key is pressed, or there is no + // mounted handler to ignore it and the assertion below passes for no reason. + await waitForFrame(app, "notes.txt"); app.stdin.press(CTRL_A); await settle(); @@ -241,7 +258,7 @@ describe("a chord never answers the diff", () => { test("Ctrl+R leaves the edit pending instead of rejecting it", async () => { const app = mount(); leaveUnanswered("ctrl-r"); - await settle(); + await waitForFrame(app, "notes.txt"); app.stdin.press(CTRL_R); await settle(); @@ -254,7 +271,7 @@ describe("a chord never answers the diff", () => { test("Enter still applies the edit", async () => { const app = mount(); const decision = store.setPendingEdit(pendingEdit("enter")); - await settle(); + await waitForFrame(app, "notes.txt"); app.stdin.press(ENTER); @@ -266,7 +283,7 @@ describe("a chord never answers the diff", () => { test("Esc still rejects the edit", async () => { const app = mount(); const decision = store.setPendingEdit(pendingEdit("esc")); - await settle(); + await waitForFrame(app, "notes.txt"); app.stdin.press(ESC); @@ -310,7 +327,8 @@ describe("the diff owns the screen while it is being judged", () => { const app = mount(); const decision = store.setPendingEdit(longEdit()); - await settle(); + // The 1000-item case is the one that overran a fixed sleep on CI. + await waitForFrame(app, "added line 0"); const frame = app.stdout.text(); expect(frame).toContain("runtime/loop.ts");