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 ( + + + + Reset workspace "{name()}"? + + {description()} This will reset the workspace to match the default branch. + + + + dialog.close()}> + Cancel + + + Reset workspace + + + + + ) + } + createEffect( on( () => ({ ready: pageReady(), dir: params.dir, id: params.id }), @@ -1391,6 +1475,12 @@ export default function Layout(props: ParentProps) { navigate(`/${slug()}/session`)}> New session + dialog.show(() => )} + > + Reset workspace + dialog.show(() => )} 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 + export const ResetInput = z + .object({ + directory: z.string(), + }) + .meta({ + ref: "WorktreeResetInput", + }) + + export type ResetInput = z.infer + 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 = Options2< @@ -745,6 +748,41 @@ export class Worktree extends HeyApiClient { }, }) } + + /** + * Reset worktree + * + * Reset a worktree branch to the primary default branch. + */ + public reset( + parameters?: { + directory?: string + worktreeResetInput?: WorktreeResetInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { key: "worktreeResetInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + 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