From 96177fa4314aed01aab33cb3300cb3098781089c Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 14:51:48 +0200 Subject: [PATCH 01/27] docs: add repo-level worktree housekeeping design spec --- ...06-10-repo-worktree-housekeeping-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-10-repo-worktree-housekeeping-design.md diff --git a/docs/superpowers/specs/2026-06-10-repo-worktree-housekeeping-design.md b/docs/superpowers/specs/2026-06-10-repo-worktree-housekeeping-design.md new file mode 100644 index 00000000000..e66d3f0a051 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-repo-worktree-housekeeping-design.md @@ -0,0 +1,123 @@ +# Repo-level worktree housekeeping — design + +## Problem + +T3 Code creates a git worktree per thread (in worktree mode) under a server-managed base directory. +Today the only cleanup is per-thread: deleting a thread removes its single orphaned worktree (`apps/web/src/hooks/useThreadActions.ts:225` via `vcs.removeWorktree`). +After finishing a set of tasks for a repo, stale worktrees accumulate and must be removed one by one. +There is no repo-scoped "clean up all worktrees" action, and no way to see how much disk space cleanup would reclaim. + +This implements [pingdotgg/t3code#684](https://github.com/pingdotgg/t3code/issues/684), which is closed as COMPLETED but has no linked PR and is not present in the codebase as of v0.0.26. + +## Goals + +- Provide a repo-scoped "Clean up worktrees" action that removes multiple t3code-managed worktrees in one flow. +- Show, upfront, the on-disk size of each worktree and the total reclaimable space for the current selection. +- Never silently destroy work: dirty worktrees require an explicit per-row opt-in, and active-thread worktrees are never auto-selected. +- Fix the related discoverability gap where archived-thread Delete is hidden behind a right-click context menu. + +## Non-goals + +- No background/scheduled automatic cleanup. +- No cross-repo "clean up everything" action; every invocation is scoped to a single repository. +- No change to how worktrees are created or to the per-thread delete-time cleanup. + +## Definitions + +A **managed worktree** is any entry from `git worktree list` whose path is under the server's `worktreesDir` (`apps/server/src/config.ts:95`, `/worktrees`) for the given repo, excluding the repo's main checkout. +All t3code worktrees are created under `worktreesDir//` (`apps/server/src/vcs/GitVcsDriverCore.ts:2057`), so the path-prefix test reliably distinguishes managed worktrees from worktrees the user created manually. + +A managed worktree is classified, client-side, relative to the threads it is referenced by: + +- **active** — referenced by at least one non-archived thread; never auto-selected, shown as protected. +- **archived-only** — referenced only by archived threads. +- **orphaned** — referenced by no thread. + +A worktree is **dirty** when it has uncommitted changes or commits not present on its upstream (unpushed work). + +## Scope setting + +A new global setting `worktreeCleanupScope` controls which worktrees are pre-selected when the dialog opens: + +- `"orphaned"` (default) — orphaned worktrees only. +- `"orphaned-archived"` — orphaned plus archived-only worktrees. + +Active-thread worktrees are never auto-selected under either value. +The dialog follows the setting and lets the user deselect individual rows; there is no per-run scope dropdown. + +## Architecture + +Responsibilities split along the existing client/server boundary. +The server enumerates managed worktrees on disk and computes sizes and dirty status (filesystem + git knowledge). +The client classifies worktrees against thread state, applies the scope setting, renders the dialog, and orchestrates removal (thread knowledge). + +### Server + +Three new VCS operations, each added to contracts (`packages/contracts/src/git.ts`, `rpc.ts`), the git driver (`GitVcsDriverCore.ts`, `GitVcsDriver.ts`), the workflow service (`GitWorkflowService.ts`), and WS wiring + auth (`apps/server/src/ws.ts`). +All three use the same auth scope as the existing single remove (`AuthOrchestrationOperateScope`). + +1. `vcsListManagedWorktrees({ cwd }) -> { worktrees: { path, refName, isDirty }[] }` + - Runs `git worktree list --porcelain`, filters to paths under `worktreesDir`, drops the main worktree. + - Computes `isDirty` per worktree (uncommitted changes or unpushed commits). + - Does not compute size, so the dialog can open immediately. + +2. `vcsWorktreeSize({ path }) -> { sizeBytes }` + - Recursive on-disk byte size for a single worktree path. + - Called lazily per row and cached on the client; this is the "exact size with caching" behavior. + +3. `vcsRemoveWorktrees({ cwd, items: { path, force }[] }) -> { results: { path, ok, error? }[] }` + - Batch remove with per-path `force`, reusing the existing single-remove git logic internally. + - Returns per-path results so partial failures surface individually. + - One source-control status refresh after the batch, instead of one per worktree. + +### Client + +- `wsRpcClient` (`packages/client-runtime/src/wsRpcClient.ts`) and `environmentApi` (`apps/web/src/environmentApi.ts`) gain `listManagedWorktrees`, `worktreeSize`, and `removeWorktrees`. +- A pure classification helper (extending `apps/web/src/worktreeCleanup.ts`) maps the server's managed-worktree list against the store's threads and archived snapshots into `active | archived-only | orphaned`, then applies `worktreeCleanupScope` to produce the default selection. +- `WorktreeCleanupDialog` renders the in-scope rows: + - branch / worktree name and path, + - size column (spinner while `worktreeSize` resolves, then formatted value, cached), + - dirty badge plus a per-row "force" checkbox (a dirty row cannot be selected for removal without force), + - per-row selection checkbox (active worktrees shown but locked off), + - a footer total of reclaimable size for the current selection. + - Confirm calls `removeWorktrees`, shows a summary toast (removed count, freed bytes, any per-path failures), and invalidates source-control state. + +### Entry points + +Both open the same `WorktreeCleanupDialog`, scoped to one repo: + +- Archived Threads panel (`apps/web/src/components/settings/SettingsPanels.tsx`, `ArchivedThreadsPanel`): a "Clean up worktrees" button in each per-project section header. +- Sidebar repo context menu (`apps/web/src/components/Sidebar.tsx`): a "Clean up worktrees…" item. + +### Discoverability fix + +Archived thread rows currently render only a visible Unarchive button; Delete exists but only via right-click (`SettingsPanels.tsx:1412`). +Add a visible Delete (trash) button to each archived row, invoking the existing `confirmAndDeleteThread` path. + +## Data flow + +1. User triggers "Clean up worktrees" for a repo from the sidebar or archived panel. +2. Client calls `listManagedWorktrees({ cwd })`; classifies each result against threads + archived snapshots; applies `worktreeCleanupScope` for the default selection. +3. Dialog opens immediately; for each visible row the client calls `worktreeSize({ path })` lazily and caches the result; the footer total updates as sizes resolve and as the user toggles rows. +4. User adjusts selection, toggles force on any dirty rows, confirms. +5. Client calls `removeWorktrees({ cwd, items })`; on response shows a summary toast and invalidates source-control state. + +## Error handling + +- `listManagedWorktrees` on a non-repo or empty result yields an empty list; the dialog shows a "nothing to clean up" empty state. +- `worktreeSize` failure for a row shows an unknown-size indicator for that row and excludes it from the footer total; it does not block removal. +- `removeWorktrees` returns per-path results; failures (for example a still-dirty worktree removed without force) are listed in the summary toast while successful removals still apply. +- Removing a worktree that is gone on disk but still registered is treated as success after a prune, consistent with the current force-remove behavior. + +## Testing (TDD) + +- Git driver, against a real temporary repo with managed and unmanaged worktrees: `listManagedWorktrees` filtering and `isDirty`, `worktreeSize`, and `removeWorktrees` batch + per-path force + partial failure. +- Contract schema round-trips for the three new input/result types. +- Client classification helper: active / archived-only / orphaned and scope-setting selection. +- `WorktreeCleanupDialog` logic: lazy size loading and caching, force gating for dirty rows, locked active rows, footer total. +- Settings: `worktreeCleanupScope` default is `"orphaned"`. +- Archived panel: visible Delete button invokes the delete path. + +## Open questions + +None outstanding; all design decisions are resolved. From b0c78ce61231127e70deb627ea6572d8e232f059 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 16:48:31 +0200 Subject: [PATCH 02/27] docs: add repo-level worktree housekeeping implementation plan --- .../2026-06-10-repo-worktree-housekeeping.md | 1842 +++++++++++++++++ 1 file changed, 1842 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-repo-worktree-housekeeping.md diff --git a/docs/superpowers/plans/2026-06-10-repo-worktree-housekeeping.md b/docs/superpowers/plans/2026-06-10-repo-worktree-housekeeping.md new file mode 100644 index 00000000000..64800d9e9ef --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-repo-worktree-housekeeping.md @@ -0,0 +1,1842 @@ +# Repo-level worktree housekeeping — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a repo-scoped "Clean up worktrees" action that lists t3code-managed worktrees with their on-disk size and dirty status, lets the user select and force-remove them, and shows the total reclaimable space upfront. + +**Architecture:** The server enumerates managed worktrees (`git worktree list --porcelain` filtered to paths under `worktreesDir`), computes per-worktree dirty status (git) and size (recursive filesystem walk), and batch-removes selected paths. The client classifies each worktree against live + archived threads, applies a global scope setting, renders a confirmation dialog with lazy/cached sizes, and invokes batch removal. Two entry points (sidebar repo context menu, archived-threads settings panel) open the same dialog. + +**Tech Stack:** Effect (server driver + RPC), Effect Schema (contracts), React + Base UI dialog primitives (web), `vite-plus/test` (test runner via `vp test run`). + +--- + +## Spec reference + +Design spec: `docs/superpowers/specs/2026-06-10-repo-worktree-housekeeping-design.md`. + +## Conventions used in this plan + +- **Test runner:** from inside a package directory, run `vp test run ` (apps/web also needs `--project unit`). Examples are given per task. +- **Typecheck:** from repo root, `bun run tc`. +- **Lint:** from repo root, `bun run lint`. +- **Test frameworks differ by package — match the file you are editing:** + - Contracts schema tests and web pure-logic tests use `vite-plus/test`: `import { describe, expect, it } from "vite-plus/test";` with `expect(...).toBe(...)`. + - The server driver test (`GitVcsDriverCore.test.ts`) uses `@effect/vitest`: `import { assert, it, describe } from "@effect/vitest";`, tests are written as `it.effect("...", () => Effect.gen(function* () { ... }))` inside the existing `it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ... })` block, and assertions use chai-style `assert.equal` / `assert.isAbove` / `assert.isString`. +- **Commit after every task.** Conventional-commit style (`feat:`, `test:`, `refactor:`). + +## File map + +**Contracts (`packages/contracts/src/`)** +- `settings.ts` — add `WorktreeCleanupScope` literal + `worktreeCleanupScope` server setting (default `"orphaned"`). +- `git.ts` — add `VcsManagedWorktree`, `VcsListManagedWorktreesInput/Result`, `VcsWorktreeSizeInput/Result`, `VcsRemoveWorktreesInput/Result`. +- `rpc.ts` — add 3 `WS_METHODS`, 3 `Rpc.make` defs, register them, import the new schemas. +- `ipc.ts` — add 3 methods to `EnvironmentApi.vcs`. + +**Server (`apps/server/src/`)** +- `vcs/GitVcsDriver.ts` — add 3 methods to the driver shape interface. +- `vcs/GitVcsDriverCore.ts` — implement `listManagedWorktrees`, `worktreeSize`, `removeWorktrees`; export them. +- `vcs/GitVcsDriverCore.test.ts` — driver tests against a real temp repo. +- `git/GitWorkflowService.ts` — add 3 members (interface + impl). +- `ws.ts` — add auth scopes + handlers. +- `server.test.ts` — extend the `gitWorkflow` mock with the 3 new methods. + +**Client runtime (`packages/client-runtime/src/`)** +- `wsRpcClient.ts` — add 3 typed methods + implementations. + +**Web (`apps/web/src/`)** +- `environmentApi.ts` — map 3 methods. +- `localApi.test.ts` — extend the vcs mock. +- `worktreeCleanup.ts` — add `classifyManagedWorktrees` + `selectWorktreesForScope` pure helpers. +- `worktreeCleanup.test.ts` — tests for the new helpers. +- `components/WorktreeCleanupDialog.logic.ts` — pure UI helpers (`formatBytes`, totals, force-gating, removal items). +- `components/WorktreeCleanupDialog.logic.test.ts` — tests. +- `components/WorktreeCleanupDialog.tsx` — the dialog component. +- `components/settings/SettingsPanels.tsx` — settings Select for scope; archived-panel cleanup button + visible Delete button. +- `components/Sidebar.tsx` — context-menu "Clean up worktrees" item + dialog mount. + +--- + +## Task 1: Add `worktreeCleanupScope` setting to contracts + +**Files:** +- Modify: `packages/contracts/src/settings.ts` +- Test: `packages/contracts/src/settings.test.ts` (create if absent) + +- [ ] **Step 1: Write the failing test** + +`packages/contracts/src/settings.test.ts` already exists and already imports `DEFAULT_SERVER_SETTINGS` from `./settings.ts` and `describe/expect/it` from `vite-plus/test`. Append this block (no new imports needed): + +```typescript +describe("worktreeCleanupScope", () => { + it("defaults to orphaned", () => { + expect(DEFAULT_SERVER_SETTINGS.worktreeCleanupScope).toBe("orphaned"); + }); +}); +``` + +(`DEFAULT_SERVER_SETTINGS` is `Schema.decodeSync(ServerSettings)({})`, so the `withDecodingDefault` you add in Step 3 populates this field automatically.) + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `packages/contracts`): `vp test run src/settings.test.ts` +Expected: FAIL — `worktreeCleanupScope` is `undefined` / not a key. + +- [ ] **Step 3: Add the literal type and the setting field** + +In `packages/contracts/src/settings.ts`, near the other `Schema.Literals` declarations (e.g. just below `ThreadEnvMode` around line 103), add: + +```typescript +export const WorktreeCleanupScope = Schema.Literals(["orphaned", "orphaned-archived"]); +export type WorktreeCleanupScope = typeof WorktreeCleanupScope.Type; +``` + +Then in the `ServerSettings` `Schema.Struct` (where `defaultThreadEnvMode` is defined, around line 373), add a sibling field: + +```typescript +worktreeCleanupScope: WorktreeCleanupScope.pipe( + Schema.withDecodingDefault(Effect.succeed("orphaned" as const satisfies WorktreeCleanupScope)), +), +``` + +(`Effect` and `Schema` are already imported in this file — confirm at the top before adding.) + +- [ ] **Step 4: Run test to verify it passes** + +Run (from `packages/contracts`): `vp test run src/settings.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/contracts/src/settings.ts packages/contracts/src/settings.test.ts +git commit -m "feat(contracts): add worktreeCleanupScope setting" +``` + +--- + +## Task 2: Add worktree-cleanup contract schemas + +**Files:** +- Modify: `packages/contracts/src/git.ts` +- Test: `packages/contracts/src/git.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `packages/contracts/src/git.test.ts` (match the file's existing import + decode style — it already imports `Schema` and the git schemas): + +```typescript +import { + VcsListManagedWorktreesResult, + VcsRemoveWorktreesInput, + VcsWorktreeSizeResult, +} from "./git.ts"; + +describe("managed worktree schemas", () => { + it("decodes a managed worktrees result", () => { + const decoded = Schema.decodeUnknownSync(VcsListManagedWorktreesResult)({ + worktrees: [{ path: "/wt/a", refName: "feature-a", isDirty: false }], + }); + expect(decoded.worktrees[0]?.isDirty).toBe(false); + }); + + it("decodes a worktree size result", () => { + const decoded = Schema.decodeUnknownSync(VcsWorktreeSizeResult)({ sizeBytes: 4096 }); + expect(decoded.sizeBytes).toBe(4096); + }); + + it("decodes a batch remove input with per-item force", () => { + const decoded = Schema.decodeUnknownSync(VcsRemoveWorktreesInput)({ + cwd: "/repo", + items: [{ path: "/wt/a", force: true }, { path: "/wt/b" }], + }); + expect(decoded.items.length).toBe(2); + expect(decoded.items[0]?.force).toBe(true); + }); +}); +``` + +If `describe/it/expect` and `Schema` are not yet imported at the top of `git.test.ts`, add `import { describe, expect, it } from "vite-plus/test";` and `import * as Schema from "effect/Schema";` (check the existing header first to avoid duplicates). + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `packages/contracts`): `vp test run src/git.test.ts` +Expected: FAIL — these schemas are not exported yet. + +- [ ] **Step 3: Add the schemas** + +In `packages/contracts/src/git.ts`, just below the existing `VcsRemoveWorktreeInput` (around line 161), add: + +```typescript +export const VcsManagedWorktree = Schema.Struct({ + path: TrimmedNonEmptyStringSchema, + refName: TrimmedNonEmptyStringSchema, + isDirty: Schema.Boolean, +}); +export type VcsManagedWorktree = typeof VcsManagedWorktree.Type; + +export const VcsListManagedWorktreesInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type VcsListManagedWorktreesInput = typeof VcsListManagedWorktreesInput.Type; + +export const VcsListManagedWorktreesResult = Schema.Struct({ + worktrees: Schema.Array(VcsManagedWorktree), +}); +export type VcsListManagedWorktreesResult = typeof VcsListManagedWorktreesResult.Type; + +export const VcsWorktreeSizeInput = Schema.Struct({ + path: TrimmedNonEmptyStringSchema, +}); +export type VcsWorktreeSizeInput = typeof VcsWorktreeSizeInput.Type; + +export const VcsWorktreeSizeResult = Schema.Struct({ + sizeBytes: NonNegativeInt, +}); +export type VcsWorktreeSizeResult = typeof VcsWorktreeSizeResult.Type; + +const VcsRemoveWorktreeItem = Schema.Struct({ + path: TrimmedNonEmptyStringSchema, + force: Schema.optional(Schema.Boolean), +}); + +export const VcsRemoveWorktreesInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + items: Schema.Array(VcsRemoveWorktreeItem), +}); +export type VcsRemoveWorktreesInput = typeof VcsRemoveWorktreesInput.Type; + +const VcsRemoveWorktreeOutcome = Schema.Struct({ + path: TrimmedNonEmptyStringSchema, + ok: Schema.Boolean, + error: Schema.optional(TrimmedNonEmptyStringSchema), +}); + +export const VcsRemoveWorktreesResult = Schema.Struct({ + results: Schema.Array(VcsRemoveWorktreeOutcome), +}); +export type VcsRemoveWorktreesResult = typeof VcsRemoveWorktreesResult.Type; +``` + +(`NonNegativeInt` is already imported at `git.ts:2`. `TrimmedNonEmptyStringSchema` is the local alias defined at `git.ts:6`.) + +- [ ] **Step 4: Run test to verify it passes** + +Run (from `packages/contracts`): `vp test run src/git.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/contracts/src/git.ts packages/contracts/src/git.test.ts +git commit -m "feat(contracts): add managed-worktree cleanup schemas" +``` + +--- + +## Task 3: Wire the new RPC methods + +**Files:** +- Modify: `packages/contracts/src/rpc.ts` + +- [ ] **Step 1: Add WS method names** + +In `rpc.ts`, in the `WS_METHODS` VCS section (around lines 133-141), add three entries next to `vcsRemoveWorktree`: + +```typescript + vcsListManagedWorktrees: "vcs.listManagedWorktrees", + vcsWorktreeSize: "vcs.worktreeSize", + vcsRemoveWorktrees: "vcs.removeWorktrees", +``` + +- [ ] **Step 2: Import the new schemas** + +In the import block from `./git.ts` (around lines 16-40), add: + +```typescript + VcsListManagedWorktreesInput, + VcsListManagedWorktreesResult, + VcsWorktreeSizeInput, + VcsWorktreeSizeResult, + VcsRemoveWorktreesInput, + VcsRemoveWorktreesResult, +``` + +- [ ] **Step 3: Define the Rpc objects** + +Next to `WsVcsRemoveWorktreeRpc` (around line 385), add: + +```typescript +export const WsVcsListManagedWorktreesRpc = Rpc.make(WS_METHODS.vcsListManagedWorktrees, { + payload: VcsListManagedWorktreesInput, + success: VcsListManagedWorktreesResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + +export const WsVcsWorktreeSizeRpc = Rpc.make(WS_METHODS.vcsWorktreeSize, { + payload: VcsWorktreeSizeInput, + success: VcsWorktreeSizeResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + +export const WsVcsRemoveWorktreesRpc = Rpc.make(WS_METHODS.vcsRemoveWorktrees, { + payload: VcsRemoveWorktreesInput, + success: VcsRemoveWorktreesResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); +``` + +- [ ] **Step 4: Register the Rpcs in the group** + +In the RpcGroup list (around lines 570-581), add next to `WsVcsRemoveWorktreeRpc,`: + +```typescript + WsVcsListManagedWorktreesRpc, + WsVcsWorktreeSizeRpc, + WsVcsRemoveWorktreesRpc, +``` + +- [ ] **Step 5: Typecheck** + +Run (from repo root): `bun run tc` +Expected: PASS for `@t3tools/contracts` (other packages may still error until later tasks — that is acceptable for this task's scope, but contracts itself must compile). + +- [ ] **Step 6: Commit** + +```bash +git add packages/contracts/src/rpc.ts +git commit -m "feat(contracts): register managed-worktree cleanup RPCs" +``` + +--- + +## Task 4: Add driver-shape interface methods + +**Files:** +- Modify: `apps/server/src/vcs/GitVcsDriver.ts` + +- [ ] **Step 1: Import the new types** + +In the `@t3tools/contracts` import block of `GitVcsDriver.ts` (around lines 19-27), add: + +```typescript + type VcsListManagedWorktreesInput, + type VcsListManagedWorktreesResult, + type VcsWorktreeSizeInput, + type VcsWorktreeSizeResult, + type VcsRemoveWorktreesInput, + type VcsRemoveWorktreesResult, +``` + +- [ ] **Step 2: Add the shape members** + +Next to `readonly removeWorktree: ...` (around line 210), add: + +```typescript + readonly listManagedWorktrees: ( + input: VcsListManagedWorktreesInput, + ) => Effect.Effect; + readonly worktreeSize: ( + input: VcsWorktreeSizeInput, + ) => Effect.Effect; + readonly removeWorktrees: ( + input: VcsRemoveWorktreesInput, + ) => Effect.Effect; +``` + +- [ ] **Step 3: Typecheck (expected to fail at the core)** + +Run (from repo root): `bun run tc` +Expected: FAIL in `GitVcsDriverCore.ts` — the frozen driver object does not yet implement these. This is expected; Task 5-7 implement them. Do not commit a broken typecheck on its own; proceed directly to Task 5 and commit the interface + first implementation together if you prefer. (If using subagent-driven execution, note this cross-task dependency to the reviewer.) + +--- + +## Task 5: Implement `listManagedWorktrees` in the driver + +**Files:** +- Modify: `apps/server/src/vcs/GitVcsDriverCore.ts` +- Test: `apps/server/src/vcs/GitVcsDriverCore.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add a new `describe` block **inside** the existing `it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ... })` callback in `GitVcsDriverCore.test.ts` (the same place the existing `describe("commit context", ...)` lives). It uses the file's existing helpers `makeTmpDir`, `initRepoWithCommit`, and the `GitVcsDriver.GitVcsDriver` tag. Note: pass `path: null` to `createWorktree` so the worktree is created under the test config's `worktreesDir` and therefore counts as *managed*. + +```typescript +describe("managed worktrees", () => { + it.effect("lists managed worktrees under the worktrees dir with dirty status", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + refName: initialBranch, + newRefName: "feature-a", + path: null, + }); + + const result = yield* driver.listManagedWorktrees({ cwd }); + + assert.equal(result.worktrees.length, 1); + assert.equal(result.worktrees[0]?.refName, "feature-a"); + // Fresh worktree branch has no remote => treated as dirty (unpushed). + assert.equal(result.worktrees[0]?.isDirty, true); + }), + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `apps/server`): `vp test run src/vcs/GitVcsDriverCore.test.ts` +Expected: FAIL — `driver.listManagedWorktrees` is not a function. + +- [ ] **Step 3: Implement the helper + method** + +In `GitVcsDriverCore.ts`, inside `makeGitVcsDriverCore` (after `removeWorktree` around line 2156, before the frozen return), add a dirty-check helper and the method. `Option` is already imported (used at line 728); `path`, `fileSystem`, `worktreesDir`, `executeGit` are all in scope. + +```typescript +const readWorktreeDirty = (worktreePath: string): Effect.Effect => + Effect.gen(function* () { + const statusResult = yield* executeGit( + "GitVcsDriver.listManagedWorktrees.status", + worktreePath, + ["status", "--porcelain"], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ).pipe(Effect.orElseSucceed(() => null)); + if (statusResult && statusResult.stdout.trim().length > 0) { + return true; + } + const remoteContains = yield* executeGit( + "GitVcsDriver.listManagedWorktrees.remoteContains", + worktreePath, + ["branch", "--remotes", "--contains", "HEAD"], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ).pipe(Effect.orElseSucceed(() => null)); + const hasRemoteContainingHead = + remoteContains !== null && + remoteContains.exitCode === 0 && + remoteContains.stdout.trim().length > 0; + // No remote branch contains HEAD => there is unpushed work. + return !hasRemoteContainingHead; + }); + +const isUnderWorktreesDir = (candidate: string): boolean => { + const normalized = path.resolve(candidate); + const base = path.resolve(worktreesDir); + return normalized === base || normalized.startsWith(base + path.sep); +}; + +const listManagedWorktrees: GitVcsDriver.GitVcsDriverShape["listManagedWorktrees"] = Effect.fn( + "listManagedWorktrees", +)(function* (input) { + const result = yield* executeGit( + "GitVcsDriver.listManagedWorktrees", + input.cwd, + ["worktree", "list", "--porcelain"], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + if (result.exitCode !== 0) { + return { worktrees: [] }; + } + + const candidates: { path: string; refName: string }[] = []; + let currentPath: string | null = null; + let currentBranch: string | null = null; + const flush = () => { + if (currentPath && isUnderWorktreesDir(currentPath)) { + candidates.push({ + path: currentPath, + refName: currentBranch ?? path.basename(currentPath), + }); + } + currentPath = null; + currentBranch = null; + }; + for (const line of result.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + flush(); + currentPath = line.slice("worktree ".length).trim(); + } else if (line.startsWith("branch refs/heads/")) { + currentBranch = line.slice("branch refs/heads/".length).trim(); + } else if (line.trim() === "") { + flush(); + } + } + flush(); + + // Keep only worktrees that still exist on disk. + const existing = yield* Effect.forEach( + candidates, + (candidate) => + fileSystem.stat(candidate.path).pipe( + Effect.as(Option.some(candidate)), + Effect.orElseSucceed(() => Option.none<{ path: string; refName: string }>()), + ), + { concurrency: 8 }, + ).pipe(Effect.map((options) => options.flatMap((o) => (Option.isSome(o) ? [o.value] : [])))); + + const worktrees = yield* Effect.forEach( + existing, + (candidate) => + readWorktreeDirty(candidate.path).pipe( + Effect.map((isDirty) => ({ path: candidate.path, refName: candidate.refName, isDirty })), + ), + { concurrency: 4 }, + ); + + return { worktrees }; +}); +``` + +- [ ] **Step 4: Add to the frozen export object** + +In the `return Object.freeze({ ... })` block (around lines 2308-2326), add `listManagedWorktrees,` next to `removeWorktree,`. + +- [ ] **Step 5: Run test to verify it passes** + +Run (from `apps/server`): `vp test run src/vcs/GitVcsDriverCore.test.ts` +Expected: PASS for the new test. + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/vcs/GitVcsDriver.ts apps/server/src/vcs/GitVcsDriverCore.ts apps/server/src/vcs/GitVcsDriverCore.test.ts +git commit -m "feat(server): list managed worktrees with dirty status" +``` + +--- + +## Task 6: Implement `worktreeSize` in the driver + +**Files:** +- Modify: `apps/server/src/vcs/GitVcsDriverCore.ts` +- Test: `apps/server/src/vcs/GitVcsDriverCore.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add inside the same `describe("managed worktrees", ...)` block: + +```typescript +it.effect("computes the on-disk byte size of a worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + refName: initialBranch, + newRefName: "feature-size", + path: null, + }); + + const { sizeBytes } = yield* driver.worktreeSize({ path: created.worktree.path }); + + // A real checkout always has tracked files on disk. + assert.isAbove(sizeBytes, 0); + }), +); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `apps/server`): `vp test run src/vcs/GitVcsDriverCore.test.ts` +Expected: FAIL — `driver.worktreeSize` is not a function. + +- [ ] **Step 3: Implement the recursive-walk size method** + +In `GitVcsDriverCore.ts`, near `listManagedWorktrees`, add: + +```typescript +const directorySizeBytes = (rootPath: string): Effect.Effect => { + const walk = (current: string): Effect.Effect => + fileSystem.readDirectory(current).pipe( + Effect.flatMap((entries) => + Effect.forEach( + entries, + (entry) => { + const childPath = path.join(current, entry); + return fileSystem.stat(childPath).pipe( + Effect.flatMap((info) => + info.type === "Directory" + ? walk(childPath) + : Effect.succeed(Number(info.size)), + ), + Effect.orElseSucceed(() => 0), + ); + }, + { concurrency: 8 }, + ), + ), + Effect.map((sizes) => sizes.reduce((total, size) => total + size, 0)), + Effect.orElseSucceed(() => 0), + ); + return walk(rootPath); +}; + +const worktreeSize: GitVcsDriver.GitVcsDriverShape["worktreeSize"] = Effect.fn("worktreeSize")( + function* (input) { + const sizeBytes = yield* directorySizeBytes(input.path); + return { sizeBytes }; + }, +); +``` + +> Note: this follows symbolic links (Effect `stat` resolves them). Worktree checkouts do not contain cyclic symlinks in practice, so this is acceptable for a size estimate. Document this as a known limitation if a reviewer asks. + +- [ ] **Step 4: Add to the frozen export object** + +Add `worktreeSize,` to the `Object.freeze({ ... })` block. + +- [ ] **Step 5: Run test to verify it passes** + +Run (from `apps/server`): `vp test run src/vcs/GitVcsDriverCore.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/vcs/GitVcsDriverCore.ts apps/server/src/vcs/GitVcsDriverCore.test.ts +git commit -m "feat(server): compute worktree on-disk size" +``` + +--- + +## Task 7: Implement `removeWorktrees` (batch) in the driver + +**Files:** +- Modify: `apps/server/src/vcs/GitVcsDriverCore.ts` +- Test: `apps/server/src/vcs/GitVcsDriverCore.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add inside the same `describe("managed worktrees", ...)` block: + +```typescript +it.effect("batch-removes worktrees and reports per-path outcomes", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const a = yield* driver.createWorktree({ + cwd, + refName: initialBranch, + newRefName: "rm-a", + path: null, + }); + + const { results } = yield* driver.removeWorktrees({ + cwd, + items: [ + { path: a.worktree.path, force: true }, + { path: "/does/not/exist", force: true }, + ], + }); + + assert.equal(results.length, 2); + assert.equal(results.find((r) => r.path === a.worktree.path)?.ok, true); + const missing = results.find((r) => r.path === "/does/not/exist"); + assert.equal(missing?.ok, false); + assert.isString(missing?.error); + }), +); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `apps/server`): `vp test run src/vcs/GitVcsDriverCore.test.ts` +Expected: FAIL — `driver.removeWorktrees` is not a function. + +- [ ] **Step 3: Implement the batch remove** + +In `GitVcsDriverCore.ts`, near the other new methods, add: + +```typescript +const removeWorktrees: GitVcsDriver.GitVcsDriverShape["removeWorktrees"] = Effect.fn( + "removeWorktrees", +)(function* (input) { + const results = yield* Effect.forEach( + input.items, + (item) => { + const args = ["worktree", "remove"]; + if (item.force) { + args.push("--force"); + } + args.push(item.path); + return executeGit("GitVcsDriver.removeWorktrees", input.cwd, args, { + timeoutMs: 15_000, + fallbackErrorMessage: "git worktree remove failed", + }).pipe( + Effect.as({ path: item.path, ok: true as const }), + Effect.catchAll((error) => + Effect.succeed({ path: item.path, ok: false as const, error: error.message }), + ), + ); + }, + { concurrency: 1 }, // serialize: concurrent worktree removals race on .git/worktrees metadata + ); + return { results }; +}); +``` + +- [ ] **Step 4: Add to the frozen export object** + +Add `removeWorktrees,` to the `Object.freeze({ ... })` block. + +- [ ] **Step 5: Run test + typecheck** + +Run (from `apps/server`): `vp test run src/vcs/GitVcsDriverCore.test.ts` +Expected: PASS +Run (from repo root): `bun run tc` +Expected: PASS for `@t3tools/server` driver layer (GitWorkflowService/ws will still need Task 8-9; those files may still error — acceptable until then, but the driver file itself must compile). + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/vcs/GitVcsDriverCore.ts apps/server/src/vcs/GitVcsDriverCore.test.ts +git commit -m "feat(server): batch-remove worktrees with per-path force" +``` + +--- + +## Task 8: Expose the three methods on `GitWorkflowService` + +**Files:** +- Modify: `apps/server/src/git/GitWorkflowService.ts` + +- [ ] **Step 1: Import the new types** + +In the `@t3tools/contracts` import block (around lines 12-21), add: + +```typescript + type VcsListManagedWorktreesInput, + type VcsListManagedWorktreesResult, + type VcsWorktreeSizeInput, + type VcsWorktreeSizeResult, + type VcsRemoveWorktreesInput, + type VcsRemoveWorktreesResult, +``` + +- [ ] **Step 2: Add interface members** + +Next to `readonly removeWorktree: ...` (around line 63), add: + +```typescript + readonly listManagedWorktrees: ( + input: VcsListManagedWorktreesInput, + ) => Effect.Effect; + readonly worktreeSize: ( + input: VcsWorktreeSizeInput, + ) => Effect.Effect; + readonly removeWorktrees: ( + input: VcsRemoveWorktreesInput, + ) => Effect.Effect; +``` + +- [ ] **Step 3: Add implementations** + +Next to the `removeWorktree:` implementation (around line 297), add. `listManagedWorktrees` and `removeWorktrees` run git in `input.cwd`, so they use `ensureGitCommand`; `worktreeSize` does no git work (filesystem only), so it calls the driver directly: + +```typescript +listManagedWorktrees: (input) => + ensureGitCommand("GitWorkflowService.listManagedWorktrees", input.cwd).pipe( + Effect.andThen(git.listManagedWorktrees(input)), + ), +worktreeSize: (input) => git.worktreeSize(input), +removeWorktrees: (input) => + ensureGitCommand("GitWorkflowService.removeWorktrees", input.cwd).pipe( + Effect.andThen(git.removeWorktrees(input)), + ), +``` + +- [ ] **Step 4: Typecheck** + +Run (from repo root): `bun run tc` +Expected: PASS for `@t3tools/server` except `ws.ts` (handlers added next). + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/git/GitWorkflowService.ts +git commit -m "feat(server): expose managed-worktree cleanup on GitWorkflowService" +``` + +--- + +## Task 9: Wire WS auth scopes + handlers + +**Files:** +- Modify: `apps/server/src/ws.ts` +- Modify: `apps/server/src/server.test.ts` + +- [ ] **Step 1: Add auth scopes** + +In `ws.ts`, in the scope-map list (around lines 166-172), add. List + size are read-only; batch remove mutates: + +```typescript +[WS_METHODS.vcsListManagedWorktrees, AuthOrchestrationReadScope], +[WS_METHODS.vcsWorktreeSize, AuthOrchestrationReadScope], +[WS_METHODS.vcsRemoveWorktrees, AuthOrchestrationOperateScope], +``` + +- [ ] **Step 2: Add handlers** + +In the handler map (next to `[WS_METHODS.vcsRemoveWorktree]:` around line 1266), add: + +```typescript +[WS_METHODS.vcsListManagedWorktrees]: (input) => + observeRpcEffect( + WS_METHODS.vcsListManagedWorktrees, + gitWorkflow.listManagedWorktrees(input), + { "rpc.aggregate": "vcs" }, + ), +[WS_METHODS.vcsWorktreeSize]: (input) => + observeRpcEffect(WS_METHODS.vcsWorktreeSize, gitWorkflow.worktreeSize(input), { + "rpc.aggregate": "vcs", + }), +[WS_METHODS.vcsRemoveWorktrees]: (input) => + observeRpcEffect( + WS_METHODS.vcsRemoveWorktrees, + gitWorkflow.removeWorktrees(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), +``` + +- [ ] **Step 3: Extend the server test mock** + +In `server.test.ts`, the `gitWorkflow` mock (around line 4863) declares `removeWorktree: () => Effect.void`. Add the three new methods so the mock satisfies the interface: + +```typescript +listManagedWorktrees: () => Effect.succeed({ worktrees: [] }), +worktreeSize: () => Effect.succeed({ sizeBytes: 0 }), +removeWorktrees: () => Effect.succeed({ results: [] }), +``` + +- [ ] **Step 4: Run server tests + typecheck** + +Run (from `apps/server`): `vp test run src/server.test.ts` +Expected: PASS +Run (from repo root): `bun run tc` +Expected: PASS for `@t3tools/server`. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/ws.ts apps/server/src/server.test.ts +git commit -m "feat(server): wire managed-worktree cleanup WS handlers" +``` + +--- + +## Task 10: Add methods to the `EnvironmentApi.vcs` IPC contract + +**Files:** +- Modify: `packages/contracts/src/ipc.ts` + +- [ ] **Step 1: Import the new types** + +In the `./git.ts` import block (around lines 1-20), add: + +```typescript + VcsListManagedWorktreesInput, + VcsListManagedWorktreesResult, + VcsWorktreeSizeInput, + VcsWorktreeSizeResult, + VcsRemoveWorktreesInput, + VcsRemoveWorktreesResult, +``` + +- [ ] **Step 2: Add interface methods** + +In the `vcs:` block (around lines 567-583), next to `removeWorktree`, add: + +```typescript + listManagedWorktrees: ( + input: VcsListManagedWorktreesInput, + ) => Promise; + worktreeSize: (input: VcsWorktreeSizeInput) => Promise; + removeWorktrees: (input: VcsRemoveWorktreesInput) => Promise; +``` + +- [ ] **Step 3: Typecheck** + +Run (from repo root): `bun run tc` +Expected: `@t3tools/contracts` compiles; consumers wired in next tasks. + +- [ ] **Step 4: Commit** + +```bash +git add packages/contracts/src/ipc.ts +git commit -m "feat(contracts): add managed-worktree cleanup to EnvironmentApi.vcs" +``` + +--- + +## Task 11: Implement the methods in `wsRpcClient` + +**Files:** +- Modify: `packages/client-runtime/src/wsRpcClient.ts` + +- [ ] **Step 1: Add type declarations** + +In the `vcs` type block (around lines 100-114), next to `removeWorktree`, add: + +```typescript + readonly listManagedWorktrees: RpcUnaryMethod; + readonly worktreeSize: RpcUnaryMethod; + readonly removeWorktrees: RpcUnaryMethod; +``` + +- [ ] **Step 2: Add implementations** + +In the `vcs` implementation block (around lines 236-259), next to `removeWorktree`, add: + +```typescript + listManagedWorktrees: (input) => + transport.request((client) => client[WS_METHODS.vcsListManagedWorktrees](input)), + worktreeSize: (input) => + transport.request((client) => client[WS_METHODS.vcsWorktreeSize](input)), + removeWorktrees: (input) => + transport.request((client) => client[WS_METHODS.vcsRemoveWorktrees](input)), +``` + +- [ ] **Step 3: Typecheck** + +Run (from repo root): `bun run tc` +Expected: PASS for `@t3tools/client-runtime`. + +- [ ] **Step 4: Commit** + +```bash +git add packages/client-runtime/src/wsRpcClient.ts +git commit -m "feat(client-runtime): add managed-worktree cleanup RPC methods" +``` + +--- + +## Task 12: Map methods in web `environmentApi` + fix mocks + +**Files:** +- Modify: `apps/web/src/environmentApi.ts` +- Modify: `apps/web/src/localApi.test.ts` + +- [ ] **Step 1: Add mappings** + +In `environmentApi.ts`, in the `vcs` object (around lines 33-43), next to `removeWorktree`, add: + +```typescript + listManagedWorktrees: rpcClient.vcs.listManagedWorktrees, + worktreeSize: rpcClient.vcs.worktreeSize, + removeWorktrees: rpcClient.vcs.removeWorktrees, +``` + +- [ ] **Step 2: Extend the vcs mock in `localApi.test.ts`** + +`localApi.test.ts` mocks `removeWorktree: vi.fn()` (around line 80). Add: + +```typescript + listManagedWorktrees: vi.fn(async () => ({ worktrees: [] })), + worktreeSize: vi.fn(async () => ({ sizeBytes: 0 })), + removeWorktrees: vi.fn(async () => ({ results: [] })), +``` + +> Also check `apps/web/src/environments/runtime/service.savedEnvironments.test.ts` and `service.threadSubscriptions.test.ts` — they each mock `removeWorktree`. If TypeScript flags them as missing the new methods, add the same three mock entries there. + +- [ ] **Step 3: Run web unit tests + typecheck** + +Run (from `apps/web`): `vp test run src/localApi.test.ts --project unit` +Expected: PASS +Run (from repo root): `bun run tc` +Expected: PASS for `@t3tools/web`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/environmentApi.ts apps/web/src/localApi.test.ts +git commit -m "feat(web): map managed-worktree cleanup environment API" +``` + +--- + +## Task 13: Add worktree classification helpers + +**Files:** +- Modify: `apps/web/src/worktreeCleanup.ts` +- Modify: `apps/web/src/worktreeCleanup.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `worktreeCleanup.test.ts`: + +```typescript +import type { VcsManagedWorktree } from "@t3tools/contracts"; +import { + classifyManagedWorktrees, + selectWorktreesForScope, + type WorktreeThreadRef, +} from "./worktreeCleanup"; + +function wt(path: string, isDirty = false): VcsManagedWorktree { + return { path, refName: path.split("/").pop() ?? path, isDirty }; +} + +describe("classifyManagedWorktrees", () => { + it("marks worktrees with a live thread as active", () => { + const refs: WorktreeThreadRef[] = [{ worktreePath: "/wt/a", isArchived: false }]; + const [classified] = classifyManagedWorktrees([wt("/wt/a")], refs); + expect(classified?.classification).toBe("active"); + }); + + it("marks worktrees referenced only by archived threads as archived-only", () => { + const refs: WorktreeThreadRef[] = [{ worktreePath: "/wt/a", isArchived: true }]; + const [classified] = classifyManagedWorktrees([wt("/wt/a")], refs); + expect(classified?.classification).toBe("archived-only"); + }); + + it("marks worktrees with no thread as orphaned", () => { + const [classified] = classifyManagedWorktrees([wt("/wt/a")], []); + expect(classified?.classification).toBe("orphaned"); + }); +}); + +describe("selectWorktreesForScope", () => { + const classified = classifyManagedWorktrees( + [wt("/wt/orphan"), wt("/wt/arch"), wt("/wt/active")], + [ + { worktreePath: "/wt/arch", isArchived: true }, + { worktreePath: "/wt/active", isArchived: false }, + ], + ); + + it("orphaned scope selects only orphaned worktrees", () => { + const selected = selectWorktreesForScope(classified, "orphaned"); + expect(selected.map((c) => c.worktree.path)).toEqual(["/wt/orphan"]); + }); + + it("orphaned-archived scope adds archived-only worktrees", () => { + const selected = selectWorktreesForScope(classified, "orphaned-archived"); + expect(selected.map((c) => c.worktree.path).sort()).toEqual(["/wt/arch", "/wt/orphan"]); + }); + + it("never selects active worktrees", () => { + const selected = selectWorktreesForScope(classified, "orphaned-archived"); + expect(selected.some((c) => c.worktree.path === "/wt/active")).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `apps/web`): `vp test run src/worktreeCleanup.test.ts --project unit` +Expected: FAIL — `classifyManagedWorktrees` / `selectWorktreesForScope` not exported. + +- [ ] **Step 3: Implement the helpers** + +Append to `worktreeCleanup.ts` (it already has `normalizeWorktreePath`): + +```typescript +import type { VcsManagedWorktree, WorktreeCleanupScope } from "@t3tools/contracts"; + +export type WorktreeClassification = "active" | "archived-only" | "orphaned"; + +export interface WorktreeThreadRef { + worktreePath: string | null; + isArchived: boolean; +} + +export interface ClassifiedWorktree { + worktree: VcsManagedWorktree; + classification: WorktreeClassification; +} + +export function classifyManagedWorktrees( + worktrees: readonly VcsManagedWorktree[], + threadRefs: readonly WorktreeThreadRef[], +): ClassifiedWorktree[] { + return worktrees.map((worktree) => { + const normalized = normalizeWorktreePath(worktree.path); + const linked = threadRefs.filter( + (ref) => normalizeWorktreePath(ref.worktreePath) === normalized, + ); + const classification: WorktreeClassification = linked.some((ref) => !ref.isArchived) + ? "active" + : linked.length > 0 + ? "archived-only" + : "orphaned"; + return { worktree, classification }; + }); +} + +export function selectWorktreesForScope( + classified: readonly ClassifiedWorktree[], + scope: WorktreeCleanupScope, +): ClassifiedWorktree[] { + return classified.filter( + (entry) => + entry.classification === "orphaned" || + (scope === "orphaned-archived" && entry.classification === "archived-only"), + ); +} +``` + +> If `WorktreeCleanupScope` / `VcsManagedWorktree` are not re-exported from the `@t3tools/contracts` barrel, add `export * from "./git.ts";` / the `settings.ts` export there (check `packages/contracts/src/index.ts`). Verify with the typecheck in the next step. + +- [ ] **Step 4: Run test + typecheck** + +Run (from `apps/web`): `vp test run src/worktreeCleanup.test.ts --project unit` +Expected: PASS +Run (from repo root): `bun run tc` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/worktreeCleanup.ts apps/web/src/worktreeCleanup.test.ts packages/contracts/src/index.ts +git commit -m "feat(web): classify managed worktrees by thread state and scope" +``` + +--- + +## Task 14: Add dialog logic helpers + +**Files:** +- Create: `apps/web/src/components/WorktreeCleanupDialog.logic.ts` +- Create: `apps/web/src/components/WorktreeCleanupDialog.logic.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `WorktreeCleanupDialog.logic.test.ts`: + +```typescript +import { describe, expect, it } from "vite-plus/test"; +import { + buildRemovalItems, + type CleanupRowState, + formatBytes, + isRowRemovable, + totalSelectedBytes, +} from "./WorktreeCleanupDialog.logic"; + +function row(overrides: Partial = {}): CleanupRowState { + return { + path: "/wt/a", + refName: "a", + classification: "orphaned", + isDirty: false, + selected: true, + force: false, + sizeBytes: 1024, + ...overrides, + }; +} + +describe("formatBytes", () => { + it("formats zero", () => expect(formatBytes(0)).toBe("0 B")); + it("formats kilobytes", () => expect(formatBytes(1024)).toBe("1.0 KB")); + it("formats megabytes", () => expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB")); +}); + +describe("totalSelectedBytes", () => { + it("sums only selected rows with known sizes", () => { + const rows = [row({ sizeBytes: 1024 }), row({ selected: false, sizeBytes: 2048 }), row({ sizeBytes: null })]; + expect(totalSelectedBytes(rows)).toBe(1024); + }); +}); + +describe("isRowRemovable", () => { + it("blocks active rows", () => expect(isRowRemovable(row({ classification: "active" }))).toBe(false)); + it("blocks dirty rows without force", () => + expect(isRowRemovable(row({ isDirty: true, force: false }))).toBe(false)); + it("allows dirty rows with force", () => + expect(isRowRemovable(row({ isDirty: true, force: true }))).toBe(true)); + it("blocks deselected rows", () => expect(isRowRemovable(row({ selected: false }))).toBe(false)); +}); + +describe("buildRemovalItems", () => { + it("forces dirty rows and includes only removable rows", () => { + const rows = [ + row({ path: "/wt/clean" }), + row({ path: "/wt/dirty", isDirty: true, force: true }), + row({ path: "/wt/active", classification: "active" }), + ]; + expect(buildRemovalItems(rows)).toEqual([ + { path: "/wt/clean", force: false }, + { path: "/wt/dirty", force: true }, + ]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `apps/web`): `vp test run src/components/WorktreeCleanupDialog.logic.test.ts --project unit` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement the logic module** + +Create `WorktreeCleanupDialog.logic.ts`: + +```typescript +import type { WorktreeClassification } from "../worktreeCleanup"; + +export interface CleanupRowState { + path: string; + refName: string; + classification: WorktreeClassification; + isDirty: boolean; + selected: boolean; + force: boolean; + sizeBytes: number | null; +} + +export function formatBytes(bytes: number): string { + if (bytes <= 0) { + return "0 B"; + } + const units = ["B", "KB", "MB", "GB", "TB"]; + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / Math.pow(1024, exponent); + return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`; +} + +export function totalSelectedBytes(rows: readonly CleanupRowState[]): number { + return rows.reduce( + (sum, row) => (row.selected && row.sizeBytes !== null ? sum + row.sizeBytes : sum), + 0, + ); +} + +export function isRowRemovable(row: CleanupRowState): boolean { + if (row.classification === "active") { + return false; + } + if (!row.selected) { + return false; + } + if (row.isDirty && !row.force) { + return false; + } + return true; +} + +export function buildRemovalItems( + rows: readonly CleanupRowState[], +): { path: string; force: boolean }[] { + return rows + .filter(isRowRemovable) + .map((row) => ({ path: row.path, force: row.isDirty || row.force })); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run (from `apps/web`): `vp test run src/components/WorktreeCleanupDialog.logic.test.ts --project unit` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/components/WorktreeCleanupDialog.logic.ts apps/web/src/components/WorktreeCleanupDialog.logic.test.ts +git commit -m "feat(web): add worktree cleanup dialog logic helpers" +``` + +--- + +## Task 15: Build the `WorktreeCleanupDialog` component + +**Files:** +- Create: `apps/web/src/components/WorktreeCleanupDialog.tsx` + +This task has no unit test (it is a presentational/effectful component verified manually in Task 19). Keep all decision logic in the Task 14 helpers. + +- [ ] **Step 1: Implement the component** + +Create `WorktreeCleanupDialog.tsx`. This mirrors the `PullRequestThreadDialog` dialog primitives and the `useThreadActions` toast/invalidate patterns. Adjust class names to match the project's styling conventions if the reviewer requests. + +```typescript +import type { EnvironmentId, VcsManagedWorktree } from "@t3tools/contracts"; +import { useCallback, useEffect, useState } from "react"; + +import { ensureEnvironmentApi } from "../environmentApi"; +import { invalidateSourceControlState } from "../lib/sourceControlActions"; +import { + classifyManagedWorktrees, + selectWorktreesForScope, + type WorktreeThreadRef, +} from "../worktreeCleanup"; +import { + buildRemovalItems, + type CleanupRowState, + formatBytes, + totalSelectedBytes, +} from "./WorktreeCleanupDialog.logic"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +interface WorktreeCleanupDialogProps { + open: boolean; + environmentId: EnvironmentId; + cwd: string; + scope: "orphaned" | "orphaned-archived"; + threadRefs: readonly WorktreeThreadRef[]; + onOpenChange: (open: boolean) => void; +} + +export function WorktreeCleanupDialog({ + open, + environmentId, + cwd, + scope, + threadRefs, + onOpenChange, +}: WorktreeCleanupDialogProps) { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [removing, setRemoving] = useState(false); + + useEffect(() => { + if (!open) { + setRows([]); + return; + } + let cancelled = false; + setLoading(true); + void (async () => { + try { + const api = ensureEnvironmentApi(environmentId); + const { worktrees } = await api.vcs.listManagedWorktrees({ cwd }); + const selected = selectWorktreesForScope( + classifyManagedWorktrees(worktrees, threadRefs), + scope, + ); + if (cancelled) return; + setRows( + selected.map((entry) => ({ + path: entry.worktree.path, + refName: entry.worktree.refName, + classification: entry.classification, + isDirty: entry.worktree.isDirty, + selected: !entry.worktree.isDirty, + force: false, + sizeBytes: null, + })), + ); + // Lazily load sizes; cache by updating each row as it resolves. + for (const entry of selected) { + void api.vcs + .worktreeSize({ path: entry.worktree.path }) + .then(({ sizeBytes }) => { + if (cancelled) return; + setRows((current) => + current.map((row) => + row.path === entry.worktree.path ? { ...row, sizeBytes } : row, + ), + ); + }) + .catch(() => { + /* leave sizeBytes null => shown as unknown, excluded from total */ + }); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [open, environmentId, cwd, scope, threadRefs]); + + const setRow = useCallback((path: string, patch: Partial) => { + setRows((current) => current.map((row) => (row.path === path ? { ...row, ...patch } : row))); + }, []); + + const handleConfirm = useCallback(async () => { + const items = buildRemovalItems(rows); + if (items.length === 0) { + onOpenChange(false); + return; + } + setRemoving(true); + try { + const api = ensureEnvironmentApi(environmentId); + const { results } = await api.vcs.removeWorktrees({ cwd, items }); + await invalidateSourceControlState({ environmentId }); + const removed = results.filter((r) => r.ok); + const failed = results.filter((r) => !r.ok); + const freed = removed.reduce((sum, r) => { + const row = rows.find((candidate) => candidate.path === r.path); + return sum + (row?.sizeBytes ?? 0); + }, 0); + toastManager.add( + stackedThreadToast({ + type: failed.length > 0 ? "warning" : "success", + title: + failed.length > 0 + ? `Removed ${removed.length}, ${failed.length} failed` + : `Removed ${removed.length} worktree${removed.length === 1 ? "" : "s"}`, + description: `Freed ${formatBytes(freed)}.${ + failed.length > 0 ? ` Failed: ${failed.map((f) => f.path).join(", ")}` : "" + }`, + }), + ); + onOpenChange(false); + } finally { + setRemoving(false); + } + }, [rows, environmentId, cwd, onOpenChange]); + + const total = totalSelectedBytes(rows); + + return ( + + + + + Clean up worktrees + + Remove t3code-managed worktrees for this repository. Dirty worktrees require an + explicit force toggle. + + + + {loading ? ( +

