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/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/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/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..7e237e378 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -253,6 +253,79 @@ 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 }, + ); + 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 }, + ); + }), + ); + 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..59e76a013 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3821,6 +3821,129 @@ 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. + * + * 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<{ readonly count: number | null; readonly unrelatedHistory: boolean }> => + Effect.gen(function* () { + if (branch === null) { + return { count: null, unrelatedHistory: false }; + } + const baseRef = yield* resolveBaseBranchForNoUpstream(cwd, branch); + if (!baseRef) { + 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", + cwd, + ["rev-list", "--count", `${baseRef}..${branch}`], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + if (result.exitCode !== 0) { + return { count: null, unrelatedHistory: false }; + } + const parsed = Number.parseInt(result.stdout.trim(), 10); + 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", + )(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; + const unmerged = yield* readUnmergedCommits(entry.path, entry.branch); + worktrees.push({ + path: entry.path, + refName: entry.branch, + isRoot, + dirty: yield* readWorktreeDirty(entry.path), + unmergedCommitCount: unmerged.count, + unrelatedHistory: unmerged.unrelatedHistory, + }); + } + return { worktrees }; + }); + const readListRefsRepositoryContext = Effect.fn("readListRefsRepositoryContext")(function* ( cwd: string, ) { @@ -4757,6 +4880,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* readConfigValue, listRefs, listWorktrees, + listWorktreeStatuses, commitGraph, commitDetails, workingTreeDiff, 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/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/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 ( } = {}, + 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 f70d5070b..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,10 +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 { + 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"; @@ -173,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"; @@ -1489,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, @@ -1519,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, @@ -1561,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)) @@ -1616,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({ @@ -1627,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, { @@ -1654,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( @@ -1760,7 +2093,7 @@ function SourceControlBranchMenu({ return ( <>
- + {ref.name} - {ref.current - ? "current" - : ref.isRemote - ? "remote" - : ref.isDefault - ? "default" - : ""} + {getVcsRefBadge(ref, target.projectCwd) ?? ""} )) @@ -1855,10 +2182,36 @@ function SourceControlBranchMenu({ )} + {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/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/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/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/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index b574f5901..ca4f39bda 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -1,8 +1,22 @@ -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 { + describeWorktreeRisks, + formatWorktreePathForDisplay, + getOrphanedWorktreePathForThread, + getVcsRefBadge, + isWorktreeSafeToDelete, + summarizeWorktreeSelection, + type WorktreeCleanupRow, +} from "./worktreeCleanup"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -72,6 +86,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({ @@ -88,6 +131,120 @@ 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 { + 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..ee1dd6901 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -1,4 +1,14 @@ -import type { Thread } from "./types"; +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 { + readonly id: string; + readonly worktreePath: string | null; +} function normalizeWorktreePath(path: string | null): string | null { const trimmed = path?.trim(); @@ -8,11 +18,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; } @@ -22,7 +44,7 @@ export function getOrphanedWorktreePathForThread( return null; } - const isShared = threads.some((thread) => { + const isShared = [...threads, ...archivedThreads].some((thread) => { if (thread.id === threadId) { return false; } @@ -32,6 +54,148 @@ 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"; + +/** + * 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) { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 084cc339d..cd75c6739 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -101,6 +101,34 @@ 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, 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({ number: PositiveInt, title: TrimmedNonEmptyStringSchema, @@ -321,6 +349,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 +570,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, 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; +}