Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
95 changes: 95 additions & 0 deletions prune-worktrees.ts
Original file line number Diff line number Diff line change
@@ -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<Worktree[]> {
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<string> {
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<boolean> {
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<boolean> {
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 ")}`);
}
Binary file modified site/public/terminal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 28 additions & 10 deletions tui/src/app.overlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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";

Expand All @@ -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);
Expand All @@ -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");
Expand All @@ -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?");
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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);

Expand All @@ -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);

Expand Down Expand Up @@ -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");
Expand Down