Adam 2 месяцев назад
Родитель
Сommit
093a3e7876

+ 90 - 0
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 }),
@@ -1391,6 +1475,12 @@ export default function Layout(props: ParentProps) {
                         <DropdownMenu.Item onSelect={() => navigate(`/${slug()}/session`)}>
                           <DropdownMenu.ItemLabel>New session</DropdownMenu.ItemLabel>
                         </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} />)}

+ 25 - 0
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({

+ 127 - 0
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
+  })
 }

+ 38 - 0
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 {

+ 31 - 0
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