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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/app/e2e/regression/settings.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test"
import type { ConfigEntry, OpenCodeEvent, SessionMessageInfo, WorktreeDirectory } from "@opencode/client/promise"
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, seed, session } from "../utils/app"
import { MockWorktreeError } from "../utils/mock-api"
import { mockMissingLocation, mockOpenCodeServer } from "../utils/mock-server"
import { mockRemoteServer, mockWorkspace, openSession, openSettings, type WorkspaceInput } from "../utils/workspace"

Expand Down Expand Up @@ -864,6 +865,69 @@ test("worktrees follow inventory events, wait for session counts, and delete by
await expect(settings.getByLabel(sandboxes[0]!, { exact: true })).toHaveCount(0)
})

test("bulk delete offers force for worktrees git refuses to remove", async ({ page }) => {
const submodule = `${directory}/submodule-workspace`
const clean = `${directory}/clean-workspace`

const inventory: WorktreeDirectory[] = [
{ directory },
{ directory: submodule, strategy: "git" },
{ directory: clean, strategy: "git" },
]

const view = await open(page, {
project: { sandboxes: [submodule, clean] },
sessions: [],
worktrees: () => inventory,
onWorktreeRemoveFailure: (input) =>
input.directory === submodule && !input.force
? new MockWorktreeError({
name: "WorktreeError",
data: {
message: "fatal: working trees containing submodules cannot be moved or removed",
forceRequired: true,
},
})
: undefined,
onWorktreeRemove: (input) => {
inventory.splice(
inventory.findIndex((item) => item.directory === input.directory),
1,
)
},
})

const settings = view.settings
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await settings.getByRole("button", { name: "More options", exact: true }).click()

const deleteWithoutSessions = page.getByRole("menuitem", {
name: "Delete worktrees without sessions",
exact: true,
})

await expect(deleteWithoutSessions).toBeVisible()
await deleteWithoutSessions.click()

const confirmation = page.getByRole("dialog", { name: "Delete worktrees without sessions", exact: true })
await confirmation.getByRole("button", { name: "Delete worktrees", exact: true }).click()

const force = page.getByRole("dialog", { name: "Force delete worktrees?", exact: true })
await expect(force).toContainText("Git refused to remove 1 worktree without force.")

const forced = page.waitForRequest((request) => {
if (new URL(request.url()).pathname !== "/api/worktree" || request.method() !== "DELETE") return false

const body = request.postDataJSON()

return body.directory === submodule && body.force === true
})

await force.getByRole("button", { name: "Force delete", exact: true }).click()
await forced
await expect(settings.getByLabel(submodule, { exact: true })).toHaveCount(0)
})

test.describe("worktrees prefetch", () => {
const other = project({ id: "proj_other", directory: "/repo/other", name: "Other project" })

Expand Down
15 changes: 14 additions & 1 deletion packages/app/e2e/utils/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ export class MockUnsupported extends Schema.TaggedError<MockUnsupported>()("Mock
message: Schema.String,
}) {}

// The server's error when git refuses to remove a worktree without force.
export class MockWorktreeError extends Schema.TaggedError<MockWorktreeError>()(
"WorktreeError",
{
name: Schema.Literal("WorktreeError"),
data: Schema.Struct({
message: Schema.String,
forceRequired: Schema.optional(Schema.Boolean),
}),
},
{ httpApiStatus: 400 },
) {}

const Unsupported = MockUnsupported.pipe(HttpApiSchema.status(501))

const PtyMissing = [MockNotFound.pipe(HttpApiSchema.status(404)), MockPtyNotFound.pipe(HttpApiSchema.status(404))]
Expand Down Expand Up @@ -197,7 +210,7 @@ const Group = HttpApiGroup.make("mock")
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree", {
payload: Worktree.RemoveInput,
success: NoContent,
error: Unsupported,
error: [Unsupported, MockWorktreeError],
}),
)
.add(
Expand Down
7 changes: 7 additions & 0 deletions packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
MockShellNotFound,
MockUnauthorized,
MockUnsupported,
MockWorktreeError,
} from "./mock-api"
import { installSseTransport } from "./sse-transport"

