summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-15 13:33:21 -0600
committerAdam <[email protected]>2026-01-19 07:35:52 -0600
commit093a3e7876bfec4bdfc57f580e37875d6fe9e4cb (patch)
tree1eaa32bdc0aa2a71296c2ac68f07d4fc67cd02b7 /packages
parentf26de6c52f7442762973155c26743d3494fb5887 (diff)
downloadopencode-093a3e7876bfec4bdfc57f580e37875d6fe9e4cb.tar.gz
opencode-093a3e7876bfec4bdfc57f580e37875d6fe9e4cb.zip
feat(app): reset worktree
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/pages/layout.tsx90
-rw-r--r--packages/opencode/src/server/routes/experimental.ts25
-rw-r--r--packages/opencode/src/worktree/index.ts127
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts38
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts31
5 files changed, 311 insertions, 0 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 8d61f0510..e7acd5f89 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -942,6 +942,30 @@ export default function Layout(props: ParentProps) {
}
}
+ const resetWorkspace = async (directory: string) => {
+ const current = currentProject()
+ if (!current) return
+ if (directory === current.worktree) return
+
+ const result = await globalSDK.client.worktree
+ .reset({ directory: current.worktree, worktreeResetInput: { directory } })
+ .then((x) => x.data)
+ .catch((err) => {
+ showToast({
+ title: "Failed to reset workspace",
+ description: errorMessage(err),
+ })
+ return false
+ })
+
+ if (!result) return
+
+ showToast({
+ title: "Workspace reset",
+ description: "Workspace now matches the default branch.",
+ })
+ }
+
function DialogDeleteWorkspace(props: { directory: string }) {
const name = createMemo(() => getFilename(props.directory))
const [data, setData] = createStore({
@@ -1000,6 +1024,66 @@ export default function Layout(props: ParentProps) {
)
}
+ function DialogResetWorkspace(props: { directory: string }) {
+ const name = createMemo(() => getFilename(props.directory))
+ const [data, setData] = createStore({
+ status: "loading" as "loading" | "ready" | "error",
+ dirty: false,
+ })
+
+ onMount(() => {
+ const current = currentProject()
+ if (!current) {
+ setData({ status: "error", dirty: false })
+ return
+ }
+
+ globalSDK.client.file
+ .status({ directory: props.directory })
+ .then((x) => {
+ const files = x.data ?? []
+ const dirty = files.length > 0
+ setData({ status: "ready", dirty })
+ })
+ .catch(() => {
+ setData({ status: "error", dirty: false })
+ })
+ })
+
+ const handleReset = async () => {
+ await resetWorkspace(props.directory)
+ dialog.close()
+ }
+
+ const description = () => {
+ if (data.status === "loading") return "Checking for unmerged changes..."
+ if (data.status === "error") return "Unable to verify git status."
+ if (!data.dirty) return "No unmerged changes detected."
+ return "Unmerged changes detected in this workspace."
+ }
+
+ return (
+ <Dialog title="Reset workspace">
+ <div class="flex flex-col gap-4 px-2.5 pb-3">
+ <div class="flex flex-col gap-1">
+ <span class="text-14-regular text-text-strong">Reset workspace "{name()}"?</span>
+ <span class="text-12-regular text-text-weak">
+ {description()} This will reset the workspace to match the default branch.
+ </span>
+ </div>
+ <div class="flex justify-end gap-2">
+ <Button variant="ghost" size="large" onClick={() => dialog.close()}>
+ Cancel
+ </Button>
+ <Button variant="primary" size="large" disabled={data.status === "loading"} onClick={handleReset}>
+ Reset workspace
+ </Button>
+ </div>
+ </div>
+ </Dialog>
+ )
+ }
+
createEffect(
on(
() => ({ ready: pageReady(), dir: params.dir, id: params.id }),
@@ -1393,6 +1477,12 @@ export default function Layout(props: ParentProps) {
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={local()}
+ onSelect={() => dialog.show(() => <DialogResetWorkspace directory={props.directory} />)}
+ >
+ <DropdownMenu.ItemLabel>Reset workspace</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item
+ disabled={local()}
onSelect={() => dialog.show(() => <DialogDeleteWorkspace directory={props.directory} />)}
>
<DropdownMenu.ItemLabel>Delete workspace</DropdownMenu.ItemLabel>
diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts
index d300d3bf8..dc5f4f7ab 100644
--- a/packages/opencode/src/server/routes/experimental.ts
+++ b/packages/opencode/src/server/routes/experimental.ts
@@ -159,6 +159,31 @@ export const ExperimentalRoutes = lazy(() =>
return c.json(true)
},
)
+ .post(
+ "/worktree/reset",
+ describeRoute({
+ summary: "Reset worktree",
+ description: "Reset a worktree branch to the primary default branch.",
+ operationId: "worktree.reset",
+ responses: {
+ 200: {
+ description: "Worktree reset",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator("json", Worktree.reset.schema),
+ async (c) => {
+ const body = c.req.valid("json")
+ await Worktree.reset(body)
+ return c.json(true)
+ },
+ )
.get(
"/resource",
describeRoute({
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index be10fd83d..aa55355e0 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -43,6 +43,16 @@ export namespace Worktree {
export type RemoveInput = z.infer<typeof RemoveInput>
+ export const ResetInput = z
+ .object({
+ directory: z.string(),
+ })
+ .meta({
+ ref: "WorktreeResetInput",
+ })
+
+ export type ResetInput = z.infer<typeof ResetInput>
+
export const NotGitError = NamedError.create(
"WorktreeNotGitError",
z.object({
@@ -78,6 +88,13 @@ export namespace Worktree {
}),
)
+ export const ResetFailedError = NamedError.create(
+ "WorktreeResetFailedError",
+ z.object({
+ message: z.string(),
+ }),
+ )
+
const ADJECTIVES = [
"brave",
"calm",
@@ -280,4 +297,114 @@ export namespace Worktree {
return true
})
+
+ export const reset = fn(ResetInput, async (input) => {
+ if (Instance.project.vcs !== "git") {
+ throw new NotGitError({ message: "Worktrees are only supported for git projects" })
+ }
+
+ const directory = path.resolve(input.directory)
+ if (directory === path.resolve(Instance.worktree)) {
+ throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
+ }
+
+ const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
+ if (list.exitCode !== 0) {
+ throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
+ }
+
+ const lines = outputText(list.stdout)
+ .split("\n")
+ .map((line) => line.trim())
+ const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
+ if (!line) return acc
+ if (line.startsWith("worktree ")) {
+ acc.push({ path: line.slice("worktree ".length).trim() })
+ return acc
+ }
+ const current = acc[acc.length - 1]
+ if (!current) return acc
+ if (line.startsWith("branch ")) {
+ current.branch = line.slice("branch ".length).trim()
+ }
+ return acc
+ }, [])
+
+ const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
+ if (!entry?.path) {
+ throw new ResetFailedError({ message: "Worktree not found" })
+ }
+
+ const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
+ if (remoteList.exitCode !== 0) {
+ throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
+ }
+
+ const remotes = outputText(remoteList.stdout)
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+
+ const remote = remotes.includes("origin")
+ ? "origin"
+ : remotes.length === 1
+ ? remotes[0]
+ : remotes.includes("upstream")
+ ? "upstream"
+ : ""
+
+ const remoteHead = remote
+ ? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
+ : { exitCode: 1, stdout: undefined, stderr: undefined }
+
+ const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
+ const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
+ const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
+
+ const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
+ const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
+ .quiet()
+ .nothrow()
+ .cwd(Instance.worktree)
+ const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
+
+ const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
+ if (!target) {
+ throw new ResetFailedError({ message: "Default branch not found" })
+ }
+
+ if (remoteBranch) {
+ const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
+ if (fetch.exitCode !== 0) {
+ throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
+ }
+ }
+
+ const checkout = await $`git checkout ${target}`.quiet().nothrow().cwd(entry.path)
+ if (checkout.exitCode !== 0) {
+ throw new ResetFailedError({ message: errorText(checkout) || `Failed to checkout ${target}` })
+ }
+
+ const worktreeBranch = entry.branch?.replace(/^refs\/heads\//, "")
+ if (!worktreeBranch) {
+ throw new ResetFailedError({ message: "Worktree branch not found" })
+ }
+
+ const reset = await $`git reset --hard ${target}`.quiet().nothrow().cwd(entry.path)
+ if (reset.exitCode !== 0) {
+ throw new ResetFailedError({ message: errorText(reset) || "Failed to reset worktree" })
+ }
+
+ const branchReset = await $`git branch -f ${worktreeBranch} ${target}`.quiet().nothrow().cwd(entry.path)
+ if (branchReset.exitCode !== 0) {
+ throw new ResetFailedError({ message: errorText(branchReset) || "Failed to update worktree branch" })
+ }
+
+ const checkoutBranch = await $`git checkout ${worktreeBranch}`.quiet().nothrow().cwd(entry.path)
+ if (checkoutBranch.exitCode !== 0) {
+ throw new ResetFailedError({ message: errorText(checkoutBranch) || "Failed to checkout worktree branch" })
+ }
+
+ return true
+ })
}
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index ba299f81f..59b7f0696 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -165,6 +165,9 @@ import type {
WorktreeRemoveErrors,
WorktreeRemoveInput,
WorktreeRemoveResponses,
+ WorktreeResetErrors,
+ WorktreeResetInput,
+ WorktreeResetResponses,
} from "./types.gen.js"
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<
@@ -745,6 +748,41 @@ export class Worktree extends HeyApiClient {
},
})
}
+
+ /**
+ * Reset worktree
+ *
+ * Reset a worktree branch to the primary default branch.
+ */
+ public reset<ThrowOnError extends boolean = false>(
+ parameters?: {
+ directory?: string
+ worktreeResetInput?: WorktreeResetInput
+ },
+ options?: Options<never, ThrowOnError>,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { key: "worktreeResetInput", map: "body" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<WorktreeResetResponses, WorktreeResetErrors, ThrowOnError>({
+ url: "/experimental/worktree/reset",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
}
export class Resource extends HeyApiClient {
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 58d3c3ae2..75540f907 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1912,6 +1912,10 @@ export type WorktreeRemoveInput = {
directory: string
}
+export type WorktreeResetInput = {
+ directory: string
+}
+
export type McpResource = {
name: string
uri: string
@@ -2630,6 +2634,33 @@ export type WorktreeCreateResponses = {
export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses]
+export type WorktreeResetData = {
+ body?: WorktreeResetInput
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/experimental/worktree/reset"
+}
+
+export type WorktreeResetErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors]
+
+export type WorktreeResetResponses = {
+ /**
+ * Worktree reset
+ */
+ 200: boolean
+}
+
+export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses]
+
export type ExperimentalResourceListData = {
body?: never
path?: never