Scanning worktrees…

+ ) : rows.length === 0 ? ( +

Nothing to clean up.

+ ) : ( +
    + {rows.map((row) => ( +
  • + setRow(row.path, { selected: event.target.checked })} + aria-label={`Select ${row.refName}`} + /> +
    + {row.refName} + {row.path} +
    + {row.isDirty ? ( + + ) : null} + + {row.sizeBytes === null ? "…" : formatBytes(row.sizeBytes)} + +
  • + ))} +
+ )} + + + + Reclaimable: {formatBytes(total)} + + + + +
+
+
+ ); +} +``` + +> Implementer notes: (1) confirm the exact dialog sub-component names exported by `./ui/dialog` (this snippet uses `Dialog`, `DialogPopup`, `DialogPanel`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogFooter` per `PullRequestThreadDialog.tsx`). (2) the rows use native checkboxes for density; swap them for the project's `Switch`/`Checkbox` primitive if the reviewer prefers house style. (3) `ensureEnvironmentApi` is imported in `useThreadActions.ts`; confirm its exact export path from `../environmentApi`. + +- [ ] **Step 2: Typecheck** + +Run (from repo root): `bun run tc` +Expected: PASS for `@t3tools/web`. Fix any import-name mismatches surfaced here. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/WorktreeCleanupDialog.tsx +git commit -m "feat(web): add worktree cleanup dialog component" +``` + +--- + +## Task 16: Render the `worktreeCleanupScope` setting + +**Files:** +- Modify: `apps/web/src/components/settings/SettingsPanels.tsx` + +- [ ] **Step 1: Add a Select row** + +In the same settings panel that renders `defaultThreadEnvMode` (around lines 700-740), add a sibling `SettingsRow`, mirroring that pattern exactly: + +```typescript + + updateSettings({ + worktreeCleanupScope: DEFAULT_UNIFIED_SETTINGS.worktreeCleanupScope, + }) + } + /> + ) : null + } + control={ + + } +/> +``` + +- [ ] **Step 2: Typecheck** + +Run (from repo root): `bun run tc` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/settings/SettingsPanels.tsx +git commit -m "feat(web): add worktree cleanup scope setting UI" +``` + +--- + +## Task 17: Archived panel — cleanup button + visible Delete button + +**Files:** +- Modify: `apps/web/src/components/settings/SettingsPanels.tsx` + +- [ ] **Step 1: Add a visible Delete button on archived rows** + +In `ArchivedThreadsPanel`, the archived `SettingsRow` currently has `control={}` (around lines 1495-1519). Replace the single control with a two-button group so Delete is discoverable. `confirmAndDeleteThread` is already destructured from `useThreadActions()` (line 1343) and already used by the context-menu handler: + +```typescript +control={ +
+ + +
+} +``` + +> Use a distinct icon for Delete if available (e.g. a `Trash` icon from `lucide-react`); `ArchiveX` is reused here only to avoid a new import. Prefer importing `Trash2` and using it for the Delete button. + +- [ ] **Step 2: Add a per-project "Clean up worktrees" row + dialog state** + +At the top of `ArchivedThreadsPanel`, add dialog state and access to settings + thread refs. `useSettings` is the hook this file already imports (`import { useSettings, useUpdateSettings } from "../../hooks/useSettings";`, line 34). `selectThreadsAcrossEnvironments` (store.ts:1762) returns the live `Thread[]` with `worktreePath` + `archivedAt`. Add: + +```typescript +const settings = useSettings(); +const liveThreads = useStore(selectThreadsAcrossEnvironments); +const [cleanupTarget, setCleanupTarget] = useState<{ + environmentId: EnvironmentId; + cwd: string; +} | null>(null); +``` + +Build the `threadRefs` for the dialog by combining live threads with the archived snapshots already loaded in this panel (`archivedSnapshots`, whose `snapshot.threads` are `OrchestrationThreadShell` and carry `worktreePath`): + +```typescript +const cleanupThreadRefs: WorktreeThreadRef[] = useMemo(() => { + const live = liveThreads.map((thread) => ({ + worktreePath: thread.worktreePath, + isArchived: thread.archivedAt !== null, + })); + const archived = archivedSnapshots.flatMap(({ snapshot }) => + snapshot.threads.map((thread) => ({ worktreePath: thread.worktreePath, isArchived: true })), + ); + return [...live, ...archived]; +}, [liveThreads, archivedSnapshots]); +``` + +In each project's `SettingsSection`, add a first row with the cleanup button (using the known `SettingsRow` pattern rather than modifying `SettingsSection`'s header): + +```typescript + + setCleanupTarget({ environmentId: project.environmentId, cwd: project.cwd }) + } + > + Clean up + + } +/> +``` + +At the end of the panel's returned JSX (inside `SettingsPageContainer`), mount the dialog once: + +```typescript +{cleanupTarget ? ( + { + if (!next) { + setCleanupTarget(null); + refreshArchivedThreads(); + } + }} + /> +) : null} +``` + +Add the imports at the top of the file: `WorktreeCleanupDialog` from `../WorktreeCleanupDialog`, `type WorktreeThreadRef` from `../../worktreeCleanup`, `selectThreadsAcrossEnvironments` from `../../store` (the file already imports `useStore` — confirm and add the selector to that import), `useMemo`/`useState` from `react` (if not already imported), `Trash2` from `lucide-react`, and `EnvironmentId` type from `@t3tools/contracts`. + +- [ ] **Step 3: Typecheck** + +Run (from repo root): `bun run tc` +Expected: PASS. Resolve any selector/shape mismatches surfaced (especially `liveThreads` selector and `archivedSnapshots` thread path). + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/settings/SettingsPanels.tsx +git commit -m "feat(web): add worktree cleanup + visible delete to archived panel" +``` + +--- + +## Task 18: Sidebar repo context-menu entry + +**Files:** +- Modify: `apps/web/src/components/Sidebar.tsx` + +- [ ] **Step 1: Add dialog state to the Sidebar host** + +In the `Sidebar` component body, add state and a settings read (`useSettings` from `../hooks/useSettings`): + +```typescript +const sidebarSettings = useSettings(); +const [worktreeCleanupTarget, setWorktreeCleanupTarget] = useState<{ + environmentId: EnvironmentId; + cwd: string; +} | null>(null); +``` + +> The sidebar does not load archived snapshots, so it passes only live-thread refs (`isArchived` from `thread.archivedAt !== null`). Under the default `orphaned` scope this is fully correct; only the `orphaned-archived` scope differs (archived-only worktrees count as orphaned from the sidebar entry). Lifting archived-snapshot loading into the sidebar is a follow-up, not part of this task. + +- [ ] **Step 2: Add the context-menu item** + +In `handleProjectButtonContextMenu` (the verbatim handler around lines 1427-1531), add a leaf to the menu array passed to `api.contextMenu.show`, and register its handler. Insert before the destructive "Remove project" item: + +```typescript +{ + id: `cleanup-worktrees:${project.memberProjects[0]?.physicalProjectKey ?? "project"}`, + label: "Clean up worktrees…", +}, +``` + +And register the handler alongside the existing `actionHandlers.set(...)` calls (use the single-member project's `cwd`/`environmentId`; if grouped, use the first member): + +```typescript +actionHandlers.set( + `cleanup-worktrees:${project.memberProjects[0]?.physicalProjectKey ?? "project"}`, + () => { + const member = project.memberProjects[0]; + if (member) { + setWorktreeCleanupTarget({ environmentId: member.environmentId, cwd: member.cwd }); + } + }, +); +``` + +> Match the exact id construction style used by `makeLeaf` (it keys ids by `physicalProjectKey`). Keep the id stable between the menu item and the `actionHandlers.set` registration. + +- [ ] **Step 3: Mount the dialog** + +In the Sidebar's returned JSX (near other dialogs/overlays the Sidebar renders), add: + +```typescript +{worktreeCleanupTarget ? ( + { + if (!next) setWorktreeCleanupTarget(null); + }} + /> +) : null} +``` + +Where `sidebarThreadRefs` is built from the live thread list (`selectThreadsAcrossEnvironments`, store.ts:1762): + +```typescript +const sidebarLiveThreads = useStore(selectThreadsAcrossEnvironments); +const sidebarThreadRefs: WorktreeThreadRef[] = useMemo( + () => + sidebarLiveThreads.map((thread) => ({ + worktreePath: thread.worktreePath, + isArchived: thread.archivedAt !== null, + })), + [sidebarLiveThreads], +); +``` + +Add imports: `WorktreeCleanupDialog` from `./WorktreeCleanupDialog`, `type WorktreeThreadRef` from `../worktreeCleanup`, `selectThreadsAcrossEnvironments` from `../store` (Sidebar already uses `useStore` — add the selector to that import), `useSettings` from `../hooks/useSettings`, `EnvironmentId` from `@t3tools/contracts`, and `useMemo`/`useState` from `react` if not present. + +- [ ] **Step 4: Typecheck + lint** + +Run (from repo root): `bun run tc` +Expected: PASS. +Run (from repo root): `bun run lint` +Expected: PASS (fix any unused-import / exhaustive-deps issues). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/components/Sidebar.tsx +git commit -m "feat(web): add clean up worktrees to sidebar repo menu" +``` + +--- + +## Task 19: Full verification + manual check + +**Files:** none (verification only). + +- [ ] **Step 1: Run the full test suite** + +Run (from repo root): `bun run test` +Expected: PASS. + +- [ ] **Step 2: Typecheck + lint the whole repo** + +Run (from repo root): `bun run tc && bun run lint` +Expected: PASS. + +- [ ] **Step 3: Manual smoke test** + +Start the app (`bun run dev`) and, against a repo that has at least two managed worktrees (create them via worktree-mode threads, then archive/delete the threads): +- Open Settings → Archived Threads. Confirm each project shows a "Clean up" row and each archived thread row shows a visible Delete button. +- Click "Clean up". Confirm the dialog opens immediately, lists orphaned worktrees (per the default `orphaned` scope), shows sizes filling in, and a "Reclaimable" total. +- Create a dirty worktree (uncommitted change). Confirm it appears with a force checkbox and is deselected by default; confirm it cannot be removed without enabling force. +- Right-click a project in the sidebar → "Clean up worktrees…". Confirm the same dialog opens. +- Confirm removal: verify the worktrees are gone on disk (`git worktree list`), the toast reports freed space, and source-control state refreshes. +- Change Settings → "Worktree cleanup scope" to "Orphaned + archived" and confirm archived-only worktrees now appear pre-selected. + +> If you cannot run the desktop/web app in this environment, state that explicitly and rely on the automated tests plus typecheck. + +- [ ] **Step 4: Final commit (if any verification fixes were needed)** + +```bash +git add -A +git commit -m "fix(web): address worktree cleanup verification findings" +``` + +--- + +## Notes / known limitations + +- **Dirty heuristic:** a worktree whose HEAD is not contained in any remote branch is treated as dirty (unpushed). For local-only branches with no remote at all, this means everything reads as dirty (force required) — a deliberately conservative default that matches the "never silently destroy unpushed work" goal. +- **Size walk:** follows symlinks and sums regular-file sizes; acceptable for an estimate. Stale (gone-on-disk) worktree registrations are filtered out of the list rather than pruned; pruning stale registrations is out of scope for this plan. +- **Sidebar archived parity:** the sidebar entry point passes only live-thread refs unless archived snapshots are also loaded there; under the default `orphaned` scope this is fully correct, and only the `orphaned-archived` scope differs (archived-only worktrees count as orphaned from the sidebar). Lift archived-snapshot loading into the sidebar as a follow-up if exact parity is desired. From bbfec434da21b80c9a01ef41cee59813473935f5 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:09:26 +0200 Subject: [PATCH 03/27] feat(contracts): add worktreeCleanupScope setting --- packages/contracts/src/settings.test.ts | 6 ++++++ packages/contracts/src/settings.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 04ee479bcd3..c0dc5ec26c8 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -151,3 +151,9 @@ describe("ServerSettingsPatch string normalization", () => { expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); }); }); + +describe("worktreeCleanupScope", () => { + it("defaults to orphaned", () => { + expect(DEFAULT_SERVER_SETTINGS.worktreeCleanupScope).toBe("orphaned"); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 33781f56c94..4072b2c95db 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -102,6 +102,9 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); export type ThreadEnvMode = typeof ThreadEnvMode.Type; +export const WorktreeCleanupScope = Schema.Literals(["orphaned", "orphaned-archived"]); +export type WorktreeCleanupScope = typeof WorktreeCleanupScope.Type; + const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( Schema.decodeTo( @@ -373,6 +376,9 @@ export const ServerSettings = Schema.Struct({ defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), + worktreeCleanupScope: WorktreeCleanupScope.pipe( + Schema.withDecodingDefault(Effect.succeed("orphaned" as const satisfies WorktreeCleanupScope)), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( From 3ebc67ec7c8217adf0153952ca35c2094df94b95 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:12:28 +0200 Subject: [PATCH 04/27] feat(contracts): add managed-worktree cleanup schemas --- packages/contracts/src/git.test.ts | 26 ++++++++++++++++ packages/contracts/src/git.ts | 49 ++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 0a5497367cd..b9ba854733e 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -7,6 +7,9 @@ import { GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, + VcsListManagedWorktreesResult, + VcsRemoveWorktreesInput, + VcsWorktreeSizeResult, } from "./git.ts"; const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput); @@ -114,3 +117,26 @@ describe("GitRunStackedActionResult", () => { } }); }); + +describe("managed worktree schemas", () => { + it("decodes a managed worktrees result", () => { + const decoded = Schema.decodeUnknownSync(VcsListManagedWorktreesResult)({ + worktrees: [{ path: "/wt/a", refName: "feature-a", isDirty: false }], + }); + expect(decoded.worktrees[0]?.isDirty).toBe(false); + }); + + it("decodes a worktree size result", () => { + const decoded = Schema.decodeUnknownSync(VcsWorktreeSizeResult)({ sizeBytes: 4096 }); + expect(decoded.sizeBytes).toBe(4096); + }); + + it("decodes a batch remove input with per-item force", () => { + const decoded = Schema.decodeUnknownSync(VcsRemoveWorktreesInput)({ + cwd: "/repo", + items: [{ path: "/wt/a", force: true }, { path: "/wt/b" }], + }); + expect(decoded.items.length).toBe(2); + expect(decoded.items[0]?.force).toBe(true); + }); +}); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e8e9a4ecc1a..b104c0d3ae7 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -160,6 +160,55 @@ export const VcsRemoveWorktreeInput = Schema.Struct({ }); export type VcsRemoveWorktreeInput = typeof VcsRemoveWorktreeInput.Type; +export const VcsManagedWorktree = Schema.Struct({ + path: TrimmedNonEmptyStringSchema, + refName: TrimmedNonEmptyStringSchema, + isDirty: Schema.Boolean, +}); +export type VcsManagedWorktree = typeof VcsManagedWorktree.Type; + +export const VcsListManagedWorktreesInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type VcsListManagedWorktreesInput = typeof VcsListManagedWorktreesInput.Type; + +export const VcsListManagedWorktreesResult = Schema.Struct({ + worktrees: Schema.Array(VcsManagedWorktree), +}); +export type VcsListManagedWorktreesResult = typeof VcsListManagedWorktreesResult.Type; + +export const VcsWorktreeSizeInput = Schema.Struct({ + path: TrimmedNonEmptyStringSchema, +}); +export type VcsWorktreeSizeInput = typeof VcsWorktreeSizeInput.Type; + +export const VcsWorktreeSizeResult = Schema.Struct({ + sizeBytes: NonNegativeInt, +}); +export type VcsWorktreeSizeResult = typeof VcsWorktreeSizeResult.Type; + +const VcsRemoveWorktreeItem = Schema.Struct({ + path: TrimmedNonEmptyStringSchema, + force: Schema.optional(Schema.Boolean), +}); + +export const VcsRemoveWorktreesInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + items: Schema.Array(VcsRemoveWorktreeItem), +}); +export type VcsRemoveWorktreesInput = typeof VcsRemoveWorktreesInput.Type; + +const VcsRemoveWorktreeOutcome = Schema.Struct({ + path: TrimmedNonEmptyStringSchema, + ok: Schema.Boolean, + error: Schema.optional(TrimmedNonEmptyStringSchema), +}); + +export const VcsRemoveWorktreesResult = Schema.Struct({ + results: Schema.Array(VcsRemoveWorktreeOutcome), +}); +export type VcsRemoveWorktreesResult = typeof VcsRemoveWorktreesResult.Type; + export const VcsCreateRefInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, refName: TrimmedNonEmptyStringSchema, From b8897f97f9f1c1c34d570fbe2a16a3f2e10e6a33 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:15:00 +0200 Subject: [PATCH 05/27] feat(contracts): register managed-worktree cleanup RPCs --- packages/contracts/src/rpc.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5a145f3f657..39f9b7bdead 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -32,6 +32,12 @@ import { GitPullRequestRefInput, VcsPullResult, VcsRemoveWorktreeInput, + VcsListManagedWorktreesInput, + VcsListManagedWorktreesResult, + VcsWorktreeSizeInput, + VcsWorktreeSizeResult, + VcsRemoveWorktreesInput, + VcsRemoveWorktreesResult, GitResolvePullRequestResult, GitRunStackedActionInput, VcsStatusInput, @@ -136,6 +142,9 @@ export const WS_METHODS = { vcsListRefs: "vcs.listRefs", vcsCreateWorktree: "vcs.createWorktree", vcsRemoveWorktree: "vcs.removeWorktree", + vcsListManagedWorktrees: "vcs.listManagedWorktrees", + vcsWorktreeSize: "vcs.worktreeSize", + vcsRemoveWorktrees: "vcs.removeWorktrees", vcsCreateRef: "vcs.createRef", vcsSwitchRef: "vcs.switchRef", vcsInit: "vcs.init", @@ -387,6 +396,24 @@ export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +export const WsVcsListManagedWorktreesRpc = Rpc.make(WS_METHODS.vcsListManagedWorktrees, { + payload: VcsListManagedWorktreesInput, + success: VcsListManagedWorktreesResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + +export const WsVcsWorktreeSizeRpc = Rpc.make(WS_METHODS.vcsWorktreeSize, { + payload: VcsWorktreeSizeInput, + success: VcsWorktreeSizeResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + +export const WsVcsRemoveWorktreesRpc = Rpc.make(WS_METHODS.vcsRemoveWorktrees, { + payload: VcsRemoveWorktreesInput, + success: VcsRemoveWorktreesResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { payload: VcsCreateRefInput, success: VcsCreateRefResult, @@ -576,6 +603,9 @@ export const WsRpcGroup = RpcGroup.make( WsVcsListRefsRpc, WsVcsCreateWorktreeRpc, WsVcsRemoveWorktreeRpc, + WsVcsListManagedWorktreesRpc, + WsVcsWorktreeSizeRpc, + WsVcsRemoveWorktreesRpc, WsVcsCreateRefRpc, WsVcsSwitchRefRpc, WsVcsInitRpc, From 40579ad0d6c96b7be4efe748213af3fe86c5b2da Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:25:25 +0200 Subject: [PATCH 06/27] feat(server): list managed worktrees with dirty status --- apps/server/src/vcs/GitVcsDriver.ts | 5 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 24 +++++ apps/server/src/vcs/GitVcsDriverCore.ts | 97 ++++++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 465cd21f320..7de12b498dd 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -25,6 +25,8 @@ import { type VcsListRefsResult, type VcsPullResult, type VcsRemoveWorktreeInput, + type VcsListManagedWorktreesInput, + type VcsListManagedWorktreesResult, type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; @@ -208,6 +210,9 @@ export interface GitVcsDriverShape { input: GitSetBranchUpstreamInput, ) => Effect.Effect; readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; + readonly listManagedWorktrees: ( + input: VcsListManagedWorktreesInput, + ) => Effect.Effect; readonly renameBranch: ( input: GitRenameBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 1909b3b5033..bf9a427224d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -312,6 +312,30 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }); + describe("managed worktrees", () => { + it.effect("lists managed worktrees under the worktrees dir with dirty status", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + refName: initialBranch, + newRefName: "feature-a", + path: null, + }); + + const result = yield* driver.listManagedWorktrees({ cwd }); + + assert.equal(result.worktrees.length, 1); + assert.equal(result.worktrees[0]?.refName, "feature-a"); + // Fresh worktree branch has no remote => treated as dirty (unpushed). + assert.equal(result.worktrees[0]?.isDirty, true); + }), + ); + }); + describe("commit context", () => { it.effect("stages selected files and commits only those files", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 240c20336c6..e9b146f0b1f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2155,6 +2155,102 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); + const readWorktreeDirty = (worktreePath: string): Effect.Effect => + Effect.gen(function* () { + const statusResult = yield* executeGit( + "GitVcsDriver.listManagedWorktrees.status", + worktreePath, + ["status", "--porcelain"], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ).pipe(Effect.orElseSucceed(() => null)); + if (statusResult && statusResult.stdout.trim().length > 0) { + return true; + } + const remoteContains = yield* executeGit( + "GitVcsDriver.listManagedWorktrees.remoteContains", + worktreePath, + ["branch", "--remotes", "--contains", "HEAD"], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ).pipe(Effect.orElseSucceed(() => null)); + const hasRemoteContainingHead = + remoteContains !== null && + remoteContains.exitCode === 0 && + remoteContains.stdout.trim().length > 0; + // No remote branch contains HEAD => there is unpushed work. + return !hasRemoteContainingHead; + }); + + const resolveRealPath = (p: string): Effect.Effect => + fileSystem.realPath(p).pipe(Effect.orElseSucceed(() => p)); + + const listManagedWorktrees: GitVcsDriver.GitVcsDriverShape["listManagedWorktrees"] = Effect.fn( + "listManagedWorktrees", + )(function* (input) { + const result = yield* executeGit( + "GitVcsDriver.listManagedWorktrees", + input.cwd, + ["worktree", "list", "--porcelain"], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + if (result.exitCode !== 0) { + return { worktrees: [] }; + } + + const realWorktreesDir = yield* resolveRealPath(worktreesDir); + const isUnderWorktreesDir = (candidate: string): boolean => { + const normalized = path.resolve(candidate); + return normalized === realWorktreesDir || normalized.startsWith(realWorktreesDir + path.sep); + }; + + const rawCandidates: { path: string; refName: string }[] = []; + let currentPath: string | null = null; + let currentBranch: string | null = null; + const flush = () => { + if (currentPath && isUnderWorktreesDir(currentPath)) { + rawCandidates.push({ + path: currentPath, + refName: currentBranch ?? path.basename(currentPath), + }); + } + currentPath = null; + currentBranch = null; + }; + for (const line of result.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + flush(); + currentPath = line.slice("worktree ".length).trim(); + } else if (line.startsWith("branch refs/heads/")) { + currentBranch = line.slice("branch refs/heads/".length).trim(); + } else if (line.trim() === "") { + flush(); + } + } + flush(); + + const candidates = rawCandidates; + + const existing = yield* Effect.forEach( + candidates, + (candidate) => + fileSystem.stat(candidate.path).pipe( + Effect.as(Option.some(candidate)), + Effect.orElseSucceed(() => Option.none<{ path: string; refName: string }>()), + ), + { concurrency: 8 }, + ).pipe(Effect.map((options) => options.flatMap((o) => (Option.isSome(o) ? [o.value] : [])))); + + const worktrees = yield* Effect.forEach( + existing, + (candidate) => + readWorktreeDirty(candidate.path).pipe( + Effect.map((isDirty) => ({ path: candidate.path, refName: candidate.refName, isDirty })), + ), + { concurrency: 4 }, + ); + + return { worktrees }; + }); + const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( function* (input) { if (input.oldBranch === input.newBranch) { @@ -2318,6 +2414,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fetchRemoteTrackingBranch, setBranchUpstream, removeWorktree, + listManagedWorktrees, renameBranch, createRef, switchRef, From 5460f195ed60eb332d70e0f3d8fd196e48795054 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:31:12 +0200 Subject: [PATCH 07/27] feat(server): compute worktree on-disk size --- apps/server/src/vcs/GitVcsDriver.ts | 5 +++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 20 ++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 34 ++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 7de12b498dd..566362b80e9 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -27,6 +27,8 @@ import { type VcsRemoveWorktreeInput, type VcsListManagedWorktreesInput, type VcsListManagedWorktreesResult, + type VcsWorktreeSizeInput, + type VcsWorktreeSizeResult, type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; @@ -213,6 +215,9 @@ export interface GitVcsDriverShape { readonly listManagedWorktrees: ( input: VcsListManagedWorktreesInput, ) => Effect.Effect; + readonly worktreeSize: ( + input: VcsWorktreeSizeInput, + ) => Effect.Effect; readonly renameBranch: ( input: GitRenameBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index bf9a427224d..6ac281bb367 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -334,6 +334,26 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(result.worktrees[0]?.isDirty, true); }), ); + + it.effect("computes the on-disk byte size of a worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + refName: initialBranch, + newRefName: "feature-size", + path: null, + }); + + const { sizeBytes } = yield* driver.worktreeSize({ path: created.worktree.path }); + + // A real checkout always has tracked files on disk. + assert.isAbove(sizeBytes, 0); + }), + ); }); describe("commit context", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e9b146f0b1f..c424e0f52ce 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2251,6 +2251,39 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { worktrees }; }); + const directorySizeBytes = (rootPath: string): Effect.Effect => { + const walk = (current: string): Effect.Effect => + fileSystem.readDirectory(current).pipe( + Effect.flatMap((entries) => + Effect.forEach( + entries, + (entry) => { + const childPath = path.join(current, entry); + return fileSystem.stat(childPath).pipe( + Effect.flatMap((info) => + info.type === "Directory" + ? walk(childPath) + : Effect.succeed(Number(info.size)), + ), + Effect.orElseSucceed(() => 0), + ); + }, + { concurrency: 8 }, + ), + ), + Effect.map((sizes) => sizes.reduce((total, size) => total + size, 0)), + Effect.orElseSucceed(() => 0), + ); + return walk(rootPath); + }; + + const worktreeSize: GitVcsDriver.GitVcsDriverShape["worktreeSize"] = Effect.fn("worktreeSize")( + function* (input) { + const sizeBytes = yield* directorySizeBytes(input.path); + return { sizeBytes }; + }, + ); + const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( function* (input) { if (input.oldBranch === input.newBranch) { @@ -2415,6 +2448,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* setBranchUpstream, removeWorktree, listManagedWorktrees, + worktreeSize, renameBranch, createRef, switchRef, From 6822b792aeafadb5ed0241c2ebe74d314734d08f Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:35:14 +0200 Subject: [PATCH 08/27] feat(server): batch-remove worktrees with per-path force --- apps/server/src/vcs/GitVcsDriver.ts | 5 ++++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 29 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 27 ++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 566362b80e9..e7fbeadc3ef 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -29,6 +29,8 @@ import { type VcsListManagedWorktreesResult, type VcsWorktreeSizeInput, type VcsWorktreeSizeResult, + type VcsRemoveWorktreesInput, + type VcsRemoveWorktreesResult, type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; @@ -218,6 +220,9 @@ export interface GitVcsDriverShape { readonly worktreeSize: ( input: VcsWorktreeSizeInput, ) => Effect.Effect; + readonly removeWorktrees: ( + input: VcsRemoveWorktreesInput, + ) => Effect.Effect; readonly renameBranch: ( input: GitRenameBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 6ac281bb367..dd758e5ecc4 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -354,6 +354,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.isAbove(sizeBytes, 0); }), ); + + it.effect("batch-removes worktrees and reports per-path outcomes", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const a = yield* driver.createWorktree({ + cwd, + refName: initialBranch, + newRefName: "rm-a", + path: null, + }); + + const { results } = yield* driver.removeWorktrees({ + cwd, + items: [ + { path: a.worktree.path, force: true }, + { path: "/does/not/exist", force: true }, + ], + }); + + assert.equal(results.length, 2); + assert.equal(results.find((r) => r.path === a.worktree.path)?.ok, true); + const missing = results.find((r) => r.path === "/does/not/exist"); + assert.equal(missing?.ok, false); + assert.isString(missing?.error); + }), + ); }); describe("commit context", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index c424e0f52ce..9575ac94543 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2284,6 +2284,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const removeWorktrees: GitVcsDriver.GitVcsDriverShape["removeWorktrees"] = Effect.fn( + "removeWorktrees", + )(function* (input) { + const results = yield* Effect.forEach( + input.items, + (item) => { + const args = ["worktree", "remove"]; + if (item.force) { + args.push("--force"); + } + args.push(item.path); + return executeGit("GitVcsDriver.removeWorktrees", input.cwd, args, { + timeoutMs: 15_000, + fallbackErrorMessage: "git worktree remove failed", + }).pipe( + Effect.as({ path: item.path, ok: true as const }), + Effect.catch((error) => + Effect.succeed({ path: item.path, ok: false as const, error: error.message }), + ), + ); + }, + { concurrency: 1 }, + ); + return { results }; + }); + const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( function* (input) { if (input.oldBranch === input.newBranch) { @@ -2449,6 +2475,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* removeWorktree, listManagedWorktrees, worktreeSize, + removeWorktrees, renameBranch, createRef, switchRef, From 58e670f83f38f51122a312ddef05dd5f337bdc90 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:37:17 +0200 Subject: [PATCH 09/27] feat(server): expose managed-worktree cleanup on GitWorkflowService --- apps/server/src/git/GitWorkflowService.ts | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 74064450fcb..5b68977ee0e 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -19,6 +19,12 @@ import { type GitPullRequestRefInput, type VcsPullResult, type VcsRemoveWorktreeInput, + type VcsListManagedWorktreesInput, + type VcsListManagedWorktreesResult, + type VcsWorktreeSizeInput, + type VcsWorktreeSizeResult, + type VcsRemoveWorktreesInput, + type VcsRemoveWorktreesResult, type GitResolvePullRequestResult, type GitRunStackedActionInput, type GitRunStackedActionResult, @@ -61,6 +67,15 @@ export interface GitWorkflowServiceShape { input: VcsCreateWorktreeInput, ) => Effect.Effect; readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; + readonly listManagedWorktrees: ( + input: VcsListManagedWorktreesInput, + ) => Effect.Effect; + readonly worktreeSize: ( + input: VcsWorktreeSizeInput, + ) => Effect.Effect; + readonly removeWorktrees: ( + input: VcsRemoveWorktreesInput, + ) => Effect.Effect; readonly createRef: ( input: VcsCreateRefInput, ) => Effect.Effect; @@ -298,6 +313,15 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), ), + listManagedWorktrees: (input) => + ensureGitCommand("GitWorkflowService.listManagedWorktrees", input.cwd).pipe( + Effect.andThen(git.listManagedWorktrees(input)), + ), + worktreeSize: (input) => git.worktreeSize(input), + removeWorktrees: (input) => + ensureGitCommand("GitWorkflowService.removeWorktrees", input.cwd).pipe( + Effect.andThen(git.removeWorktrees(input)), + ), createRef: (input) => ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( Effect.andThen(git.createRef(input)), From 110da68cd344a61e7419f827989d55c20b9e0272 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:39:03 +0200 Subject: [PATCH 10/27] feat(server): wire managed-worktree cleanup WS handlers --- apps/server/src/server.test.ts | 3 +++ apps/server/src/ws.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d061578ca68..9491749d319 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4861,6 +4861,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { worktree: { path: "/tmp/wt", refName: "feature/demo" }, }), removeWorktree: () => Effect.void, + listManagedWorktrees: () => Effect.succeed({ worktrees: [] }), + worktreeSize: () => Effect.succeed({ sizeBytes: 0 }), + removeWorktrees: () => Effect.succeed({ results: [] }), createRef: (input) => Effect.succeed({ refName: input.refName }), switchRef: (input) => Effect.succeed({ refName: input.refName }), }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0f2a8f790bf..428aa3a458c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -167,6 +167,9 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope], [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope], [WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope], + [WS_METHODS.vcsListManagedWorktrees, AuthOrchestrationReadScope], + [WS_METHODS.vcsWorktreeSize, AuthOrchestrationReadScope], + [WS_METHODS.vcsRemoveWorktrees, AuthOrchestrationOperateScope], [WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope], [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope], [WS_METHODS.vcsInit, AuthOrchestrationOperateScope], @@ -1269,6 +1272,22 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), + [WS_METHODS.vcsListManagedWorktrees]: (input) => + observeRpcEffect( + WS_METHODS.vcsListManagedWorktrees, + gitWorkflow.listManagedWorktrees(input), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsWorktreeSize]: (input) => + observeRpcEffect(WS_METHODS.vcsWorktreeSize, gitWorkflow.worktreeSize(input), { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.vcsRemoveWorktrees]: (input) => + observeRpcEffect( + WS_METHODS.vcsRemoveWorktrees, + gitWorkflow.removeWorktrees(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.vcsCreateRef]: (input) => observeRpcEffect( WS_METHODS.vcsCreateRef, From ea2a75f0dd3ffa6aba7e115426f536781ecd4edb Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:40:23 +0200 Subject: [PATCH 11/27] feat(contracts): add managed-worktree cleanup to EnvironmentApi.vcs --- packages/contracts/src/ipc.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 56f929f7def..3f7d9bc5fe1 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -9,6 +9,12 @@ import type { VcsPullInput, VcsPullResult, VcsRemoveWorktreeInput, + VcsListManagedWorktreesInput, + VcsListManagedWorktreesResult, + VcsWorktreeSizeInput, + VcsWorktreeSizeResult, + VcsRemoveWorktreesInput, + VcsRemoveWorktreesResult, VcsSwitchRefInput, VcsSwitchRefResult, GitPreparePullRequestThreadInput, @@ -568,6 +574,11 @@ export interface EnvironmentApi { listRefs: (input: VcsListRefsInput) => Promise; createWorktree: (input: VcsCreateWorktreeInput) => Promise; removeWorktree: (input: VcsRemoveWorktreeInput) => Promise; + listManagedWorktrees: ( + input: VcsListManagedWorktreesInput, + ) => Promise; + worktreeSize: (input: VcsWorktreeSizeInput) => Promise; + removeWorktrees: (input: VcsRemoveWorktreesInput) => Promise; createRef: (input: VcsCreateRefInput) => Promise; switchRef: (input: VcsSwitchRefInput) => Promise; init: (input: VcsInitInput) => Promise; From 6e8778a2efd1d6c1db348d147a4049b840bca1f0 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:41:34 +0200 Subject: [PATCH 12/27] feat(client-runtime): add managed-worktree cleanup RPC methods --- packages/client-runtime/src/wsRpcClient.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index c1c683616b2..183edb10d99 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -108,6 +108,9 @@ export interface WsRpcClient { readonly listRefs: RpcUnaryMethod; readonly createWorktree: RpcUnaryMethod; readonly removeWorktree: RpcUnaryMethod; + readonly listManagedWorktrees: RpcUnaryMethod; + readonly worktreeSize: RpcUnaryMethod; + readonly removeWorktrees: RpcUnaryMethod; readonly createRef: RpcUnaryMethod; readonly switchRef: RpcUnaryMethod; readonly init: RpcUnaryMethod; @@ -253,6 +256,12 @@ export function createWsRpcClient( transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)), removeWorktree: (input) => transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), + listManagedWorktrees: (input) => + transport.request((client) => client[WS_METHODS.vcsListManagedWorktrees](input)), + worktreeSize: (input) => + transport.request((client) => client[WS_METHODS.vcsWorktreeSize](input)), + removeWorktrees: (input) => + transport.request((client) => client[WS_METHODS.vcsRemoveWorktrees](input)), createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)), init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), From ef1dbf3977772881c45b34bdde0126fd5cc76ecc Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:44:23 +0200 Subject: [PATCH 13/27] feat(web,mobile): wire managed-worktree cleanup environment API Map listManagedWorktrees, worktreeSize, and removeWorktrees through the web environmentApi and fix all vcs mock objects in the affected test files. --- apps/web/src/environmentApi.ts | 3 +++ .../environments/runtime/service.threadSubscriptions.test.ts | 3 +++ apps/web/src/localApi.test.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index bdb2e793069..f9b11326dcf 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -37,6 +37,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { listRefs: rpcClient.vcs.listRefs, createWorktree: rpcClient.vcs.createWorktree, removeWorktree: rpcClient.vcs.removeWorktree, + listManagedWorktrees: rpcClient.vcs.listManagedWorktrees, + worktreeSize: rpcClient.vcs.worktreeSize, + removeWorktrees: rpcClient.vcs.removeWorktrees, createRef: rpcClient.vcs.createRef, switchRef: rpcClient.vcs.switchRef, init: rpcClient.vcs.init, diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 675a4868032..4b2016a21de 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -126,6 +126,9 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { listRefs: vi.fn(), createWorktree: vi.fn(), removeWorktree: vi.fn(), + listManagedWorktrees: vi.fn(), + worktreeSize: vi.fn(), + removeWorktrees: vi.fn(), createRef: vi.fn(), switchRef: vi.fn(), init: vi.fn(), diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index e50dbd9f5f8..e54d6ef7bc4 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -78,6 +78,9 @@ const rpcClientMock = { listRefs: vi.fn(), createWorktree: vi.fn(), removeWorktree: vi.fn(), + listManagedWorktrees: vi.fn(), + worktreeSize: vi.fn(), + removeWorktrees: vi.fn(), createRef: vi.fn(), switchRef: vi.fn(), init: vi.fn(), From edaede570112b98c0862aff5c52837c658400c9f Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:47:29 +0200 Subject: [PATCH 14/27] feat(web): classify managed worktrees by thread state and scope Add classifyManagedWorktrees and selectWorktreesForScope pure helpers to worktreeCleanup.ts, along with the WorktreeThreadRef, ClassifiedWorktree, and WorktreeClassification types needed to drive cleanup dialog pre-selection. --- apps/web/src/worktreeCleanup.test.ts | 54 ++++++++++++++++++++++++++++ apps/web/src/worktreeCleanup.ts | 43 ++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 0354a966996..5f2763bad80 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -3,6 +3,12 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; +import type { VcsManagedWorktree } from "@t3tools/contracts"; +import { + classifyManagedWorktrees, + selectWorktreesForScope, + type WorktreeThreadRef, +} from "./worktreeCleanup"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -108,3 +114,51 @@ describe("formatWorktreePathForDisplay", () => { expect(result).toBe("my-worktree"); }); }); + +function wt(path: string, isDirty = false): VcsManagedWorktree { + return { path, refName: path.split("/").pop() ?? path, isDirty }; +} + +describe("classifyManagedWorktrees", () => { + it("marks worktrees with a live thread as active", () => { + const refs: WorktreeThreadRef[] = [{ worktreePath: "/wt/a", isArchived: false }]; + const [classified] = classifyManagedWorktrees([wt("/wt/a")], refs); + expect(classified?.classification).toBe("active"); + }); + + it("marks worktrees referenced only by archived threads as archived-only", () => { + const refs: WorktreeThreadRef[] = [{ worktreePath: "/wt/a", isArchived: true }]; + const [classified] = classifyManagedWorktrees([wt("/wt/a")], refs); + expect(classified?.classification).toBe("archived-only"); + }); + + it("marks worktrees with no thread as orphaned", () => { + const [classified] = classifyManagedWorktrees([wt("/wt/a")], []); + expect(classified?.classification).toBe("orphaned"); + }); +}); + +describe("selectWorktreesForScope", () => { + const classified = classifyManagedWorktrees( + [wt("/wt/orphan"), wt("/wt/arch"), wt("/wt/active")], + [ + { worktreePath: "/wt/arch", isArchived: true }, + { worktreePath: "/wt/active", isArchived: false }, + ], + ); + + it("orphaned scope selects only orphaned worktrees", () => { + const selected = selectWorktreesForScope(classified, "orphaned"); + expect(selected.map((c) => c.worktree.path)).toEqual(["/wt/orphan"]); + }); + + it("orphaned-archived scope adds archived-only worktrees", () => { + const selected = selectWorktreesForScope(classified, "orphaned-archived"); + expect(selected.map((c) => c.worktree.path).sort()).toEqual(["/wt/arch", "/wt/orphan"]); + }); + + it("never selects active worktrees", () => { + const selected = selectWorktreesForScope(classified, "orphaned-archived"); + expect(selected.some((c) => c.worktree.path === "/wt/active")).toBe(false); + }); +}); diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 8c09e89afa4..f9156620536 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -1,3 +1,5 @@ +import type { VcsManagedWorktree, WorktreeCleanupScope } from "@t3tools/contracts"; + import type { Thread } from "./types"; function normalizeWorktreePath(path: string | null): string | null { @@ -43,3 +45,44 @@ export function formatWorktreePathForDisplay(worktreePath: string): string { const lastPart = parts[parts.length - 1]?.trim() ?? ""; return lastPart.length > 0 ? lastPart : trimmed; } + +export type WorktreeClassification = "active" | "archived-only" | "orphaned"; + +export interface WorktreeThreadRef { + worktreePath: string | null; + isArchived: boolean; +} + +export interface ClassifiedWorktree { + worktree: VcsManagedWorktree; + classification: WorktreeClassification; +} + +export function classifyManagedWorktrees( + worktrees: readonly VcsManagedWorktree[], + threadRefs: readonly WorktreeThreadRef[], +): ClassifiedWorktree[] { + return worktrees.map((worktree) => { + const normalized = normalizeWorktreePath(worktree.path); + const linked = threadRefs.filter( + (ref) => normalizeWorktreePath(ref.worktreePath) === normalized, + ); + const classification: WorktreeClassification = linked.some((ref) => !ref.isArchived) + ? "active" + : linked.length > 0 + ? "archived-only" + : "orphaned"; + return { worktree, classification }; + }); +} + +export function selectWorktreesForScope( + classified: readonly ClassifiedWorktree[], + scope: WorktreeCleanupScope, +): ClassifiedWorktree[] { + return classified.filter( + (entry) => + entry.classification === "orphaned" || + (scope === "orphaned-archived" && entry.classification === "archived-only"), + ); +} From d7ce592d14c755720c726fb5fb1444df63af9cb3 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 17:49:13 +0200 Subject: [PATCH 15/27] feat(web): add worktree cleanup dialog logic helpers --- .../WorktreeCleanupDialog.logic.test.ts | 63 +++++++++++++++++++ .../components/WorktreeCleanupDialog.logic.ts | 49 +++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 apps/web/src/components/WorktreeCleanupDialog.logic.test.ts create mode 100644 apps/web/src/components/WorktreeCleanupDialog.logic.ts diff --git a/apps/web/src/components/WorktreeCleanupDialog.logic.test.ts b/apps/web/src/components/WorktreeCleanupDialog.logic.test.ts new file mode 100644 index 00000000000..232f2a18ec4 --- /dev/null +++ b/apps/web/src/components/WorktreeCleanupDialog.logic.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + buildRemovalItems, + type CleanupRowState, + formatBytes, + isRowRemovable, + totalSelectedBytes, +} from "./WorktreeCleanupDialog.logic"; + +function row(overrides: Partial = {}): CleanupRowState { + return { + path: "/wt/a", + refName: "a", + classification: "orphaned", + isDirty: false, + selected: true, + force: false, + sizeBytes: 1024, + ...overrides, + }; +} + +describe("formatBytes", () => { + it("formats zero", () => expect(formatBytes(0)).toBe("0 B")); + it("formats kilobytes", () => expect(formatBytes(1024)).toBe("1.0 KB")); + it("formats megabytes", () => expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB")); +}); + +describe("totalSelectedBytes", () => { + it("sums only selected rows with known sizes", () => { + const rows = [ + row({ sizeBytes: 1024 }), + row({ selected: false, sizeBytes: 2048 }), + row({ sizeBytes: null }), + ]; + expect(totalSelectedBytes(rows)).toBe(1024); + }); +}); + +describe("isRowRemovable", () => { + it("blocks active rows", () => + expect(isRowRemovable(row({ classification: "active" }))).toBe(false)); + it("blocks dirty rows without force", () => + expect(isRowRemovable(row({ isDirty: true, force: false }))).toBe(false)); + it("allows dirty rows with force", () => + expect(isRowRemovable(row({ isDirty: true, force: true }))).toBe(true)); + it("blocks deselected rows", () => + expect(isRowRemovable(row({ selected: false }))).toBe(false)); +}); + +describe("buildRemovalItems", () => { + it("forces dirty rows and includes only removable rows", () => { + const rows = [ + row({ path: "/wt/clean" }), + row({ path: "/wt/dirty", isDirty: true, force: true }), + row({ path: "/wt/active", classification: "active" }), + ]; + expect(buildRemovalItems(rows)).toEqual([ + { path: "/wt/clean", force: false }, + { path: "/wt/dirty", force: true }, + ]); + }); +}); diff --git a/apps/web/src/components/WorktreeCleanupDialog.logic.ts b/apps/web/src/components/WorktreeCleanupDialog.logic.ts new file mode 100644 index 00000000000..13ee7301a90 --- /dev/null +++ b/apps/web/src/components/WorktreeCleanupDialog.logic.ts @@ -0,0 +1,49 @@ +import type { WorktreeClassification } from "../worktreeCleanup"; + +export interface CleanupRowState { + path: string; + refName: string; + classification: WorktreeClassification; + isDirty: boolean; + selected: boolean; + force: boolean; + sizeBytes: number | null; +} + +export function formatBytes(bytes: number): string { + if (bytes <= 0) { + return "0 B"; + } + const units = ["B", "KB", "MB", "GB", "TB"]; + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / Math.pow(1024, exponent); + return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`; +} + +export function totalSelectedBytes(rows: readonly CleanupRowState[]): number { + return rows.reduce( + (sum, row) => (row.selected && row.sizeBytes !== null ? sum + row.sizeBytes : sum), + 0, + ); +} + +export function isRowRemovable(row: CleanupRowState): boolean { + if (row.classification === "active") { + return false; + } + if (!row.selected) { + return false; + } + if (row.isDirty && !row.force) { + return false; + } + return true; +} + +export function buildRemovalItems( + rows: readonly CleanupRowState[], +): { path: string; force: boolean }[] { + return rows + .filter(isRowRemovable) + .map((row) => ({ path: row.path, force: row.isDirty || row.force })); +} From cdfe1da1db861a6486a33271a4213b164a7d6485 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 19:30:29 +0200 Subject: [PATCH 16/27] feat(web): add worktree cleanup dialog component --- .../src/components/WorktreeCleanupDialog.tsx | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 apps/web/src/components/WorktreeCleanupDialog.tsx diff --git a/apps/web/src/components/WorktreeCleanupDialog.tsx b/apps/web/src/components/WorktreeCleanupDialog.tsx new file mode 100644 index 00000000000..308fb1e04cf --- /dev/null +++ b/apps/web/src/components/WorktreeCleanupDialog.tsx @@ -0,0 +1,218 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useEffect, useState } from "react"; + +import { ensureEnvironmentApi } from "../environmentApi"; +import { invalidateSourceControlState } from "../lib/sourceControlActions"; +import { + classifyManagedWorktrees, + selectWorktreesForScope, + type WorktreeThreadRef, +} from "../worktreeCleanup"; +import { + buildRemovalItems, + type CleanupRowState, + formatBytes, + totalSelectedBytes, +} from "./WorktreeCleanupDialog.logic"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +interface WorktreeCleanupDialogProps { + open: boolean; + environmentId: EnvironmentId; + cwd: string; + scope: "orphaned" | "orphaned-archived"; + threadRefs: readonly WorktreeThreadRef[]; + onOpenChange: (open: boolean) => void; +} + +export function WorktreeCleanupDialog({ + open, + environmentId, + cwd, + scope, + threadRefs, + onOpenChange, +}: WorktreeCleanupDialogProps) { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [removing, setRemoving] = useState(false); + + useEffect(() => { + if (!open) { + setRows([]); + return; + } + let cancelled = false; + setLoading(true); + void (async () => { + try { + const api = ensureEnvironmentApi(environmentId); + const { worktrees } = await api.vcs.listManagedWorktrees({ cwd }); + const selected = selectWorktreesForScope( + classifyManagedWorktrees(worktrees, threadRefs), + scope, + ); + if (cancelled) return; + setRows( + selected.map((entry) => ({ + path: entry.worktree.path, + refName: entry.worktree.refName, + classification: entry.classification, + isDirty: entry.worktree.isDirty, + selected: !entry.worktree.isDirty, + force: false, + sizeBytes: null, + })), + ); + for (const entry of selected) { + void api.vcs + .worktreeSize({ path: entry.worktree.path }) + .then(({ sizeBytes }) => { + if (cancelled) return; + setRows((current) => + current.map((row) => + row.path === entry.worktree.path ? { ...row, sizeBytes } : row, + ), + ); + }) + .catch(() => { + /* leave sizeBytes null => shown as unknown, excluded from total */ + }); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [open, environmentId, cwd, scope, threadRefs]); + + const setRow = useCallback((path: string, patch: Partial) => { + setRows((current) => current.map((row) => (row.path === path ? { ...row, ...patch } : row))); + }, []); + + const handleConfirm = useCallback(async () => { + const items = buildRemovalItems(rows); + if (items.length === 0) { + onOpenChange(false); + return; + } + setRemoving(true); + try { + const api = ensureEnvironmentApi(environmentId); + const { results } = await api.vcs.removeWorktrees({ cwd, items }); + await invalidateSourceControlState({ environmentId }); + const removed = results.filter((result) => result.ok); + const failed = results.filter((result) => !result.ok); + const freed = removed.reduce((sum, result) => { + const row = rows.find((candidate) => candidate.path === result.path); + return sum + (row?.sizeBytes ?? 0); + }, 0); + toastManager.add( + stackedThreadToast({ + type: failed.length > 0 ? "warning" : "success", + title: + failed.length > 0 + ? `Removed ${removed.length}, ${failed.length} failed` + : `Removed ${removed.length} worktree${removed.length === 1 ? "" : "s"}`, + description: `Freed ${formatBytes(freed)}.${ + failed.length > 0 ? ` Failed: ${failed.map((failure) => failure.path).join(", ")}` : "" + }`, + }), + ); + onOpenChange(false); + } finally { + setRemoving(false); + } + }, [rows, environmentId, cwd, onOpenChange]); + + const total = totalSelectedBytes(rows); + const removableCount = buildRemovalItems(rows).length; + + return ( + + + + Clean up worktrees + + Remove t3code-managed worktrees for this repository. Dirty worktrees require an explicit + force toggle. + + + + {loading ? ( +