Expand Down Expand Up @@ -125,6 +126,8 @@ export interface MockServerConfig {
// and return the answer; by default it creates `<directory>/<name>`. A created directory joins the project's sandboxes.
onWorktreeCreate?: (input: Schema.Json) => void | MockAnswer | Promise<void | MockAnswer>
onWorktreeRemove?: (input: Worktree.RemoveInput) => void | Promise<void>
// Return an error to fail the removal before `onWorktreeRemove` runs.
onWorktreeRemoveFailure?: (input: Worktree.RemoveInput) => MockWorktreeError | undefined
// POST /api/session keeps the client-reserved `id` and `location`. Return an answer to fail the attempt (1-based).
onSessionCreate?: (body: Schema.JsonObject, attempt: number) => void | MockAnswer
// Title of created sessions (default: the request's title, else "New session").
Expand Down Expand Up @@ -1021,6 +1024,10 @@ function mockHandlers(

if (!remove) return unsupported("remove worktrees", "onWorktreeRemove")

const failure = config.onWorktreeRemoveFailure?.(ctx.payload)

if (failure) return Effect.fail(failure)

return Effect.promise(async () => remove(ctx.payload)).pipe(Effect.andThen(noContent))
},
// Discovery against a static inventory changes nothing, and the app refreshes whenever worktree settings open.
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,14 @@ export const dict = {
"settings.workspaces.delete.warning": "This permanently deletes the worktree and its branch.",
"settings.workspaces.deleteAll.warning":
"Worktrees with unmerged changes, linked sessions, or active locations will be kept.",
"settings.workspaces.deleteForce.title": "Force delete worktrees?",
"settings.workspaces.deleteForce.confirm.one":
"Git refused to remove {{count}} worktree without force. It may contain initialized submodules or files that changed during deletion.",
"settings.workspaces.deleteForce.confirm.other":
"Git refused to remove {{count}} worktrees without force. They may contain initialized submodules or files that changed during deletion.",
"settings.workspaces.deleteForce.warning":
"Force deletion also removes uncommitted changes and unpushed commits inside those worktrees, including their submodules. This cannot be undone.",
"settings.workspaces.deleteForce.button": "Force delete",
"settings.workspaces.delete.blocked.active": "The active worktree cannot be deleted.",
"settings.workspaces.delete.blocked.linked":
"Linked sessions will remain, but their working directory will no longer exist.",
Expand Down
85 changes: 71 additions & 14 deletions packages/app/src/settings/workspaces/workspaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Key } from "@solid-primitives/keyed"
import type { SessionInfo } from "@opencode/client/promise"
import { isWorktreeError, type SessionInfo } from "@opencode/client/promise"
import { useQuery, useQueryClient } from "@tanstack/solid-query"
import { Button } from "@opencode/ui/button"
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
Expand Down Expand Up @@ -45,6 +45,8 @@ type Workspace = {
project: Project
}

type RemoveResult = "removed" | "blocked" | "forceRequired" | "failed"

export const SettingsWorkspaces: Component<{
activeDirectory?: string
resetProjectFilter?: () => number
Expand Down Expand Up @@ -246,7 +248,11 @@ export const SettingsWorkspaces: Component<{
})
}

const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
const remove = async (
workspace: Workspace,
force = false,
context = captureDeleteContext(),
): Promise<RemoveResult> => {
const key = String(pathKey(workspace.directory))
setStore("deleting", (items) => [...items, key])

Expand All @@ -256,7 +262,7 @@ export const SettingsWorkspaces: Component<{
if (!force && (preflight.result.active || preflight.result.linked || preflight.result.dirty)) {
blocked(preflight.result)

return
return "blocked"
}

const removed = await context.sdk.api.worktree
Expand All @@ -265,18 +271,20 @@ export const SettingsWorkspaces: Component<{
directory: workspace.directory,
force,
})
.then(() => true)
.then(() => "removed" as const)
.catch((error) => {
if (!force && isWorktreeError(error) && error.data.forceRequired) return "forceRequired" as const

showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})

return false
return "failed" as const
})

if (!removed) return
if (removed !== "removed") return removed
setStore("removing", (items) => [...items, key])
await new Promise((resolve) => setTimeout(resolve, 150))
tabs.store.forEach((tab) => {
Expand All @@ -295,6 +303,8 @@ export const SettingsWorkspaces: Component<{
queryKey: worktreeInventoryKey(context.sdk.scope, workspace.project.id),
})
await queryClient.invalidateQueries({ queryKey: [context.sdk.scope, "settings-workspace-inventory"] })

return "removed"
} finally {
setStore("deleting", (items) => items.filter((item) => item !== key))
setStore("removing", (items) => items.filter((item) => item !== key))
Expand All @@ -307,18 +317,20 @@ export const SettingsWorkspaces: Component<{
if (store.transaction === "confirm") setStore("transaction", undefined)
}

const transact = async (task: () => Promise<void>) => {
if (store.transaction !== "confirm") return
const transact = async <T,>(task: () => Promise<T>): Promise<T | undefined> => {
if (store.transaction !== "confirm") return undefined
setStore("transaction", "running")

try {
await task()
return await task()
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})

return undefined
} finally {
setStore("transaction", undefined)
}
Expand All @@ -337,15 +349,59 @@ export const SettingsWorkspaces: Component<{
inspectionID={current}
inspect={() => inspect(workspace, context)}
inspectionMessages={inspectionMessages}
onDelete={() => transact(() => remove(workspace, true, context))}
onDelete={() => transact(() => remove(workspace, true, context)).then(() => undefined)}
/>
),
releaseConfirmation,
)
}

