From f0cabbfe6e8732c92c72fc77d21aab9dc58943d0 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:37:10 -0400 Subject: [PATCH 1/5] feat(server): expose the repository's worktrees with cleanup state Nothing could ask the server which worktrees a repository has, so the UI had no way to show them or judge whether one was safe to delete. Adds a vcs.listWorktrees RPC that enumerates the checkouts and enriches each with the two facts a cleanup decision needs: uncommitted changes, and commits the default branch cannot reach. Wired through the git workflow service, the WebSocket handler table, and the web RPC client. --- apps/server/src/git/GitWorkflowService.ts | 14 +++ apps/server/src/vcs/GitVcsDriver.ts | 11 +++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 38 ++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 97 ++++++++++++++++++++ apps/server/src/ws.ts | 4 + apps/web/src/environmentApi.ts | 1 + apps/web/src/lib/gitReactQuery.ts | 27 ++++++ apps/web/src/rpc/wsRpcClient.ts | 3 + packages/contracts/src/git.ts | 31 +++++++ packages/contracts/src/ipc.ts | 3 + packages/contracts/src/rpc.ts | 10 ++ 11 files changed, 239 insertions(+) diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 5460c981a..3e0255bed 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -34,6 +34,8 @@ import { type VcsCreateWorktreeResult, type VcsListRefsInput, type VcsListRefsResult, + type VcsListWorktreesInput, + type VcsListWorktreesResult, type GitGenerateCommitMessageInput, type GitGenerateCommitMessageResult, type GitManagerServiceError, @@ -116,6 +118,10 @@ export interface GitWorkflowServiceShape { readonly listWorktrees: (input: { readonly cwd: string; }) => Effect.Effect, GitCommandError>; + /** See GitVcsDriverShape.listWorktreeStatuses. Empty for a non-repository cwd. */ + readonly listWorktreeStatuses: ( + input: VcsListWorktreesInput, + ) => Effect.Effect; readonly commitGraph: ( input: VcsCommitGraphInput, ) => Effect.Effect; @@ -444,6 +450,14 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { : Effect.succeed>([]), ), ), + listWorktreeStatuses: (input) => + detectGitRepositoryForCommand("GitWorkflowService.listWorktreeStatuses", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository + ? git.listWorktreeStatuses(input) + : Effect.succeed({ worktrees: [] }), + ), + ), commitGraph: (input) => detectGitRepositoryForCommand("GitWorkflowService.commitGraph", input.cwd).pipe( Effect.flatMap((isGitRepository) => diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index e6ab54447..c4b9eabc6 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -35,6 +35,8 @@ import { type VcsDeleteBranchResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, + type VcsListWorktreesInput, + type VcsListWorktreesResult, type VcsInitInput, type VcsListRefsInput, type VcsListRefsResult, @@ -257,6 +259,15 @@ export interface GitVcsDriverShape { readonly listWorktrees: (input: { readonly cwd: string; }) => Effect.Effect, GitCommandError>; + /** + * The same enumeration plus the state a cleanup decision needs: uncommitted + * changes and commits the default branch cannot reach. One extra pair of git + * calls per checkout, run sequentially -- a repository has a handful of + * checkouts, not thousands. + */ + readonly listWorktreeStatuses: ( + input: VcsListWorktreesInput, + ) => Effect.Effect; readonly commitGraph: ( input: VcsCommitGraphInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 5eecfc1e4..ab1eb3b5f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -253,6 +253,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + // The cleanup UI only ever offers to delete a checkout it can describe: + // the tag it shows ("has uncommitted changes", "2 commits not on main") + // comes straight from these two fields. + it.effect("reports dirty and unmerged state per checkout", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const path = yield* Path.Path; + + const cleanPath = path.join(cwd, "worktrees", "clean"); + const messyPath = path.join(cwd, "worktrees", "messy"); + yield* git(cwd, ["worktree", "add", "-b", "clean-branch", cleanPath]); + yield* git(cwd, ["worktree", "add", "-b", "messy-branch", messyPath]); + yield* git(messyPath, ["config", "user.email", "test@test.com"]); + yield* git(messyPath, ["config", "user.name", "Test"]); + yield* writeTextFile(messyPath, "shipped.ts", "export const shipped = true;\n"); + yield* git(messyPath, ["add", "."]); + yield* git(messyPath, ["commit", "-m", "work only this checkout has"]); + yield* writeTextFile(messyPath, "scratch.ts", "export const scratch = true;\n"); + + const { worktrees } = yield* driver.listWorktreeStatuses({ cwd }); + + const root = worktrees.find((worktree) => worktree.isRoot); + assert.isDefined(root); + const clean = worktrees.find((worktree) => worktree.refName === "clean-branch"); + assert.deepStrictEqual( + { dirty: clean?.dirty, unmerged: clean?.unmergedCommitCount, isRoot: clean?.isRoot }, + { dirty: false, unmerged: 0, isRoot: false }, + ); + const messy = worktrees.find((worktree) => worktree.refName === "messy-branch"); + assert.deepStrictEqual( + { dirty: messy?.dirty, unmerged: messy?.unmergedCommitCount, isRoot: messy?.isRoot }, + { dirty: true, unmerged: 1, isRoot: false }, + ); + }), + ); + it.effect("reports no checkouts for a non-repository directory", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index ccbbe67dc..e8c507588 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3821,6 +3821,102 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + /** + * Absolute path of the repository's main checkout, derived from the shared + * git directory. Null when it cannot be determined (bare repositories), in + * which case no listed checkout is reported as the root. + */ + const resolveMainWorktreePath = Effect.fn("resolveMainWorktreePath")(function* (cwd: string) { + const result = yield* executeGit( + "GitVcsDriver.listWorktreeStatuses.commonDir", + cwd, + ["rev-parse", "--git-common-dir"], + { timeoutMs: 5_000, allowNonZeroExit: true }, + ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); + if (result === null || result.exitCode !== 0) { + return null; + } + const rawGitCommonDir = result.stdout.trim(); + if (rawGitCommonDir.length === 0) { + return null; + } + const gitCommonDir = path.isAbsolute(rawGitCommonDir) + ? path.normalize(rawGitCommonDir) + : path.normalize(path.resolve(cwd, rawGitCommonDir)); + if (path.basename(gitCommonDir) !== ".git") { + return null; + } + return path.dirname(gitCommonDir); + }); + + const realPathOrSelf = (candidate: string): Effect.Effect => + fileSystem + .realPath(candidate) + .pipe(Effect.catch(() => Effect.succeed(path.resolve(candidate)))); + + /** Uncommitted changes in one checkout. A checkout git can no longer read + * (its directory was removed underneath us) counts as clean rather than + * failing the whole listing. */ + const readWorktreeDirty = (cwd: string): Effect.Effect => + executeGit("GitVcsDriver.listWorktreeStatuses.status", cwd, ["status", "--porcelain"], { + timeoutMs: 10_000, + allowNonZeroExit: true, + }).pipe( + Effect.map((result) => result.exitCode === 0 && result.stdout.trim().length > 0), + Effect.catch(() => Effect.succeed(false)), + ); + + /** Commits on `branch` the repository's default branch cannot reach. */ + const readUnmergedCommitCount = ( + cwd: string, + branch: string | null, + ): Effect.Effect => + Effect.gen(function* () { + if (branch === null) { + return null; + } + const baseRef = yield* resolveBaseBranchForNoUpstream(cwd, branch); + if (!baseRef) { + return null; + } + const result = yield* executeGit( + "GitVcsDriver.listWorktreeStatuses.revList", + cwd, + ["rev-list", "--count", `${baseRef}..${branch}`], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + if (result.exitCode !== 0) { + return null; + } + const parsed = Number.parseInt(result.stdout.trim(), 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : null; + }).pipe(Effect.catch(() => Effect.succeed(null))); + + const listWorktreeStatuses: GitVcsDriver.GitVcsDriverShape["listWorktreeStatuses"] = Effect.fn( + "listWorktreeStatuses", + )(function* (input) { + const entries = yield* listWorktrees({ cwd: input.cwd }); + if (entries.length === 0) { + return { worktrees: [] }; + } + const mainWorktreePath = yield* resolveMainWorktreePath(input.cwd); + const mainRealPath = mainWorktreePath === null ? null : yield* realPathOrSelf(mainWorktreePath); + + const worktrees = []; + for (const entry of entries) { + const entryRealPath = yield* realPathOrSelf(entry.path); + const isRoot = mainRealPath !== null && entryRealPath === mainRealPath; + worktrees.push({ + path: entry.path, + refName: entry.branch, + isRoot, + dirty: yield* readWorktreeDirty(entry.path), + unmergedCommitCount: yield* readUnmergedCommitCount(entry.path, entry.branch), + }); + } + return { worktrees }; + }); + const readListRefsRepositoryContext = Effect.fn("readListRefsRepositoryContext")(function* ( cwd: string, ) { @@ -4757,6 +4853,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* readConfigValue, listRefs, listWorktrees, + listWorktreeStatuses, commitGraph, commitDetails, workingTreeDiff, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0d5a95153..b40f8485e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1952,6 +1952,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), + [WS_METHODS.vcsListWorktrees]: (input) => + observeRpcEffect(WS_METHODS.vcsListWorktrees, gitWorkflow.listWorktreeStatuses(input), { + "rpc.aggregate": "vcs", + }), [WS_METHODS.vcsRemoveWorktree]: (input) => observeRpcEffect( WS_METHODS.vcsRemoveWorktree, diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 3edf1062e..35aa631dd 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -91,6 +91,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { stageChanges: rpcClient.vcs.stageChanges, unstageChanges: rpcClient.vcs.unstageChanges, createWorktree: rpcClient.vcs.createWorktree, + listWorktrees: rpcClient.vcs.listWorktrees, removeWorktree: rpcClient.vcs.removeWorktree, createRef: rpcClient.vcs.createRef, createTag: rpcClient.vcs.createTag, diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 8829dfa48..85164329e 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -55,6 +55,8 @@ export const gitQueryKeys = { ["git", "auth-remediation-plan", environmentId ?? null, cwd] as const, stashes: (environmentId: EnvironmentId | null, cwd: string | null) => ["git", "stashes", environmentId ?? null, cwd] as const, + worktrees: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "worktrees", environmentId ?? null, cwd] as const, }; export const gitMutationKeys = { @@ -799,6 +801,31 @@ export function gitCreateWorktreeMutationOptions(input: { }); } +/** + * The repository's checkouts with their cleanup state. Fetched on demand (the + * branch menu opens it) and never polled: the list only moves when the user + * creates or removes a worktree, and both paths invalidate it. + */ +export function vcsListWorktreesQueryOptions(input: { + environmentId: EnvironmentId | null; + cwd: string | null; + enabled?: boolean; +}) { + return queryOptions({ + queryKey: gitQueryKeys.worktrees(input.environmentId, input.cwd), + queryFn: async () => { + if (!input.cwd || !input.environmentId) { + throw new Error("Worktrees are unavailable."); + } + return ensureEnvironmentApi(input.environmentId).vcs.listWorktrees({ cwd: input.cwd }); + }, + enabled: input.environmentId !== null && input.cwd !== null && (input.enabled ?? true), + staleTime: 5_000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); +} + /** * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. */ diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 135d35188..0fa95c727 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -149,6 +149,7 @@ export interface WsRpcClient { readonly stageChanges: RpcUnaryMethod; readonly unstageChanges: RpcUnaryMethod; readonly createWorktree: RpcUnaryMethod; + readonly listWorktrees: RpcUnaryMethod; readonly removeWorktree: RpcUnaryMethod; readonly createRef: RpcUnaryMethod; readonly createTag: RpcUnaryMethod; @@ -505,6 +506,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[WS_METHODS.vcsUnstageChanges](input)), createWorktree: (input) => transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)), + listWorktrees: (input) => + transport.request((client) => client[WS_METHODS.vcsListWorktrees](input)), removeWorktree: (input) => transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 084cc339d..83c4877fe 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -101,6 +101,27 @@ const VcsWorktree = Schema.Struct({ path: TrimmedNonEmptyStringSchema, refName: TrimmedNonEmptyStringSchema, }); + +/** + * One checkout of a repository plus the two facts a cleanup decision needs: + * whether removing it drops uncommitted work, and whether it drops commits the + * default branch never saw. + */ +export const VcsWorktreeStatus = Schema.Struct({ + path: TrimmedNonEmptyStringSchema, + /** Null for a detached checkout. */ + refName: Schema.NullOr(TrimmedNonEmptyStringSchema), + /** The repository's main checkout, which is never removable. */ + isRoot: Schema.Boolean, + dirty: Schema.Boolean, + /** + * Commits on this checkout's branch that the default branch cannot reach. + * Null when the repository has no resolvable default branch, when the + * checkout is detached, or when the count could not be read. + */ + unmergedCommitCount: Schema.NullOr(NonNegativeInt), +}); +export type VcsWorktreeStatus = typeof VcsWorktreeStatus.Type; const GitResolvedPullRequest = Schema.Struct({ number: PositiveInt, title: TrimmedNonEmptyStringSchema, @@ -321,6 +342,11 @@ export const VcsCreateWorktreeInput = Schema.Struct({ }); export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type; +export const VcsListWorktreesInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type VcsListWorktreesInput = typeof VcsListWorktreesInput.Type; + export const GitPullRequestRefInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, reference: GitPullRequestReference, @@ -537,6 +563,11 @@ export const VcsCreateWorktreeResult = Schema.Struct({ }); export type VcsCreateWorktreeResult = typeof VcsCreateWorktreeResult.Type; +export const VcsListWorktreesResult = Schema.Struct({ + worktrees: Schema.Array(VcsWorktreeStatus), +}); +export type VcsListWorktreesResult = typeof VcsListWorktreesResult.Type; + export const GitResolvePullRequestResult = Schema.Struct({ pullRequest: GitResolvedPullRequest, }); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 76034bbf5..754ff0927 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -38,6 +38,8 @@ import type { VcsListStashesResult, VcsCreateWorktreeInput, VcsCreateWorktreeResult, + VcsListWorktreesInput, + VcsListWorktreesResult, VcsInitInput, VcsListRefsInput, VcsListRefsResult, @@ -1302,6 +1304,7 @@ export interface EnvironmentApi { stageChanges: (input: VcsStageChangesInput) => Promise; unstageChanges: (input: VcsUnstageChangesInput) => Promise; createWorktree: (input: VcsCreateWorktreeInput) => Promise; + listWorktrees: (input: VcsListWorktreesInput) => Promise; removeWorktree: (input: VcsRemoveWorktreeInput) => Promise; createRef: (input: VcsCreateRefInput) => Promise; createTag: (input: VcsCreateTagInput) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 823f66a05..5243daa26 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -45,6 +45,8 @@ import { VcsDeleteBranchResult, VcsCreateWorktreeInput, VcsCreateWorktreeResult, + VcsListWorktreesInput, + VcsListWorktreesResult, VcsInitInput, VcsListRefsInput, VcsListRefsResult, @@ -278,6 +280,7 @@ export const WS_METHODS = { vcsStageChanges: "vcs.stageChanges", vcsUnstageChanges: "vcs.unstageChanges", vcsCreateWorktree: "vcs.createWorktree", + vcsListWorktrees: "vcs.listWorktrees", vcsRemoveWorktree: "vcs.removeWorktree", vcsCreateRef: "vcs.createRef", vcsCreateTag: "vcs.createTag", @@ -946,6 +949,12 @@ export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { error: GitCommandError, }); +export const WsVcsListWorktreesRpc = Rpc.make(WS_METHODS.vcsListWorktrees, { + payload: VcsListWorktreesInput, + success: VcsListWorktreesResult, + error: GitCommandError, +}); + export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { payload: VcsRemoveWorktreeInput, // Removal is refused outright while a thread still works in the folder, so @@ -1235,6 +1244,7 @@ export const WsRpcGroup = RpcGroup.make( WsVcsStageChangesRpc, WsVcsUnstageChangesRpc, WsVcsCreateWorktreeRpc, + WsVcsListWorktreesRpc, WsVcsRemoveWorktreeRpc, WsVcsCreateRefRpc, WsVcsCreateTagRpc, From 26cddca96f1d5439ea48906e69abd98259c06a32 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:39:20 -0400 Subject: [PATCH 2/5] refactor(shared): move worktree usage classification into shared The client needs the same answer the server's removal guard computes -- which threads still use a worktree -- so the pure logic lived on the wrong side of the wire. Moves the classification (and the workspace path normalizer it depends on) to @threadlines/shared/worktreeUsage; the guard keeps its effectful wrapper and imports the policy. The pure tests move with it. --- .../checkpointing/Layers/CheckpointRevert.ts | 2 +- apps/server/src/checkpointing/Utils.ts | 8 -- .../orchestration/Layers/CheckpointReactor.ts | 2 +- .../src/vcs/WorktreeRemovalGuard.test.ts | 79 +----------------- apps/server/src/vcs/WorktreeRemovalGuard.ts | 77 ++--------------- packages/shared/package.json | 4 + packages/shared/src/path.ts | 8 ++ packages/shared/src/worktreeUsage.test.ts | 83 +++++++++++++++++++ packages/shared/src/worktreeUsage.ts | 80 ++++++++++++++++++ 9 files changed, 186 insertions(+), 157 deletions(-) create mode 100644 packages/shared/src/worktreeUsage.test.ts create mode 100644 packages/shared/src/worktreeUsage.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointRevert.ts b/apps/server/src/checkpointing/Layers/CheckpointRevert.ts index f114279e3..c79fc0434 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointRevert.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointRevert.ts @@ -9,6 +9,7 @@ * @module CheckpointRevertLive */ import type { CheckpointRef, OrchestrationThread, ThreadId } from "@threadlines/contracts"; +import { normalizeWorkspacePath } from "@threadlines/shared/path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -35,7 +36,6 @@ import { checkpointPreTurnRefForThreadTurn, checkpointPreTurnRefForThreadTurnCount, checkpointRefForThreadTurn, - normalizeWorkspacePath, resolveThreadWorkspaceCwd, } from "../Utils.ts"; diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index 94a38881e..f894853c9 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -29,14 +29,6 @@ export function checkpointPreTurnRefForThreadTurnCount( ); } -/** - * Normalizes a workspace path for equality comparisons across separators, - * trailing slashes, and case-insensitive filesystems. - */ -export function normalizeWorkspacePath(value: string): string { - return value.replaceAll("\\", "/").replace(/\/+$/u, "").toLowerCase(); -} - export function resolveThreadWorkspaceCwd(input: { readonly thread: { readonly projectId: ProjectId; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index abde564a1..d3a71f73f 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -18,6 +18,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@threadlines/shared/DrainableWorker"; +import { normalizeWorkspacePath } from "@threadlines/shared/path"; import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; import { normalizeCheckpointFilePath } from "../../checkpointing/SelectiveRevert.ts"; @@ -25,7 +26,6 @@ import { checkpointPreTurnRefForThreadTurn, checkpointPreTurnRefForThreadTurnCount, checkpointRefForThreadTurn, - normalizeWorkspacePath, resolveThreadWorkspaceCwd, } from "../../checkpointing/Utils.ts"; import { diff --git a/apps/server/src/vcs/WorktreeRemovalGuard.test.ts b/apps/server/src/vcs/WorktreeRemovalGuard.test.ts index dcc953073..ea2d758d5 100644 --- a/apps/server/src/vcs/WorktreeRemovalGuard.test.ts +++ b/apps/server/src/vcs/WorktreeRemovalGuard.test.ts @@ -2,12 +2,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Cause from "effect/Cause"; +import type { WorktreeUsageThread } from "@threadlines/shared/worktreeUsage"; -import { - ensureWorktreeRemovable, - findWorktreeBlockingThreads, - type WorktreeUsageThread, -} from "./WorktreeRemovalGuard.ts"; +import { ensureWorktreeRemovable } from "./WorktreeRemovalGuard.ts"; const WORKTREE = "/repo/.worktrees/feature"; const OTHER = "/repo/.worktrees/other"; @@ -19,78 +16,6 @@ const thread = (overrides: Partial = {}): WorktreeUsageThre ...overrides, }); -describe("findWorktreeBlockingThreads", () => { - // The incident: an agent merged its PR and deleted the worktree it was - // running in, leaving its own thread with nowhere to run. - it("blocks a path a live session is running in", () => { - const blocking = findWorktreeBlockingThreads({ - worktreePath: WORKTREE, - threads: [ - thread({ worktreePath: WORKTREE, session: { status: "running", checkoutCwd: WORKTREE } }), - ], - }); - assert.deepStrictEqual(blocking, [ - { threadId: "thread-a", title: "Feature work", hasLiveSession: true }, - ]); - }); - - it("blocks a path a thread is bound to even with no session running", () => { - const blocking = findWorktreeBlockingThreads({ - worktreePath: WORKTREE, - threads: [thread({ worktreePath: WORKTREE, session: null })], - }); - assert.strictEqual(blocking.length, 1); - assert.isFalse(blocking[0]?.hasLiveSession); - }); - - // Deleting a thread stops its session and clears it from the projection - // first, so the app's own cleanup of a now-orphaned worktree still works. - it("allows removal once no thread is bound and no session runs there", () => { - assert.deepStrictEqual( - findWorktreeBlockingThreads({ - worktreePath: WORKTREE, - threads: [ - thread({ worktreePath: OTHER, session: { status: "ready", checkoutCwd: OTHER } }), - ], - }), - [], - ); - }); - - it("ignores a stopped session that merely remembers the path", () => { - assert.deepStrictEqual( - findWorktreeBlockingThreads({ - worktreePath: WORKTREE, - threads: [ - thread({ worktreePath: null, session: { status: "stopped", checkoutCwd: WORKTREE } }), - ], - }), - [], - ); - }); - - // The agent can move itself into a worktree mid-session without the thread's - // configured checkout ever pointing there. - it("blocks a path a session wandered into", () => { - const blocking = findWorktreeBlockingThreads({ - worktreePath: WORKTREE, - threads: [ - thread({ worktreePath: null, effectiveCwd: WORKTREE, session: { status: "running" } }), - ], - }); - assert.strictEqual(blocking.length, 1); - assert.isTrue(blocking[0]?.hasLiveSession); - }); - - it("matches paths across trailing separators and case", () => { - const blocking = findWorktreeBlockingThreads({ - worktreePath: WORKTREE, - threads: [thread({ worktreePath: `${WORKTREE}/` })], - }); - assert.strictEqual(blocking.length, 1); - }); -}); - describe("ensureWorktreeRemovable", () => { it.effect("fails with the blocking threads named", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/WorktreeRemovalGuard.ts b/apps/server/src/vcs/WorktreeRemovalGuard.ts index ef6d5cf2c..0c73423ff 100644 --- a/apps/server/src/vcs/WorktreeRemovalGuard.ts +++ b/apps/server/src/vcs/WorktreeRemovalGuard.ts @@ -12,80 +12,17 @@ * checkout, delete the thread), so an override would only exist to let someone * skip the step that keeps the thread usable. * + * The classification itself lives in `@threadlines/shared/worktreeUsage` so the + * client's cleanup list greys out exactly the folders this guard would refuse. + * * @module WorktreeRemovalGuard */ import * as Effect from "effect/Effect"; import { VcsWorktreeInUseError } from "@threadlines/contracts"; - -import { normalizeWorkspacePath } from "../checkpointing/Utils.ts"; - -/** The subset of a thread shell this guard reads. */ -export interface WorktreeUsageThread { - readonly id: string; - readonly title?: string | null | undefined; - readonly worktreePath: string | null; - readonly effectiveCwd?: string | null | undefined; - readonly session?: - | { - readonly status: string; - readonly checkoutCwd?: string | null | undefined; - } - | null - | undefined; -} - -export interface WorktreeBlockingThread { - readonly threadId: string; - readonly title: string | null; - readonly hasLiveSession: boolean; -} - -const samePath = (left: string | null | undefined, right: string): boolean => - typeof left === "string" && - left.length > 0 && - normalizeWorkspacePath(left) === normalizeWorkspacePath(right); - -/** - * A session counts as live unless it has been stopped. `error` and - * `interrupted` sessions still own a runtime that can be resumed in place, so - * they block too; only an explicitly stopped session releases the folder. - */ -const hasLiveSessionIn = (thread: WorktreeUsageThread, worktreePath: string): boolean => { - const session = thread.session; - if (!session || session.status === "stopped") { - return false; - } - return ( - samePath(session.checkoutCwd, worktreePath) || - samePath(thread.effectiveCwd, worktreePath) || - samePath(thread.worktreePath, worktreePath) - ); -}; - -/** - * Threads that still use `worktreePath`, either as their configured checkout or - * as the directory a live session is actually running in. Pure so the policy is - * testable without a database. - */ -export function findWorktreeBlockingThreads(input: { - readonly worktreePath: string; - readonly threads: ReadonlyArray; -}): ReadonlyArray { - const blocking: WorktreeBlockingThread[] = []; - for (const thread of input.threads) { - const live = hasLiveSessionIn(thread, input.worktreePath); - const bound = samePath(thread.worktreePath, input.worktreePath); - if (!live && !bound) { - continue; - } - blocking.push({ - threadId: thread.id, - title: thread.title?.trim() || null, - hasLiveSession: live, - }); - } - return blocking; -} +import { + findWorktreeBlockingThreads, + type WorktreeUsageThread, +} from "@threadlines/shared/worktreeUsage"; /** * Fails with {@link VcsWorktreeInUseError} when the path is still in use. diff --git a/packages/shared/package.json b/packages/shared/package.json index 600fbb851..6524a6808 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -167,6 +167,10 @@ "types": "./src/terminalCommandTracker.ts", "import": "./src/terminalCommandTracker.ts" }, + "./worktreeUsage": { + "types": "./src/worktreeUsage.ts", + "import": "./src/worktreeUsage.ts" + }, "./threadCwd": { "types": "./src/threadCwd.ts", "import": "./src/threadCwd.ts" diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index b6db0181b..5a8469fcb 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -41,3 +41,11 @@ export function normalizeFilesystemPathForComparison(value: string): string { export function areFilesystemPathsEqual(left: string, right: string): boolean { return normalizeFilesystemPathForComparison(left) === normalizeFilesystemPathForComparison(right); } + +/** + * Normalizes a workspace path for equality comparisons across separators, + * trailing slashes, and case-insensitive filesystems. + */ +export function normalizeWorkspacePath(value: string): string { + return value.replaceAll("\\", "/").replace(/\/+$/u, "").toLowerCase(); +} diff --git a/packages/shared/src/worktreeUsage.test.ts b/packages/shared/src/worktreeUsage.test.ts new file mode 100644 index 000000000..904093bb0 --- /dev/null +++ b/packages/shared/src/worktreeUsage.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { findWorktreeBlockingThreads, type WorktreeUsageThread } from "./worktreeUsage.ts"; + +const WORKTREE = "/repo/.worktrees/feature"; +const OTHER = "/repo/.worktrees/other"; + +const thread = (overrides: Partial = {}): WorktreeUsageThread => ({ + id: "thread-a", + title: "Feature work", + worktreePath: null, + ...overrides, +}); + +describe("findWorktreeBlockingThreads", () => { + // The incident: an agent merged its PR and deleted the worktree it was + // running in, leaving its own thread with nowhere to run. + it("blocks a path a live session is running in", () => { + const blocking = findWorktreeBlockingThreads({ + worktreePath: WORKTREE, + threads: [ + thread({ worktreePath: WORKTREE, session: { status: "running", checkoutCwd: WORKTREE } }), + ], + }); + expect(blocking).toEqual([ + { threadId: "thread-a", title: "Feature work", hasLiveSession: true }, + ]); + }); + + it("blocks a path a thread is bound to even with no session running", () => { + const blocking = findWorktreeBlockingThreads({ + worktreePath: WORKTREE, + threads: [thread({ worktreePath: WORKTREE, session: null })], + }); + expect(blocking.length).toBe(1); + expect(blocking[0]?.hasLiveSession).toBe(false); + }); + + // Deleting a thread stops its session and clears it from the projection + // first, so the app's own cleanup of a now-orphaned worktree still works. + it("allows removal once no thread is bound and no session runs there", () => { + expect( + findWorktreeBlockingThreads({ + worktreePath: WORKTREE, + threads: [ + thread({ worktreePath: OTHER, session: { status: "ready", checkoutCwd: OTHER } }), + ], + }), + ).toEqual([]); + }); + + it("ignores a stopped session that merely remembers the path", () => { + expect( + findWorktreeBlockingThreads({ + worktreePath: WORKTREE, + threads: [ + thread({ worktreePath: null, session: { status: "stopped", checkoutCwd: WORKTREE } }), + ], + }), + ).toEqual([]); + }); + + // The agent can move itself into a worktree mid-session without the thread's + // configured checkout ever pointing there. + it("blocks a path a session wandered into", () => { + const blocking = findWorktreeBlockingThreads({ + worktreePath: WORKTREE, + threads: [ + thread({ worktreePath: null, effectiveCwd: WORKTREE, session: { status: "running" } }), + ], + }); + expect(blocking.length).toBe(1); + expect(blocking[0]?.hasLiveSession).toBe(true); + }); + + it("matches paths across trailing separators and case", () => { + const blocking = findWorktreeBlockingThreads({ + worktreePath: WORKTREE, + threads: [thread({ worktreePath: `${WORKTREE}/` })], + }); + expect(blocking.length).toBe(1); + }); +}); diff --git a/packages/shared/src/worktreeUsage.ts b/packages/shared/src/worktreeUsage.ts new file mode 100644 index 000000000..2747f01ef --- /dev/null +++ b/packages/shared/src/worktreeUsage.ts @@ -0,0 +1,80 @@ +/** + * Which threads are still using a worktree. + * + * Shared because two very different callers need the same answer: the server + * refuses to remove a folder a thread is working in (see + * `apps/server/src/vcs/WorktreeRemovalGuard.ts`), and the client greys out the + * same folders in its cleanup list so the user is never offered a deletion the + * server would decline. + * + * @module worktreeUsage + */ +import { normalizeWorkspacePath } from "./path.ts"; + +/** The subset of a thread shell this policy reads. */ +export interface WorktreeUsageThread { + readonly id: string; + readonly title?: string | null | undefined; + readonly worktreePath: string | null; + readonly effectiveCwd?: string | null | undefined; + readonly session?: + | { + readonly status: string; + readonly checkoutCwd?: string | null | undefined; + } + | null + | undefined; +} + +export interface WorktreeBlockingThread { + readonly threadId: string; + readonly title: string | null; + readonly hasLiveSession: boolean; +} + +const samePath = (left: string | null | undefined, right: string): boolean => + typeof left === "string" && + left.length > 0 && + normalizeWorkspacePath(left) === normalizeWorkspacePath(right); + +/** + * A session counts as live unless it has been stopped. `error` and + * `interrupted` sessions still own a runtime that can be resumed in place, so + * they block too; only an explicitly stopped session releases the folder. + */ +const hasLiveSessionIn = (thread: WorktreeUsageThread, worktreePath: string): boolean => { + const session = thread.session; + if (!session || session.status === "stopped") { + return false; + } + return ( + samePath(session.checkoutCwd, worktreePath) || + samePath(thread.effectiveCwd, worktreePath) || + samePath(thread.worktreePath, worktreePath) + ); +}; + +/** + * Threads that still use `worktreePath`, either as their configured checkout or + * as the directory a live session is actually running in. Pure so the policy is + * testable without a database. + */ +export function findWorktreeBlockingThreads(input: { + readonly worktreePath: string; + readonly threads: ReadonlyArray; +}): ReadonlyArray { + const blocking: WorktreeBlockingThread[] = []; + for (const thread of input.threads) { + const live = hasLiveSessionIn(thread, input.worktreePath); + const bound = samePath(thread.worktreePath, input.worktreePath); + if (!live && !bound) { + continue; + } + blocking.push({ + threadId: thread.id, + title: thread.title?.trim() || null, + hasLiveSession: live, + }); + } + return blocking; +} From 4c9e688ceb3cc28bbaa358e93b3e19f7c08ec49f Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:40:38 -0400 Subject: [PATCH 3/5] fix(web): the panel's branch list shows the worktree tag too The composer's branch picker tagged a branch checked out in another worktree, but the source control panel's Switch to list did not, so the same branch read as plain in one place and tagged in the other. Both pickers now derive the tag from one helper, which compares the branch's checkout against the project's root rather than the checkout being viewed. --- .../BranchToolbarBranchSelector.tsx | 13 +---- .../source-control/SourceControlPanel.tsx | 9 +--- apps/web/src/worktreeCleanup.test.ts | 49 ++++++++++++++++++- apps/web/src/worktreeCleanup.ts | 27 ++++++++++ 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c5263bf49..70f228b24 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -23,6 +23,7 @@ import { newCommandId } from "../lib/utils"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; +import { getVcsRefBadge } from "../worktreeCleanup"; import { useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { @@ -654,17 +655,7 @@ export function BranchToolbarBranchSelector({ const refName = branchByName.get(itemValue); if (!refName) return null; - const hasSecondaryWorktree = - refName.worktreePath && activeProjectCwd && refName.worktreePath !== activeProjectCwd; - const badge = refName.current - ? "current" - : hasSecondaryWorktree - ? "worktree" - : refName.isRemote - ? "remote" - : refName.isDefault - ? "default" - : null; + const badge = getVcsRefBadge(refName, activeProjectCwd); return ( {ref.name} - {ref.current - ? "current" - : ref.isRemote - ? "remote" - : ref.isDefault - ? "default" - : ""} + {getVcsRefBadge(ref, target.projectCwd) ?? ""} )) diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index b574f5901..875472940 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -1,8 +1,18 @@ -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@threadlines/contracts"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type VcsRef, +} from "@threadlines/contracts"; 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 { + formatWorktreePathForDisplay, + getOrphanedWorktreePathForThread, + getVcsRefBadge, +} from "./worktreeCleanup"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -88,6 +98,41 @@ describe("getOrphanedWorktreePathForThread", () => { }); }); +describe("getVcsRefBadge", () => { + function makeRef(overrides: Partial = {}): VcsRef { + return { + name: "feature", + current: false, + isDefault: false, + worktreePath: null, + ...overrides, + }; + } + + it("tags a branch checked out in a secondary worktree", () => { + const ref = makeRef({ worktreePath: "/repo/.worktrees/feature", isDefault: true }); + expect(getVcsRefBadge(ref, "/repo")).toBe("worktree"); + }); + + // The panel's picker looks at whichever checkout is being viewed, which may + // itself be a worktree; the badge must compare against the project root. + it("does not tag the branch that occupies the project's root checkout", () => { + const ref = makeRef({ worktreePath: "/repo", isDefault: true }); + expect(getVcsRefBadge(ref, "/repo")).toBe("default"); + }); + + it("prefers current over every other tag", () => { + const ref = makeRef({ current: true, worktreePath: "/repo/.worktrees/feature" }); + expect(getVcsRefBadge(ref, "/repo")).toBe("current"); + }); + + it("falls back to remote and then default", () => { + expect(getVcsRefBadge(makeRef({ isRemote: true, isDefault: true }), "/repo")).toBe("remote"); + expect(getVcsRefBadge(makeRef({ isDefault: true }), "/repo")).toBe("default"); + expect(getVcsRefBadge(makeRef(), "/repo")).toBeNull(); + }); +}); + describe("formatWorktreePathForDisplay", () => { it("shows only the last path segment for unix-like paths", () => { const result = formatWorktreePathForDisplay( diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 8c09e89af..7367429d8 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -1,3 +1,5 @@ +import type { VcsRef } from "@threadlines/contracts"; + import type { Thread } from "./types"; function normalizeWorktreePath(path: string | null): string | null { @@ -32,6 +34,31 @@ export function getOrphanedWorktreePathForThread( return isShared ? null : targetWorktreePath; } +export type VcsRefBadge = "current" | "worktree" | "remote" | "default"; + +/** + * The one-word tag a branch row carries in every picker. + * + * "worktree" means the branch is checked out somewhere other than the + * project's root checkout, which is why the comparison is against the project + * root and not whichever checkout the picker happens to be showing. + */ +export function getVcsRefBadge(ref: VcsRef, projectRootCwd: string | null): VcsRefBadge | null { + if (ref.current) { + return "current"; + } + if (ref.worktreePath && projectRootCwd && ref.worktreePath !== projectRootCwd) { + return "worktree"; + } + if (ref.isRemote) { + return "remote"; + } + if (ref.isDefault) { + return "default"; + } + return null; +} + export function formatWorktreePathForDisplay(worktreePath: string): string { const trimmed = worktreePath.trim(); if (!trimmed) { From a72e652c11811b1634ed8466007b070b880a175c Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:44:03 -0400 Subject: [PATCH 4/5] fix(web): deleting an archived thread offers its worktree too Deleting an archived thread dispatched the delete and returned, so the worktree it was the last thread linked to was never offered for cleanup and leaked. The archived path now resolves the thread and its project from the archived snapshot and runs the same offer as the live path. The "only thread linked" check weighs live and archived threads together in both directions, so neither path offers to remove a folder the other still points at. --- apps/web/src/hooks/useThreadActions.ts | 142 +++++++++++++++++-------- apps/web/src/worktreeCleanup.test.ts | 29 +++++ apps/web/src/worktreeCleanup.ts | 26 ++++- 3 files changed, 150 insertions(+), 47 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index bc64416fa..a995f4dd7 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -25,6 +25,25 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; +/** + * Asks whether the folder should go too. Answers false when no confirm dialog + * is available, so a worktree is never removed without the user saying so. + */ +async function confirmOrphanedWorktreeDeletion(worktreePath: string): Promise { + const localApi = readLocalApi(); + if (!localApi) { + return false; + } + return localApi.dialogs.confirm( + [ + "This thread is the only one linked to this worktree:", + formatWorktreePathForDisplay(worktreePath), + "", + "Delete the worktree too?", + ].join("\n"), + ); +} + export function useThreadActions() { const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); @@ -121,19 +140,83 @@ export function useThreadActions() { }); }, []); + const removeOrphanedWorktree = useCallback( + async (input: { + readonly environmentId: ScopedThreadRef["environmentId"]; + readonly threadId: ThreadId; + readonly projectCwd: string; + readonly worktreePath: string; + }) => { + try { + await ensureEnvironmentApi(input.environmentId).vcs.removeWorktree({ + cwd: input.projectCwd, + path: input.worktreePath, + force: true, + }); + await invalidateGitQueries(queryClient, { environmentId: input.environmentId }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error removing worktree."; + console.error("Failed to remove orphaned worktree after thread deletion", { + threadId: input.threadId, + projectCwd: input.projectCwd, + worktreePath: input.worktreePath, + error, + }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread deleted, but worktree removal failed", + description: `Could not remove ${formatWorktreePathForDisplay(input.worktreePath)}. ${message}`, + }), + ); + } + }, + [queryClient], + ); + const deleteThread = useCallback( async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { const api = readEnvironmentApi(target.environmentId); if (!api) return; const resolved = resolveThreadTarget(target); if (!resolved) { - // Thread not in main store (e.g. archived thread) — dispatch delete directly. + // Thread not in the main store (archived): its worktree link only + // survives in the archived snapshot, so read it from there and offer + // the same cleanup the live path offers. + const snapshot = await api.orchestration.getArchivedShellSnapshot().catch(() => null); + const archivedThreads = snapshot?.threads ?? []; + const archivedThread = archivedThreads.find((entry) => entry.id === target.threadId); + const liveThreads = selectThreadsForEnvironment(useStore.getState(), target.environmentId); + const orphanedWorktreePath = getOrphanedWorktreePathForThread( + liveThreads, + target.threadId, + archivedThreads, + ); + const projectCwd = + archivedThread === undefined + ? undefined + : snapshot?.projects.find((project) => project.id === archivedThread.projectId) + ?.workspaceRoot; + const shouldDeleteWorktree = + orphanedWorktreePath !== null && + projectCwd !== undefined && + (await confirmOrphanedWorktreeDeletion(orphanedWorktreePath)); + await api.orchestration.dispatchCommand({ type: "thread.delete", commandId: newCommandId(), threadId: target.threadId, }); refreshArchivedThreadsForEnvironment(target.environmentId); + + if (shouldDeleteWorktree && orphanedWorktreePath && projectCwd) { + await removeOrphanedWorktree({ + environmentId: target.environmentId, + threadId: target.threadId, + projectCwd, + worktreePath: orphanedWorktreePath, + }); + } return; } const { thread, threadRef } = resolved; @@ -156,26 +239,20 @@ export function useThreadActions() { deletedIds && deletedIds.size > 0 ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) : threads; + // Archived threads keep their worktree link, so they have to be weighed + // before offering to remove the folder one of them still points at. + const archivedThreads = thread.worktreePath + ? ((await api.orchestration.getArchivedShellSnapshot().catch(() => null))?.threads ?? []) + : []; const orphanedWorktreePath = getOrphanedWorktreePathForThread( survivingThreads, threadRef.threadId, + archivedThreads, ); - const displayWorktreePath = orphanedWorktreePath - ? formatWorktreePathForDisplay(orphanedWorktreePath) - : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; - const localApi = readLocalApi(); const shouldDeleteWorktree = - canDeleteWorktree && - localApi && - (await localApi.dialogs.confirm( - [ - "This thread is the only one linked to this worktree:", - displayWorktreePath ?? orphanedWorktreePath, - "", - "Delete the worktree too?", - ].join("\n"), - )); + orphanedWorktreePath !== null && + threadProject !== undefined && + (await confirmOrphanedWorktreeDeletion(orphanedWorktreePath)); if (thread.session && thread.session.status !== "closed") { await stopThreadSession(threadRef).catch(() => undefined); @@ -237,39 +314,20 @@ export function useThreadActions() { return; } - try { - await ensureEnvironmentApi(threadRef.environmentId).vcs.removeWorktree({ - cwd: threadProject.cwd, - path: orphanedWorktreePath, - force: true, - }); - await invalidateGitQueries(queryClient, { - environmentId: threadRef.environmentId, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error removing worktree."; - console.error("Failed to remove orphaned worktree after thread deletion", { - threadId: threadRef.threadId, - projectCwd: threadProject.cwd, - worktreePath: orphanedWorktreePath, - error, - }); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Thread deleted, but worktree removal failed", - description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, - }), - ); - } + await removeOrphanedWorktree({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + projectCwd: threadProject.cwd, + worktreePath: orphanedWorktreePath, + }); }, [ clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalState, getCurrentRouteThreadRef, + removeOrphanedWorktree, router, - queryClient, resolveThreadTarget, sidebarThreadSortOrder, ], diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 875472940..6a16af5f8 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -82,6 +82,35 @@ describe("getOrphanedWorktreePathForThread", () => { expect(result).toBeNull(); }); + // An archived thread can bring its checkout back through checkout recovery, + // so its link has to count in both directions. + it("returns null when only an archived thread links to the same worktree", () => { + const result = getOrphanedWorktreePathForThread( + [makeThread({ worktreePath: "/tmp/repo/worktrees/feature-a" })], + ThreadId.make("thread-1"), + [{ id: "thread-archived", worktreePath: "/tmp/repo/worktrees/feature-a" }], + ); + expect(result).toBeNull(); + }); + + it("resolves an archived target thread against the live threads", () => { + const archived = [{ id: "thread-archived", worktreePath: "/tmp/repo/worktrees/feature-a" }]; + expect( + getOrphanedWorktreePathForThread( + [makeThread({ worktreePath: "/tmp/repo/worktrees/feature-b" })], + "thread-archived", + archived, + ), + ).toBe("/tmp/repo/worktrees/feature-a"); + expect( + getOrphanedWorktreePathForThread( + [makeThread({ worktreePath: "/tmp/repo/worktrees/feature-a" })], + "thread-archived", + archived, + ), + ).toBeNull(); + }); + it("ignores threads linked to different worktrees", () => { const threads = [ makeThread({ diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 7367429d8..a4bfd34dd 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -1,6 +1,10 @@ import type { VcsRef } from "@threadlines/contracts"; -import type { Thread } from "./types"; +/** Any thread shape that records a checkout: live store threads and archived shells both qualify. */ +export interface WorktreeLinkedThread { + readonly id: string; + readonly worktreePath: string | null; +} function normalizeWorktreePath(path: string | null): string | null { const trimmed = path?.trim(); @@ -10,11 +14,23 @@ function normalizeWorktreePath(path: string | null): string | null { return trimmed; } +/** + * The worktree a thread would leave behind, or null when something else still + * points at it. + * + * Archived threads count: they can recreate their checkout later through + * checkout recovery, so deleting a live thread must not offer to remove the + * folder an archived one is waiting on -- and the same in reverse. The target + * thread may come from either list. + */ export function getOrphanedWorktreePathForThread( - threads: readonly Thread[], - threadId: Thread["id"], + threads: readonly WorktreeLinkedThread[], + threadId: string, + archivedThreads: readonly WorktreeLinkedThread[] = [], ): string | null { - const targetThread = threads.find((thread) => thread.id === threadId); + const targetThread = + threads.find((thread) => thread.id === threadId) ?? + archivedThreads.find((thread) => thread.id === threadId); if (!targetThread) { return null; } @@ -24,7 +40,7 @@ export function getOrphanedWorktreePathForThread( return null; } - const isShared = threads.some((thread) => { + const isShared = [...threads, ...archivedThreads].some((thread) => { if (thread.id === threadId) { return false; } From 436558fb22201ccba7fc8e65d07cf809011814fb Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:50:25 -0400 Subject: [PATCH 5/5] feat(web): clean up spare worktrees from the branch menu Worktrees only ever got removed through the prompt on thread delete, and several paths skipped it, so a project quietly accumulated checkouts with no way to see or remove them. The source control branch menu gains a "Clean up worktrees..." row with a count of how many are going spare, and a spinner in that same slot while the list loads. It opens a dialog listing every checkout nothing is running in, each with its branch and a note when deleting it would lose something: uncommitted changes, commits the default branch never saw, or an archived thread still pointing there. Risk-free rows start ticked, risky ones do not, and checkouts in use are listed greyed out so the picture is complete. Confirming deletes them one at a time and reports the outcome on each row, so a failure stays on screen instead of scrolling past in a toast. The server's removal guard still has the final word. Each row also offers to move the thread into that checkout, so its uncommitted files and unshipped commits can be read in the panel before deciding. That runs the same checkout switch the composer's branch picker performs. Branches left over from before a history rewrite share no commit with the default branch, and counting there reported their entire history as unshipped work ("1731 commits not on main"). The listing now checks for a merge base first and reports unrelated history instead of a number. --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 35 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 45 +- .../SourceControlPanel.browser.tsx | 181 +++++++- .../source-control/SourceControlPanel.tsx | 404 +++++++++++++++++- apps/web/src/worktreeCleanup.test.ts | 83 ++++ apps/web/src/worktreeCleanup.ts | 123 +++++- packages/contracts/src/git.ts | 9 +- 7 files changed, 844 insertions(+), 36 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index ab1eb3b5f..7e237e378 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -288,6 +288,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { { dirty: messy?.dirty, unmerged: messy?.unmergedCommitCount, isRoot: messy?.isRoot }, { dirty: true, unmerged: 1, isRoot: false }, ); + assert.isFalse(clean?.unrelatedHistory); + assert.isFalse(messy?.unrelatedHistory); + }), + ); + + // Checkouts left over from before a history rewrite share no commit with + // the default branch. Counting there reports the branch's whole history as + // unshipped work, so the listing says "unrelated" and skips the number. + it.effect("flags a checkout whose branch shares no history with the base", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const path = yield* Path.Path; + + yield* git(cwd, ["checkout", "--orphan", "ancient-branch"]); + yield* git(cwd, ["rm", "-rf", "--cached", "."]); + yield* writeTextFile(cwd, "ancient.ts", "export const ancient = true;\n"); + yield* git(cwd, ["add", "ancient.ts"]); + yield* git(cwd, ["commit", "-m", "history from before the rewrite"]); + // Forced: the orphan commit leaves the base branch's files untracked. + yield* git(cwd, ["checkout", "-f", initialBranch]); + const ancientPath = path.join(cwd, "worktrees", "ancient"); + yield* git(cwd, ["worktree", "add", ancientPath, "ancient-branch"]); + + const { worktrees } = yield* driver.listWorktreeStatuses({ cwd }); + + const ancient = worktrees.find((worktree) => worktree.refName === "ancient-branch"); + assert.deepStrictEqual( + { + unrelated: ancient?.unrelatedHistory, + unmerged: ancient?.unmergedCommitCount, + }, + { unrelated: true, unmerged: null }, + ); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e8c507588..59e76a013 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3866,18 +3866,40 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.catch(() => Effect.succeed(false)), ); - /** Commits on `branch` the repository's default branch cannot reach. */ - const readUnmergedCommitCount = ( + /** + * Commits on `branch` the repository's default branch cannot reach. + * + * A branch with no merge base against that default branch reports + * `unrelatedHistory` and no count: `rev-list` would answer with the whole of + * its history, which reads as a mountain of unshipped work when the truth is + * that the two histories were never joined (checkouts predating a history + * rewrite are the usual source). + */ + const readUnmergedCommits = ( cwd: string, branch: string | null, - ): Effect.Effect => + ): Effect.Effect<{ readonly count: number | null; readonly unrelatedHistory: boolean }> => Effect.gen(function* () { if (branch === null) { - return null; + return { count: null, unrelatedHistory: false }; } const baseRef = yield* resolveBaseBranchForNoUpstream(cwd, branch); if (!baseRef) { - return null; + return { count: null, unrelatedHistory: false }; + } + const mergeBase = yield* executeGit( + "GitVcsDriver.listWorktreeStatuses.mergeBase", + cwd, + ["merge-base", baseRef, branch], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + // Exit 1 is git's specific "no merge base"; anything higher is a broken + // ref or a failed call, which says nothing about the histories. + if (mergeBase.exitCode === 1) { + return { count: null, unrelatedHistory: true }; + } + if (mergeBase.exitCode !== 0 || mergeBase.stdout.trim().length === 0) { + return { count: null, unrelatedHistory: false }; } const result = yield* executeGit( "GitVcsDriver.listWorktreeStatuses.revList", @@ -3886,11 +3908,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { timeoutMs: 10_000, allowNonZeroExit: true }, ); if (result.exitCode !== 0) { - return null; + return { count: null, unrelatedHistory: false }; } const parsed = Number.parseInt(result.stdout.trim(), 10); - return Number.isFinite(parsed) ? Math.max(0, parsed) : null; - }).pipe(Effect.catch(() => Effect.succeed(null))); + return { + count: Number.isFinite(parsed) ? Math.max(0, parsed) : null, + unrelatedHistory: false, + }; + }).pipe(Effect.catch(() => Effect.succeed({ count: null, unrelatedHistory: false }))); const listWorktreeStatuses: GitVcsDriver.GitVcsDriverShape["listWorktreeStatuses"] = Effect.fn( "listWorktreeStatuses", @@ -3906,12 +3931,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* for (const entry of entries) { const entryRealPath = yield* realPathOrSelf(entry.path); const isRoot = mainRealPath !== null && entryRealPath === mainRealPath; + const unmerged = yield* readUnmergedCommits(entry.path, entry.branch); worktrees.push({ path: entry.path, refName: entry.branch, isRoot, dirty: yield* readWorktreeDirty(entry.path), - unmergedCommitCount: yield* readUnmergedCommitCount(entry.path, entry.branch), + unmergedCommitCount: unmerged.count, + unrelatedHistory: unmerged.unrelatedHistory, }); } return { worktrees }; diff --git a/apps/web/src/components/source-control/SourceControlPanel.browser.tsx b/apps/web/src/components/source-control/SourceControlPanel.browser.tsx index bfe4740e0..433c8e900 100644 --- a/apps/web/src/components/source-control/SourceControlPanel.browser.tsx +++ b/apps/web/src/components/source-control/SourceControlPanel.browser.tsx @@ -1,7 +1,10 @@ import "../../index.css"; +import { scopeThreadRef } from "@threadlines/client-runtime"; import { EnvironmentId, + ThreadId, + type ScopedThreadRef, type GitActionProgressEvent, type GitRunStackedActionResult, type EnvironmentApi, @@ -31,6 +34,7 @@ import { } from "../../environmentApi"; import { __resetLocalApiForTests } from "../../localApi"; import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry"; +import { useStore } from "../../store"; import { resetGitActionProgressStateForTests } from "../gitActionProgressState"; import { SourceControlPanel, type SourceControlProjectTarget } from "./SourceControlPanel"; @@ -247,9 +251,21 @@ function getCommitMessageTextarea() { } function makeEnvironmentApi( - overrides: { readonly vcs?: Partial } = {}, + overrides: { + readonly vcs?: Partial; + readonly orchestration?: Partial; + } = {}, ): EnvironmentApi { return { + orchestration: { + getArchivedShellSnapshot: vi.fn(async () => ({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "2026-05-25T12:00:00.000Z", + })), + ...overrides.orchestration, + }, vcs: { listRefs: vi.fn(async () => ({ isRepo: true, @@ -307,6 +323,61 @@ function makeEnvironmentApi( } as unknown as EnvironmentApi; } +/** + * Puts one live thread in the store bound to `worktreePath`, which is what + * makes the cleanup dialog treat that checkout as in use. + */ +function seedWorktreeThread(worktreePath: string): void { + const threadId = "source-control-live-thread"; + const projectId = "source-control-live-project"; + useStore.setState({ + activeEnvironmentId: ENVIRONMENT_ID, + environmentStateById: { + [ENVIRONMENT_ID]: { + projectIds: [projectId], + projectById: { + [projectId]: { + id: projectId, + environmentId: ENVIRONMENT_ID, + kind: "workspace", + name: "Threadlines", + cwd: CWD, + }, + }, + threadIds: [threadId], + threadShellById: { + [threadId]: { + id: threadId, + environmentId: ENVIRONMENT_ID, + projectId, + title: "Live work", + worktreePath, + branch: null, + effectiveCwd: null, + modelSelection: { instanceId: "codex", model: "gpt-5.3-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + }, + }, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + }, + }, + } as never); +} + +function resetSeededThreads(): void { + useStore.setState({ activeEnvironmentId: null, environmentStateById: {} } as never); +} + function createTestRouter(children: ReactNode) { const rootRoute = createRootRoute({ component: () => children, @@ -327,6 +398,7 @@ async function renderPanel( readonly environmentApi?: EnvironmentApi; readonly registerEnvironmentApi?: boolean; readonly target?: SourceControlProjectTarget; + readonly activeThreadRef?: ScopedThreadRef; readonly onActiveBranchChange?: (branch: string | null, worktreePath: string | null) => void; readonly onOpenDiff?: (filePath?: string) => void; } = {}, @@ -354,7 +426,7 @@ async function renderPanel( { await mounted.cleanup(); } }); + + it("cleans up only the worktrees ticked in the cleanup dialog", async () => { + const safePath = "/repo/.worktrees/feature-safe"; + const riskyPath = "/repo/.worktrees/feature-risky"; + const livePath = "/repo/.worktrees/feature-live"; + const listWorktrees = vi.fn(async () => ({ + worktrees: [ + { + path: CWD, + refName: "main", + isRoot: true, + dirty: false, + unmergedCommitCount: null, + unrelatedHistory: false, + }, + { + path: safePath, + refName: "feature/safe", + isRoot: false, + dirty: false, + unmergedCommitCount: 0, + unrelatedHistory: false, + }, + { + path: riskyPath, + refName: "feature/risky", + isRoot: false, + dirty: true, + unmergedCommitCount: 2, + unrelatedHistory: false, + }, + { + path: livePath, + refName: "feature/live", + isRoot: false, + dirty: false, + unmergedCommitCount: 0, + unrelatedHistory: false, + }, + ], + })); + const removeWorktree = vi.fn(async () => undefined); + const onActiveBranchChange = vi.fn(); + seedWorktreeThread(livePath); + const mounted = await renderPanel({ + activeThreadRef: scopeThreadRef(ENVIRONMENT_ID, ThreadId.make("source-control-live-thread")), + environmentApi: makeEnvironmentApi({ + vcs: { listWorktrees, removeWorktree } as unknown as Partial, + }), + onActiveBranchChange, + }); + + const openCleanupDialog = async () => { + await page.getByRole("button", { name: "Branch: main" }).click(); + const cleanupItem = page.getByRole("menuitem", { name: /Clean up worktrees/ }); + await expect.element(cleanupItem).toBeVisible(); + // Root excluded, the live checkout is in use, so two are cleanable. + await expect.element(cleanupItem).toHaveTextContent("2 unused"); + await cleanupItem.click(); + }; + + try { + await expect.element(page.getByRole("button", { name: "Branch: main" })).toBeVisible(); + await openCleanupDialog(); + + // Inspecting a checkout before deciding moves the thread into it, which + // is the same switch the composer's branch picker performs. + await page.getByRole("button", { name: "Switch checkout to feature-risky" }).click(); + await vi.waitFor(() => { + expect(onActiveBranchChange).toHaveBeenCalledWith("feature/risky", riskyPath); + }); + + await openCleanupDialog(); + await expect.element(page.getByText("Clean up worktrees")).toBeVisible(); + await expect + .element(page.getByText("Threadlines has 2 worktrees nothing is using.")) + .toBeVisible(); + await expect + .element(page.getByText("uncommitted changes, 2 commits not on main")) + .toBeVisible(); + await expect.element(page.getByText("in use")).toBeVisible(); + + // Only the risk-free checkout starts ticked; Select all pulls in the + // risky one too and Select none clears both. + await expect.element(page.getByRole("button", { name: "Delete 1 worktree" })).toBeVisible(); + await page.getByRole("button", { name: "Select all 2" }).click(); + await expect.element(page.getByRole("button", { name: "Delete 2 worktrees" })).toBeVisible(); + await page.getByRole("button", { name: "Select none" }).click(); + await expect.element(page.getByRole("button", { name: "Delete 0 worktrees" })).toBeDisabled(); + await page.getByText("feature-safe", { exact: false }).click(); + + const confirm = page.getByRole("button", { name: "Delete 1 worktree" }); + await expect.element(confirm).toBeVisible(); + await confirm.click(); + + await vi.waitFor(() => { + expect(removeWorktree).toHaveBeenCalledTimes(1); + }); + expect(removeWorktree).toHaveBeenCalledWith({ cwd: CWD, path: safePath, force: true }); + await expect.element(page.getByRole("button", { name: "Close" })).toBeVisible(); + } finally { + await mounted.cleanup(); + resetSeededThreads(); + } + }); }); describe("SourceControlPanel commit graph", () => { diff --git a/apps/web/src/components/source-control/SourceControlPanel.tsx b/apps/web/src/components/source-control/SourceControlPanel.tsx index 221ea7569..83708bee4 100644 --- a/apps/web/src/components/source-control/SourceControlPanel.tsx +++ b/apps/web/src/components/source-control/SourceControlPanel.tsx @@ -69,6 +69,7 @@ import { useState, } from "react"; import * as Schema from "effect/Schema"; +import { useShallow } from "zustand/react/shallow"; import { openInPreferredEditor } from "~/editorPreferences"; import { openFileInActiveViewer } from "~/fileViewerStore"; @@ -93,8 +94,10 @@ import { gitRunStackedActionMutationOptions, gitStartProviderReviewMutationOptions, gitStashesQueryOptions, + invalidateGitQueries, gitStageChangesMutationOptions, gitUnstageChangesMutationOptions, + vcsListWorktreesQueryOptions, } from "~/lib/gitReactQuery"; import { GIT_STATUS_STALE_MESSAGE, @@ -117,11 +120,20 @@ import { import { getAppModelOptionsForInstance } from "~/modelSelection"; import { useServerProviders } from "~/rpc/serverState"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; -import { useStore } from "~/store"; +import { selectThreadsForEnvironment, useStore } from "~/store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "~/storeSelectors"; import { buildThreadRouteParams } from "~/threadRoutes"; import { resolvePathLinkTarget } from "~/terminal-links"; -import { getVcsRefBadge } from "~/worktreeCleanup"; +import { + classifyWorktreesForCleanup, + describeWorktreeRisks, + formatWorktreePathForDisplay, + getVcsRefBadge, + isWorktreeSafeToDelete, + summarizeWorktreeSelection, + type WorktreeCleanupRow, +} from "~/worktreeCleanup"; +import { useArchivedThreadSnapshots } from "~/lib/archivedThreadsState"; import { PublishRepositoryDialog } from "../GitActionsControl"; import { GitAuthRemediationDialog } from "./GitAuthRemediationDialog"; import { ProviderReviewDialog } from "./ProviderReviewDialog"; @@ -174,6 +186,7 @@ import { MenuTrigger, } from "../ui/menu"; import { Skeleton } from "../ui/skeleton"; +import { Spinner } from "../ui/spinner"; import { Textarea } from "../ui/textarea"; import { SectionLabel } from "../ui/threadline"; import { stackedThreadToast, toastManager, type ThreadToastData } from "../ui/toast"; @@ -1490,6 +1503,299 @@ function getBranchActionDisabledReason(input: { return null; } +/** + * The branch menu's entry point into worktree cleanup. + * + * Mounted only while the branch menu is open: the worktree list and the + * archived thread snapshot are both fetched on demand, and neither is worth + * polling for a menu the user opens for a second at a time. The rows travel up + * with the click so the dialog can outlive the menu that launched it. + */ +function BranchMenuCleanupItem({ + target, + onOpenCleanup, +}: { + readonly target: SourceControlProjectTarget; + readonly onOpenCleanup: (rows: readonly WorktreeCleanupRow[]) => void; +}) { + const worktreesQuery = useQuery( + vcsListWorktreesQueryOptions({ + environmentId: target.environmentId, + cwd: target.cwd, + }), + ); + const environmentIds = useMemo(() => [target.environmentId], [target.environmentId]); + const { snapshots, isLoading: archivedLoading } = useArchivedThreadSnapshots(environmentIds); + const liveThreads = useStore( + useShallow((state) => selectThreadsForEnvironment(state, target.environmentId)), + ); + const archivedThreads = useMemo( + () => snapshots.flatMap((entry) => entry.snapshot.threads), + [snapshots], + ); + const rows = useMemo( + () => + classifyWorktreesForCleanup({ + worktrees: worktreesQuery.data?.worktrees ?? [], + liveThreads, + archivedThreads, + }), + [archivedThreads, liveThreads, worktreesQuery.data?.worktrees], + ); + const cleanableCount = rows.filter((row) => row.state !== "in-use").length; + // The archived snapshot decides which rows read as "archived", so opening + // before it lands would pre-check a worktree an archived thread still wants. + // A background revalidation with snapshots already in hand does not count. + const isLoading = worktreesQuery.isPending || (archivedLoading && snapshots.length === 0); + + return ( + onOpenCleanup(rows)}> + + Clean up worktrees... + {isLoading ? ( + + ) : cleanableCount > 0 ? ( + + {cleanableCount} unused + + ) : null} + + ); +} + +type WorktreeCleanupProgress = + | { readonly status: "deleting" } + | { readonly status: "deleted" } + | { readonly status: "failed"; readonly message: string }; + +/** + * Batch cleanup for a project's spare checkouts. + * + * Deletions run one at a time and report in place, so a repository with two + * dozen worktrees costs the user a single decision and still shows an outcome + * per row. Mounted fresh per open, which is what resets the ticks and the + * progress. + */ +function WorktreeCleanupDialogBody({ + rows, + projectName, + projectCwd, + environmentId, + defaultBranchName, + onDone, + onSwitchCheckout, +}: { + readonly rows: readonly WorktreeCleanupRow[]; + readonly projectName: string; + readonly projectCwd: string; + readonly environmentId: EnvironmentId; + readonly defaultBranchName: string | null; + readonly onDone: () => void; + /** Omitted when no thread is open: there is nothing to move. */ + readonly onSwitchCheckout?: ((row: WorktreeCleanupRow) => void) | undefined; +}) { + const queryClient = useQueryClient(); + const cleanableRows = useMemo(() => rows.filter((row) => row.state !== "in-use"), [rows]); + const inUseRows = useMemo(() => rows.filter((row) => row.state === "in-use"), [rows]); + const [selectedPaths, setSelectedPaths] = useState>( + () => new Set(cleanableRows.filter(isWorktreeSafeToDelete).map((row) => row.path)), + ); + const [progress, setProgress] = useState>( + () => new Map(), + ); + const [isRunning, setIsRunning] = useState(false); + const [hasRun, setHasRun] = useState(false); + const selection = summarizeWorktreeSelection(cleanableRows, selectedPaths); + const locked = isRunning || hasRun; + const allSelected = cleanableRows.length > 0 && selection.count === cleanableRows.length; + const showSelectAll = cleanableRows.length > 1; + + const toggleRow = (path: string, checked: boolean) => { + setSelectedPaths((current) => { + const next = new Set(current); + if (checked) { + next.add(path); + } else { + next.delete(path); + } + return next; + }); + }; + + const runCleanup = async () => { + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + const targets = cleanableRows.filter((row) => selectedPaths.has(row.path)); + setIsRunning(true); + for (const row of targets) { + setProgress((current) => new Map(current).set(row.path, { status: "deleting" })); + try { + // Run from the project root: git cannot remove the folder it stands in. + await api.vcs.removeWorktree({ cwd: projectCwd, path: row.path, force: true }); + setProgress((current) => new Map(current).set(row.path, { status: "deleted" })); + } catch (error) { + setProgress((current) => + new Map(current).set(row.path, { + status: "failed", + message: toGitActionErrorMessage(error), + }), + ); + } + } + setIsRunning(false); + setHasRun(true); + await invalidateGitQueries(queryClient, { environmentId }); + }; + + const safeCount = cleanableRows.filter(isWorktreeSafeToDelete).length; + // The count on the menu row promises N deletable; explain here why fewer + // start checked: only the rows whose removal provably loses nothing. + const preselectionNote = + cleanableRows.length === 0 + ? "" + : safeCount === 0 + ? " Each one has something at risk, so none are checked yet." + : safeCount < cleanableRows.length + ? ` The ${safeCount} with nothing to lose ${safeCount === 1 ? "is" : "are"} already checked; the rest show what deleting them would cost.` + : ""; + const summary = + cleanableRows.length === 0 + ? "All worktrees are in use." + : `${projectName} has ${cleanableRows.length} worktree${ + cleanableRows.length === 1 ? "" : "s" + } nothing is using.${preselectionNote}`; + + return ( + <> + + Clean up worktrees + {summary} + + {showSelectAll ? ( +
+ +
+ ) : null} + {/* Sides match the header's p-6; the popup itself is unpadded. */} +
+ {cleanableRows.map((row) => { + const risks = describeWorktreeRisks(row, defaultBranchName); + const rowProgress = progress.get(row.path); + return ( +
+ {/* The label stops short of the switch button so clicking that + button never doubles as a tick. */} + + {rowProgress ? ( + + {rowProgress.status === "deleting" + ? "deleting" + : rowProgress.status === "deleted" + ? "deleted" + : "failed"} + + ) : null} + {onSwitchCheckout && row.refName ? ( + + ) : null} +
+ ); + })} + {inUseRows.map((row) => ( +
+ {formatWorktreePathForDisplay(row.path)} + {row.refName ? ( + {row.refName} + ) : null} + in use +
+ ))} +
+ + {hasRun ? ( + + ) : ( + <> + }> + Cancel + + + + )} + + + ); +} + function SourceControlBranchMenu({ target, activeThreadRef, @@ -1520,6 +1826,10 @@ function SourceControlBranchMenu({ const [pendingMergeRef, setPendingMergeRef] = useState(null); const [createBranchOpen, setCreateBranchOpen] = useState(false); const [createBranchName, setCreateBranchName] = useState(""); + const [menuOpen, setMenuOpen] = useState(false); + // Held in the panel, not the menu: opening the dialog closes the menu, which + // would unmount a dialog rendered inside it. + const [cleanupRows, setCleanupRows] = useState(null); const branchSearch = useInfiniteQuery( gitBranchSearchInfiniteQueryOptions({ environmentId: target.environmentId, @@ -1562,6 +1872,7 @@ function SourceControlBranchMenu({ [branchSearch.data?.pages], ); const currentBranch = status?.refName ?? refs.find((ref) => ref.current)?.name ?? null; + const defaultBranchName = refs.find((ref) => ref.isDefault && !ref.isRemote)?.name ?? null; const switchRefs = refs.slice(0, BRANCH_MENU_REF_LIMIT); const mergeRefs = refs .filter((ref) => ref.name !== currentBranch && !isRefOnCurrentBranch(ref.name, currentBranch)) @@ -1617,6 +1928,45 @@ function SourceControlBranchMenu({ ], ); + /** + * Points the thread at a checkout. Nothing runs in git: the thread records + * where its next turn belongs, and the server cycles the runtime there when + * that turn is dispatched. A pick that leaves the live session in a different + * checkout is queued, not applied, and the composer chip saying so is easy to + * miss, so it is announced here too. + */ + const applyCheckoutSwitch = useCallback( + (branch: string | null, nextWorktreePath: string | null) => { + syncActiveThreadBranch(branch, nextWorktreePath); + const queued = queuedCheckoutSwitchToast({ + session: activeThreadSession, + activeProjectCwd: target.projectCwd, + nextWorktreePath, + }); + if (queued) { + toastManager.add(stackedThreadToast({ type: "info", ...queued })); + } + }, + [activeThreadSession, syncActiveThreadBranch, target.projectCwd], + ); + + /** + * Moves the thread into a worktree from the cleanup list so its uncommitted + * files and unshipped commits can be read in the panel before deciding. Same + * checkout switch the composer's picker performs for a branch that already + * has a checkout, which also means the worktree reads as in use afterwards. + */ + const switchCheckoutToWorktree = useCallback( + (row: WorktreeCleanupRow) => { + if (!row.refName) { + return; + } + setCleanupRows(null); + applyCheckoutSwitch(row.refName, row.path); + }, + [applyCheckoutSwitch], + ); + const executeSwitchRef = useCallback( (ref: VcsRef) => { const selectionTarget = resolveBranchSelectionTarget({ @@ -1628,18 +1978,7 @@ function SourceControlBranchMenu({ .mutateAsync({ cwd: selectionTarget.checkoutCwd, refName: ref.name }) .then((result) => { const nextBranch = result.refName ?? ref.name; - syncActiveThreadBranch(nextBranch, selectionTarget.nextWorktreePath); - // A pick that leaves the live session in a different checkout is - // queued, not applied; the composer chip is easy to miss, so say it - // out loud here too. - const queued = queuedCheckoutSwitchToast({ - session: activeThreadSession, - activeProjectCwd: target.projectCwd, - nextWorktreePath: selectionTarget.nextWorktreePath, - }); - if (queued) { - toastManager.add(stackedThreadToast({ type: "info", ...queued })); - } + applyCheckoutSwitch(nextBranch, selectionTarget.nextWorktreePath); return nextBranch; }); void toastManager.promise(promise, { @@ -1655,14 +1994,7 @@ function SourceControlBranchMenu({ }); void promise.then(refreshPanel, () => undefined); }, - [ - activeThreadSession, - checkoutMutation, - refreshPanel, - syncActiveThreadBranch, - target.projectCwd, - target.worktreePath, - ], + [applyCheckoutSwitch, checkoutMutation, refreshPanel, target.projectCwd, target.worktreePath], ); const runSwitchRef = useCallback( @@ -1761,7 +2093,7 @@ function SourceControlBranchMenu({ return ( <>
- + + {menuOpen ? ( + + ) : null}
+ { + if (!open) { + setCleanupRows(null); + } + }} + > + + {cleanupRows ? ( + setCleanupRows(null)} + onSwitchCheckout={activeThreadRef ? switchCheckoutToWorktree : undefined} + projectCwd={target.projectCwd} + projectName={target.name} + rows={cleanupRows} + /> + ) : null} + + + { diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 6a16af5f8..ca4f39bda 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -9,9 +9,13 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; import { + describeWorktreeRisks, formatWorktreePathForDisplay, getOrphanedWorktreePathForThread, getVcsRefBadge, + isWorktreeSafeToDelete, + summarizeWorktreeSelection, + type WorktreeCleanupRow, } from "./worktreeCleanup"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -127,6 +131,85 @@ describe("getOrphanedWorktreePathForThread", () => { }); }); +describe("worktree cleanup selection", () => { + function makeRow(overrides: Partial = {}): WorktreeCleanupRow { + return { + path: "/repo/.worktrees/feature", + refName: "feature", + dirty: false, + unmergedCommitCount: 0, + unrelatedHistory: false, + state: "unused", + archivedThreadTitles: [], + ...overrides, + }; + } + + // Only these get pre-checked, so the bar for "safe" has to stay strict. + it("treats a row as safe only when nothing at all would be lost", () => { + expect(isWorktreeSafeToDelete(makeRow())).toBe(true); + expect(isWorktreeSafeToDelete(makeRow({ dirty: true }))).toBe(false); + expect(isWorktreeSafeToDelete(makeRow({ unmergedCommitCount: 2 }))).toBe(false); + expect( + isWorktreeSafeToDelete(makeRow({ state: "archived", archivedThreadTitles: ["Old work"] })), + ).toBe(false); + expect(isWorktreeSafeToDelete(makeRow({ state: "in-use" }))).toBe(false); + expect(isWorktreeSafeToDelete(makeRow({ unrelatedHistory: true }))).toBe(false); + // An unknown count is not a promise that the branch is merged. + expect(isWorktreeSafeToDelete(makeRow({ unmergedCommitCount: null }))).toBe(false); + }); + + it("names every risk on the row", () => { + expect(describeWorktreeRisks(makeRow(), "main")).toEqual([]); + expect( + describeWorktreeRisks( + makeRow({ + dirty: true, + unmergedCommitCount: 1, + state: "archived", + archivedThreadTitles: ["Old work"], + }), + "main", + ), + ).toEqual(["uncommitted changes", "1 commit not on main", "archived thread points here"]); + expect(describeWorktreeRisks(makeRow({ unmergedCommitCount: 3 }), null)).toEqual([ + "3 commits not on the default branch", + ]); + expect( + describeWorktreeRisks(makeRow({ refName: null, unmergedCommitCount: null }), "main"), + ).toEqual(["detached checkout"]); + }); + + // Counting against a base the branch never touched reports its whole history + // as unshipped work, which is how "1731 commits not on main" happened. + it("says the histories are unrelated instead of counting them", () => { + expect( + describeWorktreeRisks(makeRow({ unrelatedHistory: true, unmergedCommitCount: null }), "main"), + ).toEqual(["no shared history with main"]); + expect( + describeWorktreeRisks(makeRow({ unrelatedHistory: true, unmergedCommitCount: null }), null), + ).toEqual(["no shared history with the default branch"]); + }); + + it("counts the ticked rows and flags when any of them is risky", () => { + const safe = makeRow({ path: "/repo/.worktrees/safe" }); + const risky = makeRow({ path: "/repo/.worktrees/risky", dirty: true }); + + expect(summarizeWorktreeSelection([safe, risky], new Set(["/repo/.worktrees/safe"]))).toEqual({ + count: 1, + hasRisky: false, + }); + expect(summarizeWorktreeSelection([safe, risky], new Set([safe.path, risky.path]))).toEqual({ + count: 2, + hasRisky: true, + }); + expect(summarizeWorktreeSelection([safe, risky], new Set())).toEqual({ + count: 0, + hasRisky: false, + }); + }); +}); + describe("getVcsRefBadge", () => { function makeRef(overrides: Partial = {}): VcsRef { return { diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index a4bfd34dd..ee1dd6901 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -1,4 +1,8 @@ -import type { VcsRef } from "@threadlines/contracts"; +import type { VcsRef, VcsWorktreeStatus } from "@threadlines/contracts"; +import { + findWorktreeBlockingThreads, + type WorktreeUsageThread, +} from "@threadlines/shared/worktreeUsage"; /** Any thread shape that records a checkout: live store threads and archived shells both qualify. */ export interface WorktreeLinkedThread { @@ -50,6 +54,123 @@ export function getOrphanedWorktreePathForThread( return isShared ? null : targetWorktreePath; } +/** + * Why a worktree can or cannot be removed. + * + * `in-use` mirrors the server's removal guard exactly, so the cleanup list + * never offers a deletion the server would refuse. `archived` still points + * somewhere -- an archived thread can bring the checkout back through checkout + * recovery -- but removing it is the user's call. + */ +export type WorktreeCleanupState = "in-use" | "archived" | "unused"; + +export interface WorktreeCleanupRow { + readonly path: string; + readonly refName: string | null; + readonly dirty: boolean; + readonly unmergedCommitCount: number | null; + /** See VcsWorktreeStatus.unrelatedHistory. */ + readonly unrelatedHistory: boolean; + readonly state: WorktreeCleanupState; + /** Titles of the archived threads pointing here, for the confirm dialog. */ + readonly archivedThreadTitles: readonly string[]; +} + +/** The repository's secondary checkouts, tagged with what still points at them. */ +export function classifyWorktreesForCleanup(input: { + readonly worktrees: readonly VcsWorktreeStatus[]; + readonly liveThreads: readonly WorktreeUsageThread[]; + readonly archivedThreads: readonly WorktreeUsageThread[]; +}): readonly WorktreeCleanupRow[] { + return input.worktrees + .filter((worktree) => !worktree.isRoot) + .map((worktree) => { + const live = findWorktreeBlockingThreads({ + worktreePath: worktree.path, + threads: input.liveThreads, + }); + const archived = findWorktreeBlockingThreads({ + worktreePath: worktree.path, + threads: input.archivedThreads, + }); + return { + path: worktree.path, + refName: worktree.refName, + dirty: worktree.dirty, + unmergedCommitCount: worktree.unmergedCommitCount, + unrelatedHistory: worktree.unrelatedHistory, + state: live.length > 0 ? "in-use" : archived.length > 0 ? "archived" : "unused", + archivedThreadTitles: archived.map((thread) => thread.title ?? "Untitled thread"), + } satisfies WorktreeCleanupRow; + }); +} + +/** + * A checkout whose removal provably loses nothing: no uncommitted changes, a + * verified count of zero commits the default branch is missing, and nothing + * pointing at it. These are the rows the cleanup dialog pre-checks; everything + * else the user opts into. A null count means the state could not be read (a + * detached checkout, unrelated histories, or no resolvable default branch) and + * unknown is not safe. Unrelated history is spelled out rather than left to the + * null count, because that is the whole point of the flag. + */ +export function isWorktreeSafeToDelete(row: WorktreeCleanupRow): boolean { + return ( + row.state === "unused" && + !row.dirty && + !row.unrelatedHistory && + row.unmergedCommitCount === 0 && + row.archivedThreadTitles.length === 0 + ); +} + +/** + * What a removal would throw away, phrased for a muted note under the row. + * Empty means the row is safe. + */ +export function describeWorktreeRisks( + row: WorktreeCleanupRow, + defaultBranchName: string | null, +): readonly string[] { + const risks: string[] = []; + if (row.refName === null) { + risks.push("detached checkout"); + } + if (row.dirty) { + risks.push("uncommitted changes"); + } + if (row.unrelatedHistory) { + // Counting here would report the branch's entire history as unshipped work. + risks.push(`no shared history with ${defaultBranchName ?? "the default branch"}`); + } else if (row.unmergedCommitCount !== null && row.unmergedCommitCount > 0) { + risks.push( + `${row.unmergedCommitCount} commit${row.unmergedCommitCount === 1 ? "" : "s"} not on ${ + defaultBranchName ?? "the default branch" + }`, + ); + } + if (row.archivedThreadTitles.length > 0) { + risks.push( + `archived thread${row.archivedThreadTitles.length === 1 ? "" : "s"} point${ + row.archivedThreadTitles.length === 1 ? "s" : "" + } here`, + ); + } + return risks; +} + +/** What the cleanup dialog's confirm button needs to know about the ticked rows. */ +export function summarizeWorktreeSelection( + rows: readonly WorktreeCleanupRow[], + selectedPaths: ReadonlySet, +): { readonly count: number; readonly hasRisky: boolean } { + const selected = rows.filter((row) => selectedPaths.has(row.path)); + return { + count: selected.length, + hasRisky: selected.some((row) => !isWorktreeSafeToDelete(row)), + }; +} + export type VcsRefBadge = "current" | "worktree" | "remote" | "default"; /** diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 83c4877fe..cd75c6739 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -117,9 +117,16 @@ export const VcsWorktreeStatus = Schema.Struct({ /** * Commits on this checkout's branch that the default branch cannot reach. * Null when the repository has no resolvable default branch, when the - * checkout is detached, or when the count could not be read. + * checkout is detached, when the histories are unrelated, or when the count + * could not be read. */ unmergedCommitCount: Schema.NullOr(NonNegativeInt), + /** + * The branch shares no commit at all with the default branch, so a count of + * "unmerged" commits would be the whole of its history. Happens to checkouts + * left over from before a history rewrite. + */ + unrelatedHistory: Schema.Boolean, }); export type VcsWorktreeStatus = typeof VcsWorktreeStatus.Type; const GitResolvedPullRequest = Schema.Struct({