Scanning worktrees…

+ ) : rows.length === 0 ? ( +

Nothing to clean up.

+ ) : ( +
    + {rows.map((row) => ( +
  • + setRow(row.path, { selected: event.target.checked })} + aria-label={`Select ${row.refName}`} + /> +
    + {row.refName} + {row.path} +
    + {row.isDirty ? ( + + ) : null} + + {row.sizeBytes === null ? "…" : formatBytes(row.sizeBytes)} + +
  • + ))} +
+ )} + + + + Reclaimable: {formatBytes(total)} + + + + +
+
+ ); +} From aaa9f031e25ac5420c4aef339830907093d86ee3 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 19:31:57 +0200 Subject: [PATCH 17/27] feat(web): add worktree cleanup scope setting UI --- .../components/settings/SettingsPanels.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 76d5d34c355..fe627d01aa6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -738,6 +738,49 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + worktreeCleanupScope: DEFAULT_UNIFIED_SETTINGS.worktreeCleanupScope, + }) + } + /> + ) : null + } + control={ + + } + /> + Date: Wed, 10 Jun 2026 19:35:37 +0200 Subject: [PATCH 18/27] feat(web): add worktree cleanup and visible delete to archived panel --- .../components/settings/SettingsPanels.tsx | 119 ++++++++++++++---- 1 file changed, 94 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fe627d01aa6..07e6dc49bbe 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,10 +1,11 @@ -import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; +import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon, Trash2 } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { useCallback, useMemo, useRef, useState } from "react"; import { defaultInstanceIdForDriver, type DesktopUpdateChannel, + type EnvironmentId, PROVIDER_DISPLAY_NAMES, ProviderDriverKind, type ProviderInstanceConfig, @@ -47,7 +48,7 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { useShallow } from "zustand/react/shallow"; -import { selectProjectsAcrossEnvironments, useStore } from "../../store"; +import { selectProjectsAcrossEnvironments, selectThreadsAcrossEnvironments, useStore } from "../../store"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; @@ -78,6 +79,8 @@ import { useRelativeTimeTick, } from "./settingsLayout"; import { ProjectFavicon } from "../ProjectFavicon"; +import { WorktreeCleanupDialog } from "../WorktreeCleanupDialog"; +import { type WorktreeThreadRef } from "../../worktreeCleanup"; import { useServerObservability, useServerProviders } from "../../rpc/serverState"; const THEME_OPTIONS = [ @@ -1395,6 +1398,24 @@ export function ArchivedThreadsPanel() { refresh: refreshArchivedThreads, } = useArchivedThreadSnapshots(environmentIds); + const settings = useSettings(); + const liveThreads = useStore(selectThreadsAcrossEnvironments); + const [cleanupTarget, setCleanupTarget] = useState<{ + environmentId: EnvironmentId; + cwd: string; + } | null>(null); + + const cleanupThreadRefs: WorktreeThreadRef[] = useMemo(() => { + const live = liveThreads.map((thread) => ({ + worktreePath: thread.worktreePath, + isArchived: thread.archivedAt !== null, + })); + const archived = archivedSnapshots.flatMap(({ snapshot }) => + snapshot.threads.map((thread) => ({ worktreePath: thread.worktreePath, isArchived: true })), + ); + return [...live, ...archived]; + }, [liveThreads, archivedSnapshots]); + const archivedGroups = useMemo(() => { const projectsByEnvironmentAndId = new Map( archivedSnapshots.flatMap(({ environmentId, snapshot }) => @@ -1514,6 +1535,23 @@ export function ArchivedThreadsPanel() { title={project.name} icon={} > + + setCleanupTarget({ environmentId: project.environmentId, cwd: project.cwd }) + } + > + Clean up + + } + /> {projectThreads.map((thread) => ( } control={ - +
+ + +
} /> ))} )) )} + {cleanupTarget ? ( + { + if (!next) { + setCleanupTarget(null); + refreshArchivedThreads(); + } + }} + /> + ) : null} ); } From 4de32a66a03773ff9ca372aa81c972506da09380 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 19:39:16 +0200 Subject: [PATCH 19/27] feat(web): add clean up worktrees to sidebar repo menu --- apps/web/src/components/Sidebar.tsx | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f3f163b017d..f2db921ea85 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -39,6 +39,7 @@ import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, type DesktopUpdateState, + type EnvironmentId, ProjectId, type ScopedThreadRef, type SidebarProjectGroupingMode, @@ -71,6 +72,7 @@ import { selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, + selectThreadsAcrossEnvironments, useStore, } from "../store"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -198,6 +200,8 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; +import { WorktreeCleanupDialog } from "./WorktreeCleanupDialog"; +import type { WorktreeThreadRef } from "../worktreeCleanup"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -1069,6 +1073,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [projectGroupingSelection, setProjectGroupingSelection] = useState< SidebarProjectGroupingMode | "inherit" >("inherit"); + const sidebarSettings = useSettings(); + const sidebarLiveThreads = useStore(selectThreadsAcrossEnvironments); + const [worktreeCleanupTarget, setWorktreeCleanupTarget] = useState<{ + environmentId: EnvironmentId; + cwd: string; + } | null>(null); + const sidebarThreadRefs: WorktreeThreadRef[] = useMemo( + () => + sidebarLiveThreads.map((thread) => ({ + worktreePath: thread.worktreePath, + isArchived: thread.archivedAt !== null, + })), + [sidebarLiveThreads], + ); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const confirmArchiveButtonRefs = useRef(new Map()); @@ -1497,11 +1515,23 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }; }; + const cleanupWorktreesId = `cleanup-worktrees:${project.memberProjects[0]?.physicalProjectKey ?? "project"}`; + actionHandlers.set(cleanupWorktreesId, () => { + const member = project.memberProjects[0]; + if (member) { + setWorktreeCleanupTarget({ environmentId: member.environmentId, cwd: member.cwd }); + } + }); + const clicked = await api.contextMenu.show( [ buildTargetedItem("rename", "Rename project"), buildTargetedItem("grouping", "Project grouping…"), buildTargetedItem("copy-path", "Copy Project Path"), + { + id: cleanupWorktreesId, + label: "Clean up worktrees…", + }, buildTargetedItem("delete", "Remove project", { destructive: true, }), @@ -1526,6 +1556,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec openProjectRenameDialog, project.groupedProjectCount, project.memberProjects, + setWorktreeCleanupTarget, suppressProjectClickForContextMenuRef, ], ); @@ -2226,6 +2257,19 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec + + {worktreeCleanupTarget ? ( + { + if (!next) setWorktreeCleanupTarget(null); + }} + /> + ) : null} ); }); From 174b5442af09d372e4088535ebe2275baf743a2d Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 19:47:09 +0200 Subject: [PATCH 20/27] test(contracts): hoist worktree-schema decoders to module scope --- packages/contracts/src/git.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index b9ba854733e..6eeea58441b 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -19,6 +19,9 @@ const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput); const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); +const decodeListManagedWorktreesResult = Schema.decodeUnknownSync(VcsListManagedWorktreesResult); +const decodeWorktreeSizeResult = Schema.decodeUnknownSync(VcsWorktreeSizeResult); +const decodeRemoveWorktreesInput = Schema.decodeUnknownSync(VcsRemoveWorktreesInput); describe("VcsCreateWorktreeInput", () => { it("accepts omitted newRefName for existing-refName worktrees", () => { @@ -120,19 +123,19 @@ describe("GitRunStackedActionResult", () => { describe("managed worktree schemas", () => { it("decodes a managed worktrees result", () => { - const decoded = Schema.decodeUnknownSync(VcsListManagedWorktreesResult)({ + const decoded = decodeListManagedWorktreesResult({ worktrees: [{ path: "/wt/a", refName: "feature-a", isDirty: false }], }); expect(decoded.worktrees[0]?.isDirty).toBe(false); }); it("decodes a worktree size result", () => { - const decoded = Schema.decodeUnknownSync(VcsWorktreeSizeResult)({ sizeBytes: 4096 }); + const decoded = decodeWorktreeSizeResult({ sizeBytes: 4096 }); expect(decoded.sizeBytes).toBe(4096); }); it("decodes a batch remove input with per-item force", () => { - const decoded = Schema.decodeUnknownSync(VcsRemoveWorktreesInput)({ + const decoded = decodeRemoveWorktreesInput({ cwd: "/repo", items: [{ path: "/wt/a", force: true }, { path: "/wt/b" }], }); From 1ae469acc38dd8070958e11e61be794ed836a514 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 19:58:00 +0200 Subject: [PATCH 21/27] fix(web,contracts): persist worktreeCleanupScope patch and fix reclaimable total - add worktreeCleanupScope to ServerSettingsPatch so the setting persists through the updateSettings RPC (Schema strips unknown keys otherwise) - totalSelectedBytes now counts only removable rows so the footer matches what will actually be deleted (dirty rows need force) --- .../components/WorktreeCleanupDialog.logic.test.ts | 8 ++++++++ .../src/components/WorktreeCleanupDialog.logic.ts | 14 +++++++------- packages/contracts/src/settings.test.ts | 5 +++++ packages/contracts/src/settings.ts | 1 + 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/WorktreeCleanupDialog.logic.test.ts b/apps/web/src/components/WorktreeCleanupDialog.logic.test.ts index 232f2a18ec4..f75c7c6d5f8 100644 --- a/apps/web/src/components/WorktreeCleanupDialog.logic.test.ts +++ b/apps/web/src/components/WorktreeCleanupDialog.logic.test.ts @@ -35,6 +35,14 @@ describe("totalSelectedBytes", () => { ]; expect(totalSelectedBytes(rows)).toBe(1024); }); + + it("excludes dirty rows that are selected but not forced (matches what gets removed)", () => { + const rows = [ + row({ sizeBytes: 1024 }), + row({ isDirty: true, force: false, sizeBytes: 4096 }), + ]; + expect(totalSelectedBytes(rows)).toBe(1024); + }); }); describe("isRowRemovable", () => { diff --git a/apps/web/src/components/WorktreeCleanupDialog.logic.ts b/apps/web/src/components/WorktreeCleanupDialog.logic.ts index 13ee7301a90..8bbd34460f4 100644 --- a/apps/web/src/components/WorktreeCleanupDialog.logic.ts +++ b/apps/web/src/components/WorktreeCleanupDialog.logic.ts @@ -20,13 +20,6 @@ export function formatBytes(bytes: number): string { return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`; } -export function totalSelectedBytes(rows: readonly CleanupRowState[]): number { - return rows.reduce( - (sum, row) => (row.selected && row.sizeBytes !== null ? sum + row.sizeBytes : sum), - 0, - ); -} - export function isRowRemovable(row: CleanupRowState): boolean { if (row.classification === "active") { return false; @@ -40,6 +33,13 @@ export function isRowRemovable(row: CleanupRowState): boolean { return true; } +export function totalSelectedBytes(rows: readonly CleanupRowState[]): number { + return rows.reduce( + (sum, row) => (isRowRemovable(row) && row.sizeBytes !== null ? sum + row.sizeBytes : sum), + 0, + ); +} + export function buildRemovalItems( rows: readonly CleanupRowState[], ): { path: string; force: boolean }[] { diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index c0dc5ec26c8..f6ff4cb4511 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -156,4 +156,9 @@ describe("worktreeCleanupScope", () => { it("defaults to orphaned", () => { expect(DEFAULT_SERVER_SETTINGS.worktreeCleanupScope).toBe("orphaned"); }); + + it("survives a settings patch round-trip so the setting persists", () => { + const patch = decodeServerSettingsPatch({ worktreeCleanupScope: "orphaned-archived" }); + expect(patch.worktreeCleanupScope).toBe("orphaned-archived"); + }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4072b2c95db..2997c627f84 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -487,6 +487,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + worktreeCleanupScope: Schema.optionalKey(WorktreeCleanupScope), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( From 19b0dd09fb8d24b281cc0266891982740ee467e8 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 21:14:12 +0200 Subject: [PATCH 22/27] fix(web,server): address PR review findings for worktree cleanup - normalize separators/trailing slashes when matching worktree paths so live-thread worktrees aren't misclassified as orphaned - resolve real paths symmetrically in listManagedWorktrees so symlinked worktrees dirs match correctly - hold threadRefs in a ref so frequent thread-store updates no longer reset the dialog and discard in-progress selection - surface listManagedWorktrees and removeWorktrees failures via toast + an inline load-error state instead of silently showing "nothing to clean up" - add a .catch with toast to the archived-thread Delete button - route the sidebar "Clean up worktrees" action through the per-member submenu machinery so grouped projects target the right repo --- apps/server/src/vcs/GitVcsDriverCore.ts | 22 +++++----- apps/web/src/components/Sidebar.tsx | 20 +++------- .../src/components/WorktreeCleanupDialog.tsx | 40 +++++++++++++++++-- .../components/settings/SettingsPanels.tsx | 15 +++++-- apps/web/src/worktreeCleanup.test.ts | 6 +++ apps/web/src/worktreeCleanup.ts | 5 ++- 6 files changed, 77 insertions(+), 31 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 9575ac94543..eee79c5ba25 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2197,16 +2197,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const realWorktreesDir = yield* resolveRealPath(worktreesDir); - const isUnderWorktreesDir = (candidate: string): boolean => { - const normalized = path.resolve(candidate); - return normalized === realWorktreesDir || normalized.startsWith(realWorktreesDir + path.sep); - }; + const isUnderWorktreesDir = (candidate: string): boolean => + candidate === realWorktreesDir || candidate.startsWith(realWorktreesDir + path.sep); const rawCandidates: { path: string; refName: string }[] = []; let currentPath: string | null = null; let currentBranch: string | null = null; const flush = () => { - if (currentPath && isUnderWorktreesDir(currentPath)) { + if (currentPath) { rawCandidates.push({ path: currentPath, refName: currentBranch ?? path.basename(currentPath), @@ -2227,13 +2225,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } flush(); - const candidates = rawCandidates; - + // Resolve each candidate's real path (also confirms it still exists on disk) and + // compare against the resolved worktrees dir so symlinked paths match symmetrically. const existing = yield* Effect.forEach( - candidates, + rawCandidates, (candidate) => - fileSystem.stat(candidate.path).pipe( - Effect.as(Option.some(candidate)), + fileSystem.realPath(candidate.path).pipe( + Effect.map((realCandidatePath) => + isUnderWorktreesDir(realCandidatePath) + ? Option.some({ path: candidate.path, refName: candidate.refName }) + : Option.none<{ path: string; refName: string }>(), + ), Effect.orElseSucceed(() => Option.none<{ path: string; refName: string }>()), ), { concurrency: 8 }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index faa515cbc24..64f0403ac5e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1467,7 +1467,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const actionHandlers = new Map Promise | void>(); const makeLeaf = ( - action: "rename" | "grouping" | "copy-path" | "delete", + action: "rename" | "grouping" | "copy-path" | "cleanup-worktrees" | "delete", member: SidebarProjectGroupMember, options?: { destructive?: boolean; @@ -1486,6 +1486,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec case "copy-path": copyPathToClipboard(member.cwd, { path: member.cwd }); return; + case "cleanup-worktrees": + setWorktreeCleanupTarget({ environmentId: member.environmentId, cwd: member.cwd }); + return; case "delete": return handleRemoveProject(member); } @@ -1500,7 +1503,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }; const buildTargetedItem = ( - action: "rename" | "grouping" | "copy-path" | "delete", + action: "rename" | "grouping" | "copy-path" | "cleanup-worktrees" | "delete", label: string, options?: { destructive?: boolean; @@ -1532,23 +1535,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }; }; - const cleanupWorktreesId = `cleanup-worktrees:${project.memberProjects[0]?.physicalProjectKey ?? "project"}`; - actionHandlers.set(cleanupWorktreesId, () => { - const member = project.memberProjects[0]; - if (member) { - setWorktreeCleanupTarget({ environmentId: member.environmentId, cwd: member.cwd }); - } - }); - const clicked = await api.contextMenu.show( [ buildTargetedItem("rename", "Rename"), buildTargetedItem("grouping", "Group into..."), buildTargetedItem("copy-path", "Copy Path"), - { - id: cleanupWorktreesId, - label: "Clean up worktrees…", - }, + buildTargetedItem("cleanup-worktrees", "Clean up worktrees…"), buildTargetedItem("delete", "Remove", { destructive: true, }), diff --git a/apps/web/src/components/WorktreeCleanupDialog.tsx b/apps/web/src/components/WorktreeCleanupDialog.tsx index 308fb1e04cf..96b9ea5f8ba 100644 --- a/apps/web/src/components/WorktreeCleanupDialog.tsx +++ b/apps/web/src/components/WorktreeCleanupDialog.tsx @@ -1,5 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ensureEnvironmentApi } from "../environmentApi"; import { invalidateSourceControlState } from "../lib/sourceControlActions"; @@ -44,21 +44,29 @@ export function WorktreeCleanupDialog({ }: WorktreeCleanupDialogProps) { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); const [removing, setRemoving] = useState(false); + // Hold the latest threadRefs in a ref so frequent thread-store updates don't + // re-run the load effect and discard the user's in-progress selection. + const threadRefsRef = useRef(threadRefs); + threadRefsRef.current = threadRefs; + useEffect(() => { if (!open) { setRows([]); + setLoadError(null); return; } let cancelled = false; setLoading(true); + setLoadError(null); void (async () => { try { const api = ensureEnvironmentApi(environmentId); const { worktrees } = await api.vcs.listManagedWorktrees({ cwd }); const selected = selectWorktreesForScope( - classifyManagedWorktrees(worktrees, threadRefs), + classifyManagedWorktrees(worktrees, threadRefsRef.current), scope, ); if (cancelled) return; @@ -88,6 +96,18 @@ export function WorktreeCleanupDialog({ /* leave sizeBytes null => shown as unknown, excluded from total */ }); } + } catch (error) { + if (cancelled) return; + const message = + error instanceof Error ? error.message : "Failed to load worktrees."; + setLoadError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not load worktrees", + description: message, + }), + ); } finally { if (!cancelled) setLoading(false); } @@ -95,7 +115,7 @@ export function WorktreeCleanupDialog({ return () => { cancelled = true; }; - }, [open, environmentId, cwd, scope, threadRefs]); + }, [open, environmentId, cwd, scope]); const setRow = useCallback((path: string, patch: Partial) => { setRows((current) => current.map((row) => (row.path === path ? { ...row, ...patch } : row))); @@ -131,6 +151,16 @@ export function WorktreeCleanupDialog({ }), ); onOpenChange(false); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to remove worktrees."; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Worktree cleanup failed", + description: message, + }), + ); } finally { setRemoving(false); } @@ -152,6 +182,10 @@ export function WorktreeCleanupDialog({ {loading ? (

Scanning worktrees…

+ ) : loadError ? ( +

+ Could not load worktrees: {loadError} +

) : rows.length === 0 ? (

Nothing to clean up.

) : ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 07e6dc49bbe..7fb3c687fb7 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1581,9 +1581,18 @@ export function ArchivedThreadsPanel() { size="sm" className="h-7 cursor-pointer gap-1.5 px-2.5 text-destructive" onClick={() => - void confirmAndDeleteThread( - scopeThreadRef(thread.environmentId, thread.id), - ).then(() => refreshArchivedThreads()) + void confirmAndDeleteThread(scopeThreadRef(thread.environmentId, thread.id)) + .then(() => refreshArchivedThreads()) + .catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: + error instanceof Error ? error.message : "An error occurred.", + }), + ); + }) } > diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 5f2763bad80..aaa7b28f004 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -136,6 +136,12 @@ describe("classifyManagedWorktrees", () => { const [classified] = classifyManagedWorktrees([wt("/wt/a")], []); expect(classified?.classification).toBe("orphaned"); }); + + it("matches a live thread despite trailing-slash and separator differences", () => { + const refs: WorktreeThreadRef[] = [{ worktreePath: "C:\\repo\\worktrees\\a\\", isArchived: false }]; + const [classified] = classifyManagedWorktrees([wt("C:/repo/worktrees/a")], refs); + expect(classified?.classification).toBe("active"); + }); }); describe("selectWorktreesForScope", () => { diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index f9156620536..f6dacb07f4f 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -7,7 +7,10 @@ function normalizeWorktreePath(path: string | null): string | null { if (!trimmed) { return null; } - return trimmed; + // Canonicalize separator style and trailing slashes so paths from different + // sources (stored thread paths vs. git-reported worktree paths) compare equal. + const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, ""); + return normalized.length > 0 ? normalized : null; } export function getOrphanedWorktreePathForThread( From 76754a0baa4f2a238b8595998204a1b0faa4c611 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 21:22:12 +0200 Subject: [PATCH 23/27] fix(web): scope worktree-cleanup thread refs to the target environment classifyManagedWorktrees matched worktree paths against threads from every environment, so a path collision in another environment could mark a worktree active. Filter both the sidebar and archived-panel thread refs to the environment the dialog is opened for. --- apps/web/src/components/Sidebar.tsx | 12 ++++++----- .../components/settings/SettingsPanels.tsx | 21 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 64f0403ac5e..3f55cc74826 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1096,11 +1096,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } | null>(null); const sidebarThreadRefs: WorktreeThreadRef[] = useMemo( () => - sidebarLiveThreads.map((thread) => ({ - worktreePath: thread.worktreePath, - isArchived: thread.archivedAt !== null, - })), - [sidebarLiveThreads], + sidebarLiveThreads + .filter((thread) => thread.environmentId === worktreeCleanupTarget?.environmentId) + .map((thread) => ({ + worktreePath: thread.worktreePath, + isArchived: thread.archivedAt !== null, + })), + [sidebarLiveThreads, worktreeCleanupTarget], ); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 7fb3c687fb7..9e32882b3f0 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1406,15 +1406,20 @@ export function ArchivedThreadsPanel() { } | null>(null); const cleanupThreadRefs: WorktreeThreadRef[] = useMemo(() => { - const live = liveThreads.map((thread) => ({ - worktreePath: thread.worktreePath, - isArchived: thread.archivedAt !== null, - })); - const archived = archivedSnapshots.flatMap(({ snapshot }) => - snapshot.threads.map((thread) => ({ worktreePath: thread.worktreePath, isArchived: true })), - ); + const targetEnvironmentId = cleanupTarget?.environmentId; + const live = liveThreads + .filter((thread) => thread.environmentId === targetEnvironmentId) + .map((thread) => ({ + worktreePath: thread.worktreePath, + isArchived: thread.archivedAt !== null, + })); + const archived = archivedSnapshots + .filter(({ environmentId }) => environmentId === targetEnvironmentId) + .flatMap(({ snapshot }) => + snapshot.threads.map((thread) => ({ worktreePath: thread.worktreePath, isArchived: true })), + ); return [...live, ...archived]; - }, [liveThreads, archivedSnapshots]); + }, [liveThreads, archivedSnapshots, cleanupTarget]); const archivedGroups = useMemo(() => { const projectsByEnvironmentAndId = new Map( From 79c20774f81379645dc9bdc42bf4de5768f06d72 Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 22:21:20 +0200 Subject: [PATCH 24/27] fix(web): self-load thread refs in cleanup dialog and show protected worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar passed only live-thread refs, so archived-thread worktrees opened from it were misclassified as orphaned and pre-selected under the default "orphaned" scope. And active worktrees were filtered out of the dialog entirely, so the "protected" rows were never visible. The dialog now assembles its own thread picture (live store threads + archived snapshots) for its environment, classifies every managed worktree, and renders all rows via derived state — active rows shown locked/protected, selection defaulted per scope and preserved across thread-store updates via per-path overrides. Removal is gated until both worktrees and archived snapshots load. Both call sites drop the now-internal threadRefs prop. --- apps/web/src/components/Sidebar.tsx | 14 -- .../src/components/WorktreeCleanupDialog.tsx | 199 +++++++++++------- .../components/settings/SettingsPanels.tsx | 21 +- 3 files changed, 126 insertions(+), 108 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3f55cc74826..0139b7654a9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -72,7 +72,6 @@ import { selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, - selectThreadsAcrossEnvironments, useStore, } from "../store"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -201,7 +200,6 @@ import { } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; import { WorktreeCleanupDialog } from "./WorktreeCleanupDialog"; -import type { WorktreeThreadRef } from "../worktreeCleanup"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -1089,21 +1087,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec SidebarProjectGroupingMode | "inherit" >("inherit"); const sidebarSettings = useSettings(); - const sidebarLiveThreads = useStore(selectThreadsAcrossEnvironments); const [worktreeCleanupTarget, setWorktreeCleanupTarget] = useState<{ environmentId: EnvironmentId; cwd: string; } | null>(null); - const sidebarThreadRefs: WorktreeThreadRef[] = useMemo( - () => - sidebarLiveThreads - .filter((thread) => thread.environmentId === worktreeCleanupTarget?.environmentId) - .map((thread) => ({ - worktreePath: thread.worktreePath, - isArchived: thread.archivedAt !== null, - })), - [sidebarLiveThreads, worktreeCleanupTarget], - ); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const confirmArchiveButtonRefs = useRef(new Map()); @@ -2281,7 +2268,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec environmentId={worktreeCleanupTarget.environmentId} cwd={worktreeCleanupTarget.cwd} scope={sidebarSettings.worktreeCleanupScope} - threadRefs={sidebarThreadRefs} onOpenChange={(next) => { if (!next) setWorktreeCleanupTarget(null); }} diff --git a/apps/web/src/components/WorktreeCleanupDialog.tsx b/apps/web/src/components/WorktreeCleanupDialog.tsx index 96b9ea5f8ba..55845842916 100644 --- a/apps/web/src/components/WorktreeCleanupDialog.tsx +++ b/apps/web/src/components/WorktreeCleanupDialog.tsx @@ -1,8 +1,10 @@ -import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useEffect, useRef, useState } from "react"; +import type { EnvironmentId, VcsManagedWorktree } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ensureEnvironmentApi } from "../environmentApi"; +import { useArchivedThreadSnapshots } from "../lib/archivedThreadsState"; import { invalidateSourceControlState } from "../lib/sourceControlActions"; +import { selectThreadsForEnvironment, useStore } from "../store"; import { classifyManagedWorktrees, selectWorktreesForScope, @@ -30,76 +32,87 @@ interface WorktreeCleanupDialogProps { environmentId: EnvironmentId; cwd: string; scope: "orphaned" | "orphaned-archived"; - threadRefs: readonly WorktreeThreadRef[]; onOpenChange: (open: boolean) => void; } +type RowOverride = { selected?: boolean; force?: boolean }; + +const CLASSIFICATION_ORDER: Record = { + orphaned: 0, + "archived-only": 1, + active: 2, +}; + export function WorktreeCleanupDialog({ open, environmentId, cwd, scope, - threadRefs, onOpenChange, }: WorktreeCleanupDialogProps) { - const [rows, setRows] = useState([]); + const [worktrees, setWorktrees] = useState(null); + const [sizes, setSizes] = useState>({}); + const [overrides, setOverrides] = useState>({}); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [removing, setRemoving] = useState(false); - // Hold the latest threadRefs in a ref so frequent thread-store updates don't - // re-run the load effect and discard the user's in-progress selection. - const threadRefsRef = useRef(threadRefs); - threadRefsRef.current = threadRefs; + // Assemble the full thread picture for this environment ourselves, so both + // entry points (sidebar + archived panel) classify identically and archived + // worktrees are never mistaken for orphaned ones. + const environmentIds = useMemo(() => [environmentId], [environmentId]); + const liveThreads = useStore((state) => selectThreadsForEnvironment(state, environmentId)); + const { snapshots: archivedSnapshots, isLoading: archivedLoading } = + useArchivedThreadSnapshots(environmentIds); + const threadRefs = useMemo(() => { + const live = liveThreads.map((thread) => ({ + worktreePath: thread.worktreePath, + isArchived: thread.archivedAt !== null, + })); + const archived = archivedSnapshots + .filter((entry) => entry.environmentId === environmentId) + .flatMap((entry) => + entry.snapshot.threads.map((thread) => ({ + worktreePath: thread.worktreePath, + isArchived: true, + })), + ); + return [...live, ...archived]; + }, [liveThreads, archivedSnapshots, environmentId]); useEffect(() => { if (!open) { - setRows([]); + setWorktrees(null); + setSizes({}); + setOverrides({}); setLoadError(null); return; } let cancelled = false; setLoading(true); setLoadError(null); + setSizes({}); + setOverrides({}); void (async () => { try { const api = ensureEnvironmentApi(environmentId); - const { worktrees } = await api.vcs.listManagedWorktrees({ cwd }); - const selected = selectWorktreesForScope( - classifyManagedWorktrees(worktrees, threadRefsRef.current), - scope, - ); + const result = await api.vcs.listManagedWorktrees({ cwd }); if (cancelled) return; - setRows( - selected.map((entry) => ({ - path: entry.worktree.path, - refName: entry.worktree.refName, - classification: entry.classification, - isDirty: entry.worktree.isDirty, - selected: !entry.worktree.isDirty, - force: false, - sizeBytes: null, - })), - ); - for (const entry of selected) { + setWorktrees(result.worktrees); + for (const worktree of result.worktrees) { void api.vcs - .worktreeSize({ path: entry.worktree.path }) + .worktreeSize({ path: worktree.path }) .then(({ sizeBytes }) => { if (cancelled) return; - setRows((current) => - current.map((row) => - row.path === entry.worktree.path ? { ...row, sizeBytes } : row, - ), - ); + setSizes((current) => ({ ...current, [worktree.path]: sizeBytes })); }) .catch(() => { - /* leave sizeBytes null => shown as unknown, excluded from total */ + /* leave size unknown => shown as "…", excluded from total */ }); } } catch (error) { if (cancelled) return; - const message = - error instanceof Error ? error.message : "Failed to load worktrees."; + const message = error instanceof Error ? error.message : "Failed to load worktrees."; setLoadError(message); toastManager.add( stackedThreadToast({ @@ -115,10 +128,41 @@ export function WorktreeCleanupDialog({ return () => { cancelled = true; }; - }, [open, environmentId, cwd, scope]); + }, [open, environmentId, cwd]); + + // Derived rows: classify every managed worktree, default-select per scope, then + // apply the user's manual overrides (preserved across thread-store updates). + const rows = useMemo(() => { + if (!worktrees) return []; + const classified = classifyManagedWorktrees(worktrees, threadRefs); + const inScope = new Set( + selectWorktreesForScope(classified, scope).map((entry) => entry.worktree.path), + ); + return classified + .map((entry) => { + const path = entry.worktree.path; + const isDirty = entry.worktree.isDirty; + const defaultSelected = + entry.classification !== "active" && inScope.has(path) && !isDirty; + const override = overrides[path]; + return { + path, + refName: entry.worktree.refName, + classification: entry.classification, + isDirty, + selected: override?.selected ?? defaultSelected, + force: override?.force ?? false, + sizeBytes: sizes[path] ?? null, + } satisfies CleanupRowState; + }) + .sort( + (a, b) => + CLASSIFICATION_ORDER[a.classification] - CLASSIFICATION_ORDER[b.classification], + ); + }, [worktrees, threadRefs, scope, sizes, overrides]); - const setRow = useCallback((path: string, patch: Partial) => { - setRows((current) => current.map((row) => (row.path === path ? { ...row, ...patch } : row))); + const setRow = useCallback((path: string, patch: RowOverride) => { + setOverrides((current) => ({ ...current, [path]: { ...current[path], ...patch } })); }, []); const handleConfirm = useCallback(async () => { @@ -152,8 +196,7 @@ export function WorktreeCleanupDialog({ ); onOpenChange(false); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to remove worktrees."; + const message = error instanceof Error ? error.message : "Failed to remove worktrees."; toastManager.add( stackedThreadToast({ type: "error", @@ -175,12 +218,12 @@ export function WorktreeCleanupDialog({ Clean up worktrees - Remove t3code-managed worktrees for this repository. Dirty worktrees require an explicit - force toggle. + Remove t3code-managed worktrees for this repository. Worktrees of active threads are + shown but protected; dirty worktrees require an explicit force toggle. - {loading ? ( + {loading || archivedLoading ? (

Scanning worktrees…

) : loadError ? (

@@ -190,35 +233,43 @@ export function WorktreeCleanupDialog({

Nothing to clean up.

) : (
    - {rows.map((row) => ( -
  • - setRow(row.path, { selected: event.target.checked })} - aria-label={`Select ${row.refName}`} - /> -
    - {row.refName} - {row.path} -
    - {row.isDirty ? ( - - ) : null} - - {row.sizeBytes === null ? "…" : formatBytes(row.sizeBytes)} - -
  • - ))} + {rows.map((row) => { + const isActive = row.classification === "active"; + return ( +
  • + setRow(row.path, { selected: event.target.checked })} + aria-label={`Select ${row.refName}`} + /> +
    + {row.refName} + {row.path} +
    + {isActive ? ( + protected + ) : row.isDirty ? ( + + ) : null} + + {row.sizeBytes === null ? "…" : formatBytes(row.sizeBytes)} + +
  • + ); + })}
)} @@ -241,7 +292,7 @@ export function WorktreeCleanupDialog({ onClick={() => { void handleConfirm(); }} - disabled={removing || removableCount === 0} + disabled={removing || loading || archivedLoading || removableCount === 0} > {removing ? "Removing…" : `Remove ${removableCount}`} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9e32882b3f0..7b27d3e365e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -48,7 +48,7 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { useShallow } from "zustand/react/shallow"; -import { selectProjectsAcrossEnvironments, selectThreadsAcrossEnvironments, useStore } from "../../store"; +import { selectProjectsAcrossEnvironments, useStore } from "../../store"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; @@ -80,7 +80,6 @@ import { } from "./settingsLayout"; import { ProjectFavicon } from "../ProjectFavicon"; import { WorktreeCleanupDialog } from "../WorktreeCleanupDialog"; -import { type WorktreeThreadRef } from "../../worktreeCleanup"; import { useServerObservability, useServerProviders } from "../../rpc/serverState"; const THEME_OPTIONS = [ @@ -1399,28 +1398,11 @@ export function ArchivedThreadsPanel() { } = useArchivedThreadSnapshots(environmentIds); const settings = useSettings(); - const liveThreads = useStore(selectThreadsAcrossEnvironments); const [cleanupTarget, setCleanupTarget] = useState<{ environmentId: EnvironmentId; cwd: string; } | null>(null); - const cleanupThreadRefs: WorktreeThreadRef[] = useMemo(() => { - const targetEnvironmentId = cleanupTarget?.environmentId; - const live = liveThreads - .filter((thread) => thread.environmentId === targetEnvironmentId) - .map((thread) => ({ - worktreePath: thread.worktreePath, - isArchived: thread.archivedAt !== null, - })); - const archived = archivedSnapshots - .filter(({ environmentId }) => environmentId === targetEnvironmentId) - .flatMap(({ snapshot }) => - snapshot.threads.map((thread) => ({ worktreePath: thread.worktreePath, isArchived: true })), - ); - return [...live, ...archived]; - }, [liveThreads, archivedSnapshots, cleanupTarget]); - const archivedGroups = useMemo(() => { const projectsByEnvironmentAndId = new Map( archivedSnapshots.flatMap(({ environmentId, snapshot }) => @@ -1639,7 +1621,6 @@ export function ArchivedThreadsPanel() { environmentId={cleanupTarget.environmentId} cwd={cleanupTarget.cwd} scope={settings.worktreeCleanupScope} - threadRefs={cleanupThreadRefs} onOpenChange={(next) => { if (!next) { setCleanupTarget(null); From 8010b27a31e18d115d12bc8a38f2813adae751fd Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 22:34:31 +0200 Subject: [PATCH 25/27] fix(web): wrap cleanup dialog thread selector in useShallow The inline `useStore((s) => selectThreadsForEnvironment(s, environmentId))` returned a new array each render, which defeats useSyncExternalStore's memoization and caused an infinite render loop ("Maximum update depth exceeded") when opening the dialog. Wrap it in useShallow like the rest of the store call sites; the underlying thread objects are cache-stable, so shallow equality holds and the snapshot stays stable. --- apps/web/src/components/WorktreeCleanupDialog.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/WorktreeCleanupDialog.tsx b/apps/web/src/components/WorktreeCleanupDialog.tsx index 55845842916..658a25392b0 100644 --- a/apps/web/src/components/WorktreeCleanupDialog.tsx +++ b/apps/web/src/components/WorktreeCleanupDialog.tsx @@ -1,5 +1,6 @@ import type { EnvironmentId, VcsManagedWorktree } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; import { ensureEnvironmentApi } from "../environmentApi"; import { useArchivedThreadSnapshots } from "../lib/archivedThreadsState"; @@ -61,7 +62,9 @@ export function WorktreeCleanupDialog({ // entry points (sidebar + archived panel) classify identically and archived // worktrees are never mistaken for orphaned ones. const environmentIds = useMemo(() => [environmentId], [environmentId]); - const liveThreads = useStore((state) => selectThreadsForEnvironment(state, environmentId)); + const liveThreads = useStore( + useShallow((state) => selectThreadsForEnvironment(state, environmentId)), + ); const { snapshots: archivedSnapshots, isLoading: archivedLoading } = useArchivedThreadSnapshots(environmentIds); const threadRefs = useMemo(() => { From 8ddbdf4446bbb64118d7b2ea300824525287836a Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 10 Jun 2026 22:39:15 +0200 Subject: [PATCH 26/27] fix(web,server): surface archived-snapshot and git-list failures in cleanup - the cleanup dialog now reads the archived-snapshot load error; when it fails, archived worktrees can't be distinguished from orphaned, so it shows the error and disables removal instead of silently offering archived worktrees under the orphaned scope - listManagedWorktrees now fails with a GitCommandError when `git worktree list` exits non-zero, instead of returning an empty list that the UI renders as "Nothing to clean up" --- apps/server/src/vcs/GitVcsDriverCore.ts | 5 +---- .../src/components/WorktreeCleanupDialog.tsx | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index eee79c5ba25..d0b6e6ca730 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2190,11 +2190,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.listManagedWorktrees", input.cwd, ["worktree", "list", "--porcelain"], - { timeoutMs: 10_000, allowNonZeroExit: true }, + { timeoutMs: 10_000, fallbackErrorMessage: "git worktree list failed" }, ); - if (result.exitCode !== 0) { - return { worktrees: [] }; - } const realWorktreesDir = yield* resolveRealPath(worktreesDir); const isUnderWorktreesDir = (candidate: string): boolean => diff --git a/apps/web/src/components/WorktreeCleanupDialog.tsx b/apps/web/src/components/WorktreeCleanupDialog.tsx index 658a25392b0..1d634fa8f43 100644 --- a/apps/web/src/components/WorktreeCleanupDialog.tsx +++ b/apps/web/src/components/WorktreeCleanupDialog.tsx @@ -65,8 +65,11 @@ export function WorktreeCleanupDialog({ const liveThreads = useStore( useShallow((state) => selectThreadsForEnvironment(state, environmentId)), ); - const { snapshots: archivedSnapshots, isLoading: archivedLoading } = - useArchivedThreadSnapshots(environmentIds); + const { + snapshots: archivedSnapshots, + isLoading: archivedLoading, + error: archivedError, + } = useArchivedThreadSnapshots(environmentIds); const threadRefs = useMemo(() => { const live = liveThreads.map((thread) => ({ worktreePath: thread.worktreePath, @@ -232,6 +235,11 @@ export function WorktreeCleanupDialog({

Could not load worktrees: {loadError}

+ ) : archivedError ? ( +

+ Could not load archived threads, so worktrees cannot be safely classified:{" "} + {archivedError} +

) : rows.length === 0 ? (

Nothing to clean up.

) : ( @@ -295,7 +303,13 @@ export function WorktreeCleanupDialog({ onClick={() => { void handleConfirm(); }} - disabled={removing || loading || archivedLoading || removableCount === 0} + disabled={ + removing || + loading || + archivedLoading || + archivedError !== null || + removableCount === 0 + } > {removing ? "Removing…" : `Remove ${removableCount}`} From a8e292755852b42ac1bf03f8a8c00406f53bf7e9 Mon Sep 17 00:00:00 2001 From: pat-s Date: Thu, 11 Jun 2026 08:47:33 +0200 Subject: [PATCH 27/27] fix(server): guard batch worktree removal to the managed worktrees dir removeWorktrees ran `git worktree remove` on any client-supplied path. Add a defense-in-depth check that resolves each path and refuses (per-path) anything not under the server worktrees dir, mirroring listManagedWorktrees. --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 23 +++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 49 +++++++++++++------- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index dd758e5ecc4..7cf8dfd610f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -383,6 +383,29 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.isString(missing?.error); }), ); + + it.effect("refuses to remove a path outside the managed worktrees dir", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + // A real directory that exists but is not under the server worktrees dir. + const outside = yield* makeTmpDir("outside-"); + + const { results } = yield* driver.removeWorktrees({ + cwd, + items: [{ path: outside, force: true }], + }); + + assert.equal(results.length, 1); + assert.equal(results[0]?.ok, false); + assert.match(results[0]?.error ?? "", /managed worktrees directory/i); + // The refusal must not delete the directory. + const fileSystem = yield* FileSystem.FileSystem; + assert.equal(yield* fileSystem.exists(outside), true); + }), + ); }); describe("commit context", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index d0b6e6ca730..5a399ccaec1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2286,24 +2286,41 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const removeWorktrees: GitVcsDriver.GitVcsDriverShape["removeWorktrees"] = Effect.fn( "removeWorktrees", )(function* (input) { + // Defense-in-depth: never run `git worktree remove` on a client-supplied path + // that is not under the managed worktrees dir, even though git itself only + // operates on registered worktrees. + const realWorktreesDir = yield* resolveRealPath(worktreesDir); const results = yield* Effect.forEach( input.items, - (item) => { - const args = ["worktree", "remove"]; - if (item.force) { - args.push("--force"); - } - args.push(item.path); - return executeGit("GitVcsDriver.removeWorktrees", input.cwd, args, { - timeoutMs: 15_000, - fallbackErrorMessage: "git worktree remove failed", - }).pipe( - Effect.as({ path: item.path, ok: true as const }), - Effect.catch((error) => - Effect.succeed({ path: item.path, ok: false as const, error: error.message }), - ), - ); - }, + (item) => + resolveRealPath(item.path).pipe( + Effect.flatMap((realCandidate) => { + const isManaged = + realCandidate === realWorktreesDir || + realCandidate.startsWith(realWorktreesDir + path.sep); + if (!isManaged) { + return Effect.succeed({ + path: item.path, + ok: false as const, + error: "refused: path is outside the managed worktrees directory", + }); + } + const args = ["worktree", "remove"]; + if (item.force) { + args.push("--force"); + } + args.push(item.path); + return executeGit("GitVcsDriver.removeWorktrees", input.cwd, args, { + timeoutMs: 15_000, + fallbackErrorMessage: "git worktree remove failed", + }).pipe( + Effect.as({ path: item.path, ok: true as const }), + Effect.catch((error) => + Effect.succeed({ path: item.path, ok: false as const, error: error.message }), + ), + ); + }), + ), { concurrency: 1 }, ); return { results };