const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
const forceRequired: Workspace[] = []
await removeWorkspacesSequentially(inventory, async (workspace) => {
if ((await remove(workspace, false, context)) === "forceRequired") forceRequired.push(workspace)
})

return forceRequired
}

const forceDelete = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
if (store.transaction) return
setStore("transaction", "running")

try {
await removeWorkspacesSequentially(inventory, async (workspace) => {
await remove(workspace, true, context)
})
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
} finally {
setStore("transaction", undefined)
}
}

const confirmForceDelete = (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
if (store.transaction) return
void dialog.push(() => (
<DialogDeleteWorkspaces
title={language.t("settings.workspaces.deleteForce.title")}
confirmation={language.plural("settings.workspaces.deleteForce.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteForce.warning")}
confirmLabel={language.t("settings.workspaces.deleteForce.button")}
onDelete={() => forceDelete(inventory, context)}
/>
))
}

const removeAllConfirmed = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
const forceRequired = await transact(() => removeAll(inventory, context))

if (!forceRequired?.length) return
confirmForceDelete(forceRequired, context)
}

const confirmDeleteAll = () => {
Expand All @@ -359,7 +415,7 @@ export const SettingsWorkspaces: Component<{
title={language.t("settings.workspaces.deleteAll")}
confirmation={language.plural("settings.workspaces.deleteAll.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteAll.warning")}
onDelete={() => transact(() => removeAll(inventory, context))}
onDelete={() => removeAllConfirmed(inventory, context)}
/>
),
releaseConfirmation,
Expand All @@ -377,7 +433,7 @@ export const SettingsWorkspaces: Component<{
title={language.t("settings.workspaces.deleteWithoutSessions")}
confirmation={language.plural("settings.workspaces.deleteWithoutSessions.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteWithoutSessions.warning")}
onDelete={() => transact(() => removeAll(inventory, context))}
onDelete={() => removeAllConfirmed(inventory, context)}
/>
),
releaseConfirmation,
Expand Down Expand Up @@ -574,6 +630,7 @@ function DialogDeleteWorkspaces(props: {
title: string
confirmation: string
warning: string
confirmLabel?: string
onDelete: () => Promise<void>
}) {
const dialog = useDialog()
Expand Down Expand Up @@ -603,7 +660,7 @@ function DialogDeleteWorkspaces(props: {
{language.t("common.cancel")}
</Button>
<Button type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.delete.button")}
{props.confirmLabel ?? language.t("settings.workspaces.delete.button")}
</Button>
</DialogFooter>
</Dialog>
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1417,7 +1417,15 @@ const layer = Layer.effect(
cwd = repository.worktree,
) {
const result = yield* proc
.run(ChildProcess.make(gitExecutable(), args, { cwd, extendEnv: true, stdin: "ignore" }))
.run(
ChildProcess.make(gitExecutable(), args, {
cwd,
// Git localizes diagnostics, and `git worktree` has no machine-readable output.
env: { LC_ALL: "C" },
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(
Effect.mapError(
(cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
Expand All @@ -1429,7 +1437,9 @@ const layer = Layer.effect(
operation,
directory: worktreeDirectory,
message,
forceRequired: operation === "remove" && /contains modified or untracked files|is dirty/i.test(message),
forceRequired:
operation === "remove" &&
/contains modified or untracked files|is dirty|containing submodules/i.test(message),
})
})

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tool/plugin/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,6 @@ function removeFailure(directory: string, error: unknown) {
if (error instanceof Worktree.StrategyUnavailableError)
return `Unable to remove worktree ${directory}: its worktree strategy ${error.strategy} is not available.`
if ((error instanceof Git.WorktreeError || error instanceof Worktree.OperationError) && error.forceRequired)
return `Unable to remove worktree ${directory}: it has modified or untracked files.`
return `Unable to remove worktree ${directory}: it has modified or untracked files, or initialized submodules. Retry with force: true to delete it anyway.`
return `Unable to remove worktree ${directory}.`
}
Loading
Loading