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)), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0bf2f6589f0..607d5c203e9 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/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index adf991556d4..fc0ccaf0a8a 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -25,6 +25,12 @@ import { type VcsListRefsResult, type VcsPullResult, type VcsRemoveWorktreeInput, + type VcsListManagedWorktreesInput, + type VcsListManagedWorktreesResult, + type VcsWorktreeSizeInput, + type VcsWorktreeSizeResult, + type VcsRemoveWorktreesInput, + type VcsRemoveWorktreesResult, type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; @@ -222,6 +228,15 @@ export interface GitVcsDriverShape { input: GitSetBranchUpstreamInput, ) => 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 renameBranch: ( input: GitRenameBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c0e0f1876c4..32ce61aec6f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -373,6 +373,102 @@ 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); + }), + ); + + 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); + }), + ); + + 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); + }), + ); + + 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", () => { 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 33d009b9dc2..a5af3bce26c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2247,6 +2247,177 @@ 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, fallbackErrorMessage: "git worktree list failed" }, + ); + + const realWorktreesDir = yield* resolveRealPath(worktreesDir); + 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) { + 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(); + + // 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( + rawCandidates, + (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 }, + ).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 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 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) => + 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 }; + }); + const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( function* (input) { if (input.oldBranch === input.newBranch) { @@ -2411,6 +2582,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fetchRemoteTrackingBranch, setBranchUpstream, removeWorktree, + listManagedWorktrees, + worktreeSize, + removeWorktrees, renameBranch, createRef, switchRef, 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, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dc5acaaadc7..0139b7654a9 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, @@ -198,6 +199,7 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; +import { WorktreeCleanupDialog } from "./WorktreeCleanupDialog"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -1084,6 +1086,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [projectGroupingSelection, setProjectGroupingSelection] = useState< SidebarProjectGroupingMode | "inherit" >("inherit"); + const sidebarSettings = useSettings(); + const [worktreeCleanupTarget, setWorktreeCleanupTarget] = useState<{ + environmentId: EnvironmentId; + cwd: string; + } | null>(null); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const confirmArchiveButtonRefs = useRef(new Map()); @@ -1449,7 +1456,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; @@ -1468,6 +1475,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); } @@ -1482,7 +1492,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; @@ -1519,6 +1529,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec buildTargetedItem("rename", "Rename"), buildTargetedItem("grouping", "Group into..."), buildTargetedItem("copy-path", "Copy Path"), + buildTargetedItem("cleanup-worktrees", "Clean up worktrees…"), buildTargetedItem("delete", "Remove", { destructive: true, }), @@ -1543,6 +1554,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec openProjectRenameDialog, project.groupedProjectCount, project.memberProjects, + setWorktreeCleanupTarget, suppressProjectClickForContextMenuRef, ], ); @@ -2249,6 +2261,18 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec + + {worktreeCleanupTarget ? ( + { + if (!next) setWorktreeCleanupTarget(null); + }} + /> + ) : null} ); }); 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..f75c7c6d5f8 --- /dev/null +++ b/apps/web/src/components/WorktreeCleanupDialog.logic.test.ts @@ -0,0 +1,71 @@ +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); + }); + + 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", () => { + 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..8bbd34460f4 --- /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 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 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 }[] { + return rows + .filter(isRowRemovable) + .map((row) => ({ path: row.path, force: row.isDirty || row.force })); +} diff --git a/apps/web/src/components/WorktreeCleanupDialog.tsx b/apps/web/src/components/WorktreeCleanupDialog.tsx new file mode 100644 index 00000000000..1d634fa8f43 --- /dev/null +++ b/apps/web/src/components/WorktreeCleanupDialog.tsx @@ -0,0 +1,320 @@ +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"; +import { invalidateSourceControlState } from "../lib/sourceControlActions"; +import { selectThreadsForEnvironment, useStore } from "../store"; +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"; + 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, + onOpenChange, +}: WorktreeCleanupDialogProps) { + 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); + + // 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( + useShallow((state) => selectThreadsForEnvironment(state, environmentId)), + ); + const { + snapshots: archivedSnapshots, + isLoading: archivedLoading, + error: archivedError, + } = 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) { + setWorktrees(null); + setSizes({}); + setOverrides({}); + setLoadError(null); + return; + } + let cancelled = false; + setLoading(true); + setLoadError(null); + setSizes({}); + setOverrides({}); + void (async () => { + try { + const api = ensureEnvironmentApi(environmentId); + const result = await api.vcs.listManagedWorktrees({ cwd }); + if (cancelled) return; + setWorktrees(result.worktrees); + for (const worktree of result.worktrees) { + void api.vcs + .worktreeSize({ path: worktree.path }) + .then(({ sizeBytes }) => { + if (cancelled) return; + setSizes((current) => ({ ...current, [worktree.path]: sizeBytes })); + }) + .catch(() => { + /* leave size unknown => shown as "…", 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); + } + })(); + return () => { + cancelled = true; + }; + }, [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: RowOverride) => { + setOverrides((current) => ({ ...current, [path]: { ...current[path], ...patch } })); + }, []); + + 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); + } 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); + } + }, [rows, environmentId, cwd, onOpenChange]); + + const total = totalSelectedBytes(rows); + const removableCount = buildRemovalItems(rows).length; + + return ( + + + + Clean up worktrees + + Remove t3code-managed worktrees for this repository. Worktrees of active threads are + shown but protected; dirty worktrees require an explicit force toggle. + + + + {loading || archivedLoading ? ( +

Scanning worktrees…

+ ) : loadError ? ( +

+ 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.

+ ) : ( +
    + {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)} + +
  • + ); + })} +
+ )} + + + + Reclaimable: {formatBytes(total)} + + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 76d5d34c355..7b27d3e365e 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, @@ -78,6 +79,7 @@ import { useRelativeTimeTick, } from "./settingsLayout"; import { ProjectFavicon } from "../ProjectFavicon"; +import { WorktreeCleanupDialog } from "../WorktreeCleanupDialog"; import { useServerObservability, useServerProviders } from "../../rpc/serverState"; const THEME_OPTIONS = [ @@ -738,6 +740,49 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + worktreeCleanupScope: DEFAULT_UNIFIED_SETTINGS.worktreeCleanupScope, + }) + } + /> + ) : null + } + control={ + + } + /> + (null); + const archivedGroups = useMemo(() => { const projectsByEnvironmentAndId = new Map( archivedSnapshots.flatMap(({ environmentId, snapshot }) => @@ -1471,6 +1522,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} ); } 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(), diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 0354a966996..aaa7b28f004 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,57 @@ 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"); + }); + + 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", () => { + 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..f6dacb07f4f 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 { @@ -5,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( @@ -43,3 +48,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"), + ); +} 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. 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. 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)), diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 0a5497367cd..6eeea58441b 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); @@ -16,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", () => { @@ -114,3 +120,26 @@ describe("GitRunStackedActionResult", () => { } }); }); + +describe("managed worktree schemas", () => { + it("decodes a managed worktrees result", () => { + 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 = decodeWorktreeSizeResult({ sizeBytes: 4096 }); + expect(decoded.sizeBytes).toBe(4096); + }); + + it("decodes a batch remove input with per-item force", () => { + const decoded = decodeRemoveWorktreesInput({ + 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, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1d8656ddf4f..e10c40e83f4 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, @@ -576,6 +582,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; 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, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 04ee479bcd3..f6ff4cb4511 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -151,3 +151,14 @@ 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"); + }); + + 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 33781f56c94..2997c627f84 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( @@ -481,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(