Przeglądaj źródła

feat(core): copy file changes when warping (#26190)

James Long 3 miesięcy temu
rodzic
commit
3c4b4d5faf
23 zmienionych plików z 955 dodań i 28 usunięć
  1. 7 1
      packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
  2. 37 2
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
  3. 138 0
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx
  4. 11 1
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  5. 99 1
      packages/opencode/src/control-plane/workspace.ts
  6. 15 2
      packages/opencode/src/git/index.ts
  7. 84 1
      packages/opencode/src/project/vcs.ts
  8. 31 2
      packages/opencode/src/server/routes/control/workspace.ts
  9. 47 1
      packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts
  10. 13 1
      packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts
  11. 27 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts
  12. 23 2
      packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts
  13. 97 1
      packages/opencode/src/server/routes/instance/index.ts
  14. 5 0
      packages/opencode/src/util/locale.ts
  15. 25 0
      packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts
  16. 54 4
      packages/opencode/test/control-plane/workspace.test.ts
  17. 7 1
      packages/opencode/test/plugin/workspace-adapter.test.ts
  18. 8 1
      packages/opencode/test/server/httpapi-instance-context.test.ts
  19. 8 2
      packages/opencode/test/server/httpapi-session.test.ts
  20. 8 1
      packages/opencode/test/server/httpapi-workspace-routing.test.ts
  21. 6 2
      packages/opencode/test/server/httpapi-workspace.test.ts
  22. 110 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  23. 95 2
      packages/sdk/js/src/v2/gen/types.gen.ts

+ 7 - 1
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -12,7 +12,11 @@ import { Flag } from "@opencode-ai/core/flag/flag"
 import { DialogSessionRename } from "./dialog-session-rename"
 import { createDebouncedSignal } from "../util/signal"
 import { useToast } from "../ui/toast"
-import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
+import {
+  openWorkspaceSelect,
+  type WorkspaceSelection,
+  warpWorkspaceSession,
+} from "./dialog-workspace-create"
 import { Spinner } from "./spinner"
 import { errorMessage } from "@/util/error"
 import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
@@ -70,8 +74,10 @@ export function DialogSessionList() {
         sync,
         project,
         toast,
+        sourceWorkspaceID: session.workspaceID,
         workspaceID,
         sessionID: session.id,
+        copyChanges: false,
         done: list,
       })
     }

+ 37 - 2
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx

@@ -3,10 +3,13 @@ import { useDialog } from "@tui/ui/dialog"
 import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
 import { useSync } from "@tui/context/sync"
 import { useProject } from "@tui/context/project"
+import { useRoute } from "@tui/context/route"
 import { createMemo, createSignal, onMount } from "solid-js"
 import { errorMessage } from "@/util/error"
 import { useSDK } from "../context/sdk"
 import { useToast } from "../ui/toast"
+import { DialogAlert } from "../ui/dialog-alert"
+import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
 
 type Adapter = {
   type: string
@@ -38,6 +41,7 @@ export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string }>(
   get: (workspaceID: string) => WorkspaceInfo | undefined
   status: (workspaceID: string) => string | undefined
   limit?: number
+  omitWorkspaceID?: string
 }) {
   const workspaces = input.sessions
     .toSorted((a, b) => b.time.updated - a.time.updated)
@@ -45,6 +49,7 @@ export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string }>(
       const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined
       return workspace && input.status(workspace.id) === "connected" ? [workspace] : []
     })
+    .filter((workspace) => workspace.id !== input.omitWorkspaceID)
     .filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index)
   const recent = workspaces.slice(0, input.limit ?? 3)
 
@@ -93,17 +98,29 @@ export async function warpWorkspaceSession(input: {
   sync: ReturnType<typeof useSync>
   project: ReturnType<typeof useProject>
   toast: ReturnType<typeof useToast>
+  sourceWorkspaceID?: string
   workspaceID: string | null
   sessionID: string
+  copyChanges: boolean
   done?: () => void
 }): Promise<boolean> {
   const result = await input.sdk.client.experimental.workspace
     .warp({
       id: input.workspaceID,
       sessionID: input.sessionID,
+      copyChanges: input.copyChanges,
     })
     .catch(() => undefined)
   if (!result?.data) {
+    if (result?.error?.name === "VcsApplyError") {
+      await DialogAlert.show(
+        input.dialog,
+        "Unable to Warp Session",
+        "Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.",
+      )
+      return false
+    }
+
     input.toast.show({
       message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
       variant: "error",
@@ -143,16 +160,29 @@ export async function warpWorkspaceSession(input: {
   return true
 }
 
+export async function confirmWorkspaceFileChanges(input: {
+  dialog: ReturnType<typeof useDialog>
+  sdk: ReturnType<typeof useSDK>
+  sourceWorkspaceID?: string
+}) {
+  const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined)
+  const fileChangeChoice = status?.data?.length ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) : "no"
+  if (!fileChangeChoice) return
+  return fileChangeChoice === "yes"
+}
+
 export function DialogWorkspaceSelect(props: {
   adapters?: Adapter[]
   onSelect: (selection: WorkspaceSelection) => Promise<void> | void
 }) {
   const dialog = useDialog()
   const project = useProject()
+  const route = useRoute()
   const sync = useSync()
   const sdk = useSDK()
   const toast = useToast()
   const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
+  const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined))
 
   onMount(() => {
     dialog.setSize("medium")
@@ -171,6 +201,7 @@ export function DialogWorkspaceSelect(props: {
       sessions: sync.data.session,
       get: project.workspace.get,
       status: project.workspace.status,
+      omitWorkspaceID: omittedWorkspaceID(),
     })
     return [
       ...list.map((adapter) => ({
@@ -231,19 +262,23 @@ export function DialogWorkspaceSelect(props: {
           return
         }
 
-        dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
+        dialog.replace(() => <DialogExistingWorkspaceSelect omitWorkspaceID={omittedWorkspaceID()} onSelect={props.onSelect} />)
       }}
     />
   )
 }
 
-function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
+function DialogExistingWorkspaceSelect(props: {
+  omitWorkspaceID?: string
+  onSelect: (selection: WorkspaceSelection) => Promise<void> | void
+}) {
   const project = useProject()
 
   const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
     project.workspace
       .list()
       .filter((workspace) => project.workspace.status(workspace.id) === "connected")
+      .filter((workspace) => workspace.id !== props.omitWorkspaceID)
       .map((workspace: Workspace) => ({
         title: workspace.name,
         description: `(${workspace.type})`,

+ 138 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx

@@ -0,0 +1,138 @@
+import { TextAttributes } from "@opentui/core"
+import { useKeyboard } from "@opentui/solid"
+import type { VcsFileStatus } from "@opencode-ai/sdk/v2"
+import { createMemo, For } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Locale } from "@/util/locale"
+import { useTheme } from "../context/theme"
+import { useTuiConfig } from "../context/tui-config"
+import { useDialog, type DialogContext } from "../ui/dialog"
+import { getScrollAcceleration } from "../util/scroll"
+
+const options = ["no", "yes"] as const
+
+export type WorkspaceFileChangesChoice = (typeof options)[number]
+
+function statusLabel(status: VcsFileStatus["status"]) {
+  if (status === "added") return "A"
+  if (status === "deleted") return "D"
+  return "M"
+}
+
+function changeCountWidth(file: VcsFileStatus) {
+  // The "plus 2" is for spaces
+  return `${file.additions ? `+${file.additions}` : ""}${file.deletions ? ` -${file.deletions}` : ""}`.length + 2
+}
+
+export function DialogWorkspaceFileChanges(props: {
+  files: VcsFileStatus[]
+  onSelect: (choice: WorkspaceFileChangesChoice) => void
+}) {
+  const dialog = useDialog()
+  const { theme } = useTheme()
+  const tuiConfig = useTuiConfig()
+  const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
+  const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
+  const height = createMemo(() => Math.min(props.files.length, 8))
+  const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
+
+  function confirm() {
+    props.onSelect(store.active)
+    dialog.clear()
+  }
+
+  useKeyboard((evt) => {
+    if (evt.name === "return") {
+      evt.preventDefault()
+      evt.stopPropagation()
+      confirm()
+      return
+    }
+    if (evt.name === "left") {
+      evt.preventDefault()
+      evt.stopPropagation()
+      const index = options.indexOf(store.active)
+      setStore("active", options[Math.max(index - 1, 0)])
+      return
+    }
+    if (evt.name === "right") {
+      evt.preventDefault()
+      evt.stopPropagation()
+      const index = options.indexOf(store.active)
+      setStore("active", options[Math.min(index + 1, options.length - 1)])
+    }
+  })
+
+  return (
+    <box gap={1}>
+      <box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
+        <text attributes={TextAttributes.BOLD} fg={theme.text}>
+          File Changes Found
+        </text>
+        <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+          esc
+        </text>
+      </box>
+      <scrollbox
+        height={height()}
+        backgroundColor={theme.backgroundElement}
+        scrollbarOptions={{ visible: false }}
+        scrollAcceleration={scrollAcceleration()}
+      >
+        <For each={props.files}>
+          {(item) => (
+            <box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
+              <box flexDirection="row" minWidth={0} flexShrink={1}>
+                <box width={2} flexShrink={0}>
+                  <text fg={theme.textMuted}>{statusLabel(item.status)}</text>
+                </box>
+                <text fg={theme.textMuted} wrapMode="none">
+                  {Locale.truncateLeft(item.file, fileNameWidth())}
+                </text>
+              </box>
+              <box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
+                <text>
+                  {" "}
+                  {item.additions ? <span style={{ fg: theme.diffAdded }}>+{item.additions}</span> : null}
+                  {item.deletions ? <span style={{ fg: theme.diffRemoved }}> -{item.deletions}</span> : null}
+                </text>
+              </box>
+            </box>
+          )}
+        </For>
+      </scrollbox>
+      <box paddingLeft={2} paddingRight={2}>
+        <text fg={theme.textMuted} wrapMode="word">
+          Do you want to apply these changes after warping?
+        </text>
+      </box>
+      <box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
+        <For each={options}>
+          {(item) => (
+            <box
+              paddingLeft={2}
+              paddingRight={2}
+              backgroundColor={item === store.active ? theme.primary : undefined}
+              onMouseUp={() => {
+                setStore("active", item)
+                props.onSelect(item)
+                dialog.clear()
+              }}
+            >
+              <text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
+            </box>
+          )}
+        </For>
+      </box>
+    </box>
+  )
+}
+
+DialogWorkspaceFileChanges.show = (dialog: DialogContext, files: VcsFileStatus[]) => {
+  return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
+    dialog.replace(
+      () => <DialogWorkspaceFileChanges files={files} onSelect={resolve} />,
+      () => resolve(undefined),
+    )
+  })
+}

+ 11 - 1
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -42,7 +42,12 @@ import { useKV } from "../../context/kv"
 import { createFadeIn } from "../../util/signal"
 import { useTextareaKeybindings } from "../textarea-keybindings"
 import { DialogSkill } from "../dialog-skill"
-import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
+import {
+  confirmWorkspaceFileChanges,
+  openWorkspaceSelect,
+  warpWorkspaceSession,
+  type WorkspaceSelection,
+} from "../dialog-workspace-create"
 import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
 import { useArgs } from "@tui/context/args"
 import { Flag } from "@opencode-ai/core/flag/flag"
@@ -230,6 +235,9 @@ export function Prompt(props: PromptProps) {
       if (selection.type === "new") void createWorkspace(selection)
       return
     }
+    const sourceWorkspaceID = project.workspace.current()
+    const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
+    if (copyChanges === undefined) return
     selectWorkspace(selection)
     dialog.clear()
 
@@ -247,8 +255,10 @@ export function Prompt(props: PromptProps) {
       sync,
       project,
       toast,
+      sourceWorkspaceID,
       workspaceID: workspace.id,
       sessionID: props.sessionID,
+      copyChanges,
     })
     if (warped) showWarpNotice(workspace.name)
   }

+ 99 - 1
packages/opencode/src/control-plane/workspace.ts

@@ -18,7 +18,7 @@ import { ProjectID } from "@/project/schema"
 import { Slug } from "@opencode-ai/core/util/slug"
 import { WorkspaceTable } from "./workspace.sql"
 import { getAdapter } from "./adapters"
-import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
+import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
 import { WorkspaceID } from "./schema"
 import { Session } from "@/session/session"
 import { SessionPrompt } from "@/session/prompt"
@@ -31,6 +31,9 @@ import { WorkspaceContext } from "./workspace-context"
 import { EffectBridge } from "@/effect/bridge"
 import { withStatics } from "@/util/schema"
 import { zod as effectZod, zodObject } from "@/util/effect-zod"
+import { Vcs } from "@/project/vcs"
+import { InstanceStore } from "@/project/instance-store"
+import { InstanceBootstrap } from "@/project/bootstrap"
 
 export const Info = WorkspaceInfoSchema
 export type Info = WorkspaceInfo
@@ -86,6 +89,7 @@ export type CreateInput = Schema.Schema.Type<typeof CreateInput>
 export const SessionWarpInput = Schema.Struct({
   workspaceID: Schema.NullOr(WorkspaceID),
   sessionID: SessionID,
+  copyChanges: Schema.optional(Schema.Boolean),
 }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
 export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
 
@@ -137,6 +141,7 @@ type SessionWarpError =
   | WorkspaceNotFoundError
   | SessionEventsNotFoundError
   | SessionWarpHttpError
+  | Vcs.PatchApplyError
   | HttpClientError.HttpClientError
 type WaitForSyncError = SyncTimeoutError | SyncAbortedError
 type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
@@ -167,6 +172,7 @@ export const layer = Layer.effect(
     const prompt = yield* SessionPrompt.Service
     const http = yield* HttpClient.HttpClient
     const sync = yield* SyncEvent.Service
+    const vcs = yield* Vcs.Service
     const connections = new Map<WorkspaceID, ConnectionStatus>()
     const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
 
@@ -255,6 +261,66 @@ export const layer = Layer.effect(
       )
     })
 
+    const runInWorkspace = <A, E, R>(input: {
+      workspaceID?: WorkspaceID
+      local: () => Effect.Effect<A, E, R>
+      remote: (input: {
+        workspace: Info
+        target: Extract<Target, { type: "remote" }>
+      }) => HttpClientRequest.HttpClientRequest
+      fallback: A
+      response?: "json" | "text"
+    }) =>
+      Effect.gen(function* () {
+        if (!input.workspaceID) return yield* input.local()
+
+        const workspace = yield* get(input.workspaceID)
+        if (!workspace) return input.fallback
+
+        const adapter = getAdapter(workspace.projectID, workspace.type)
+        const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace))
+
+        if (target.type === "local") {
+          const store = yield* InstanceStore.Service
+          return yield* store.provide({ directory: target.directory }, input.local())
+        }
+
+        const response = yield* http.execute(input.remote({ workspace, target })).pipe(
+          Effect.catch((error) =>
+            Effect.sync(() => {
+              log.warn("workspace target request failed", {
+                workspaceID: workspace.id,
+                error: errorData(error),
+              })
+            }),
+          ),
+        )
+        if (!response) return input.fallback
+        if (response.status < 200 || response.status >= 300) {
+          const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
+          log.warn("workspace target request failed", {
+            workspaceID: workspace.id,
+            status: response.status,
+            body,
+          })
+          return input.fallback
+        }
+
+        const body = input.response === "text" ? response.text : response.json
+        return yield* body.pipe(
+          Effect.map((result) => result as A),
+          Effect.catch((error) =>
+            Effect.sync(() => {
+              log.warn("workspace target response decode failed", {
+                workspaceID: workspace.id,
+                error: errorData(error),
+              })
+              return input.fallback
+            }),
+          ),
+        )
+      })
+
     const syncHistory = Effect.fn("Workspace.syncHistory")(function* (
       space: Info,
       url: URL | string,
@@ -557,6 +623,36 @@ export const layer = Layer.effect(
           }
         }
 
+        const sourcePatch =
+          input.copyChanges && current?.workspaceID
+            ? yield* runInWorkspace({
+                workspaceID: current?.workspaceID ?? undefined,
+                local: () => vcs.diffRaw(),
+                remote: ({ target }) =>
+                  HttpClientRequest.get(route(target.url, "/vcs/diff/raw"), {
+                    headers: new Headers(target.headers),
+                  }),
+                fallback: "",
+                response: "text",
+              }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))))
+            : ""
+
+        if (sourcePatch) {
+          // Attempt to apply the file changes to the new workspace.
+          // We intentionally do first so if it fails we don't warp
+          // the session.
+          yield* runInWorkspace({
+            workspaceID: input.workspaceID ?? undefined,
+            local: () => vcs.apply({ patch: sourcePatch }),
+            remote: ({ target }) =>
+              HttpClientRequest.post(route(target.url, "/vcs/apply"), {
+                headers: new Headers(target.headers),
+                body: HttpBody.jsonUnsafe({ patch: sourcePatch }),
+              }),
+            fallback: { applied: false },
+          }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))))
+        }
+
         if (input.workspaceID === null) {
           yield* Effect.sync(() =>
             SyncEvent.run(Session.Event.Updated, {
@@ -866,6 +962,8 @@ export const defaultLayer = layer.pipe(
   Layer.provide(Session.defaultLayer),
   Layer.provide(SyncEvent.defaultLayer),
   Layer.provide(SessionPrompt.defaultLayer),
+  Layer.provide(Project.defaultLayer),
+  Layer.provide(Vcs.defaultLayer),
   Layer.provide(FetchHttpClient.layer),
 )
 

+ 15 - 2
packages/opencode/src/git/index.ts

@@ -68,6 +68,7 @@ export interface Options {
   readonly cwd: string
   readonly env?: Record<string, string>
   readonly maxOutputBytes?: number
+  readonly stdin?: ChildProcess.CommandInput
 }
 
 export interface Interface {
@@ -85,6 +86,7 @@ export interface Interface {
   readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect<Patch>
   readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
   readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
+  readonly applyPatch: (cwd: string, patch: string) => Effect.Effect<Result>
 }
 
 const kind = (code: string): Kind => {
@@ -101,6 +103,8 @@ export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+    const encoder = new TextEncoder()
+    const stdin = (text: string) => Stream.make(encoder.encode(text))
 
     const run = Effect.fn("Git.run")(
       function* (args: string[], opts: Options) {
@@ -108,7 +112,7 @@ export const layer = Layer.effect(
           cwd: opts.cwd,
           env: opts.env,
           extendEnv: true,
-          stdin: "ignore",
+          stdin: opts.stdin ?? "ignore",
           stdout: "pipe",
           stderr: "pipe",
         })
@@ -316,9 +320,13 @@ export const layer = Layer.effect(
         cwd,
         maxOutputBytes: 4096,
       })
+
       if (result.truncated) return
-      const parts = result.text().split("\t")
+      const text = result.text()
+
+      const parts = text.split("\t")
       if (parts.length < 2) return
+
       const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10)
       const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10)
       return {
@@ -328,6 +336,10 @@ export const layer = Layer.effect(
       } satisfies Stat
     })
 
+    const applyPatch = Effect.fn("Git.applyPatch")(function* (cwd: string, patch: string) {
+      return yield* run(["apply", "-"], { cwd, stdin: stdin(patch) })
+    })
+
     return Service.of({
       run,
       branch,
@@ -343,6 +355,7 @@ export const layer = Layer.effect(
       patchAll,
       patchUntracked,
       statUntracked,
+      applyPatch,
     })
   }),
 )

+ 84 - 1
packages/opencode/src/project/vcs.ts

@@ -6,7 +6,7 @@ import { InstanceState } from "@/effect/instance-state"
 import { FileWatcher } from "@/file/watcher"
 import { Git } from "@/git"
 import * as Log from "@opencode-ai/core/util/log"
-import { zod } from "@/util/effect-zod"
+import { zod, zodObject } from "@/util/effect-zod"
 import { NonNegativeInt, withStatics } from "@/util/schema"
 
 const log = Log.create({ service: "vcs" })
@@ -239,11 +239,39 @@ export const FileDiff = Schema.Struct({
   .pipe(withStatics((s) => ({ zod: zod(s) })))
 export type FileDiff = Schema.Schema.Type<typeof FileDiff>
 
+export const FileStatus = Schema.Struct({
+  file: Schema.String,
+  additions: NonNegativeInt,
+  deletions: NonNegativeInt,
+  status: Schema.Literals(["added", "deleted", "modified"]),
+})
+  .annotate({ identifier: "VcsFileStatus" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type FileStatus = Schema.Schema.Type<typeof FileStatus>
+
+export const ApplyInput = Schema.Struct({
+  patch: Schema.String,
+}).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) })))
+export type ApplyInput = Schema.Schema.Type<typeof ApplyInput>
+
+export const ApplyResult = Schema.Struct({
+  applied: Schema.Boolean,
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ApplyResult = Schema.Schema.Type<typeof ApplyResult>
+
+export class PatchApplyError extends Schema.TaggedErrorClass<PatchApplyError>()("VcsPatchApplyError", {
+  message: Schema.String,
+  reason: Schema.Literals(["non-git", "not-clean"]),
+}) {}
+
 export interface Interface {
   readonly init: () => Effect.Effect<void>
   readonly branch: () => Effect.Effect<string | undefined>
   readonly defaultBranch: () => Effect.Effect<string | undefined>
+  readonly status: () => Effect.Effect<FileStatus[]>
   readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
+  readonly diffRaw: () => Effect.Effect<string>
+  readonly apply: (input: ApplyInput) => Effect.Effect<ApplyResult, PatchApplyError>
 }
 
 interface State {
@@ -304,6 +332,31 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
       defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
         return yield* InstanceState.use(state, (x) => x.root?.name)
       }),
+      status: Effect.fn("Vcs.status")(function* () {
+        const ctx = yield* InstanceState.context
+        if (ctx.project.vcs !== "git") return []
+        const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
+        const [list, stats] = yield* Effect.all(
+          [git.status(ctx.directory), ref ? git.stats(ctx.directory, ref) : Effect.succeed([])],
+          { concurrency: 2 },
+        )
+        const map = nums(stats)
+        return yield* Effect.forEach(
+          list.toSorted((a, b) => a.file.localeCompare(b.file)),
+          (item) =>
+            Effect.gen(function* () {
+              const stat =
+                map.get(item.file) ??
+                (item.status === "added" ? yield* git.statUntracked(ctx.worktree, item.file) : undefined)
+              return {
+                file: item.file,
+                additions: stat?.additions ?? 0,
+                deletions: stat?.deletions ?? 0,
+                status: item.status,
+              } satisfies FileStatus
+            }),
+        )
+      }),
       diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
         const value = yield* InstanceState.get(state)
         const ctx = yield* InstanceState.context
@@ -318,6 +371,36 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
         if (!ref) return []
         return yield* diffAgainstRef(git, ctx.directory, ref)
       }),
+      diffRaw: Effect.fn("Vcs.diffRaw")(function* () {
+        const ctx = yield* InstanceState.context
+        if (ctx.project.vcs !== "git") return ""
+        const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], {
+          concurrency: 2,
+        })
+        const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD")).text : ""
+        const untracked = yield* Effect.forEach(
+          status.filter((item) => item.code === "??"),
+          (item) => git.patchUntracked(ctx.directory, item.file).pipe(Effect.map((patch) => patch.text)),
+        )
+        return [tracked, ...untracked].filter(Boolean).join("\n")
+      }),
+      apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) {
+        const ctx = yield* InstanceState.context
+        if (ctx.project.vcs !== "git") {
+          return yield* new PatchApplyError({
+            message: "Patch can't be applied because the project is not git-based",
+            reason: "non-git",
+          })
+        }
+        const applied = yield* git.applyPatch(ctx.directory, input.patch)
+        if (applied.exitCode !== 0) {
+          return yield* new PatchApplyError({
+            message: "Patch can't be applied",
+            reason: "not-clean",
+          })
+        }
+        return { applied: true }
+      }),
     })
   }),
 )

+ 31 - 2
packages/opencode/src/server/routes/control/workspace.ts

@@ -8,6 +8,7 @@ import { AppRuntime } from "@/effect/app-runtime"
 import { WorkspaceAdapterEntry } from "@/control-plane/types"
 import { zodObject } from "@/util/effect-zod"
 import { Instance } from "@/project/instance"
+import { Vcs } from "@/project/vcs"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 
@@ -164,19 +165,47 @@ export const WorkspaceRoutes = lazy(() =>
         z.object({
           id: zodObject(Workspace.Info).shape.id.nullable(),
           sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
+          copyChanges: z.boolean().optional(),
         }),
       ),
       async (c) => {
         const body = c.req.valid("json")
-        await AppRuntime.runPromise(
+        return AppRuntime.runPromise(
           Workspace.Service.use((workspace) =>
             workspace.sessionWarp({
               workspaceID: body.id,
               sessionID: body.sessionID,
+              copyChanges: body.copyChanges,
+            }),
+          ).pipe(
+            Effect.match({
+              onFailure: (error) => {
+                if (error instanceof Vcs.PatchApplyError) {
+                  return c.json(
+                    {
+                      name: "VcsApplyError",
+                      data: {
+                        message: error.message,
+                        reason: error.reason,
+                      },
+                    },
+                    400,
+                  )
+                }
+                return c.json(
+                  {
+                    name: "WorkspaceWarpError",
+                    data: {
+                      message: error.message,
+                    },
+                  },
+                  400,
+                )
+              },
+              onSuccess: () => c.body(null, 204),
             }),
           ),
         )
-        return c.body(null, 204)
       },
     ),
 )

+ 47 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts

@@ -5,7 +5,7 @@ import { LSP } from "@/lsp/lsp"
 import { Vcs } from "@/project/vcs"
 import { Skill } from "@/skill"
 import { Schema } from "effect"
-import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
 import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
@@ -23,11 +23,25 @@ export const VcsDiffQuery = Schema.Struct({
   mode: Vcs.Mode,
 })
 
+export class ApiVcsApplyError extends Schema.ErrorClass<ApiVcsApplyError>("VcsApplyError")(
+  {
+    name: Schema.Literal("VcsApplyError"),
+    data: Schema.Struct({
+      message: Schema.String,
+      reason: Schema.Literals(["non-git", "not-clean"]),
+    }),
+  },
+  { httpApiStatus: 400 },
+) {}
+
 export const InstancePaths = {
   dispose: "/instance/dispose",
   path: "/path",
   vcs: "/vcs",
+  vcsStatus: "/vcs/status",
   vcsDiff: "/vcs/diff",
+  vcsDiffRaw: "/vcs/diff/raw",
+  vcsApply: "/vcs/apply",
   command: "/command",
   agent: "/agent",
   skill: "/skill",
@@ -68,6 +82,15 @@ export const InstanceApi = HttpApi.make("instance")
               "Retrieve version control system (VCS) information for the current project, such as git branch.",
           }),
         ),
+        HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
+          success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "vcs.status",
+            summary: "Get VCS status",
+            description: "Retrieve changed files in the current working tree without patches.",
+          }),
+        ),
         HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
           query: VcsDiffQuery,
           success: described(Schema.Array(Vcs.FileDiff), "VCS diff"),
@@ -78,6 +101,29 @@ export const InstanceApi = HttpApi.make("instance")
             description: "Retrieve the current git diff for the working tree or against the default branch.",
           }),
         ),
+        HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
+          success: described(
+            Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
+            "Raw VCS diff",
+          ),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "vcs.diff.raw",
+            summary: "Get raw VCS diff",
+            description: "Retrieve a raw patch for current uncommitted changes.",
+          }),
+        ),
+        HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
+          payload: Vcs.ApplyInput,
+          success: described(Vcs.ApplyResult, "VCS patch applied"),
+          error: ApiVcsApplyError,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "vcs.apply",
+            summary: "Apply VCS patch",
+            description: "Apply a raw patch to the current working tree.",
+          }),
+        ),
         HttpApiEndpoint.get("command", InstancePaths.command, {
           success: described(Schema.Array(Command.Info), "List of commands"),
         }).annotateMerge(

+ 13 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts

@@ -2,6 +2,7 @@ import { Workspace } from "@/control-plane/workspace"
 import { WorkspaceAdapterEntry } from "@/control-plane/types"
 import { Schema, Struct } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import { ApiVcsApplyError } from "./instance"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
 import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
@@ -12,8 +13,19 @@ export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fie
 export const WarpPayload = Schema.Struct({
   id: Schema.NullOr(Workspace.Info.fields.id),
   sessionID: Workspace.SessionWarpInput.fields.sessionID,
+  copyChanges: Workspace.SessionWarpInput.fields.copyChanges,
 })
 
+export class ApiWorkspaceWarpError extends Schema.ErrorClass<ApiWorkspaceWarpError>("WorkspaceWarpError")(
+  {
+    name: Schema.Literal("WorkspaceWarpError"),
+    data: Schema.Struct({
+      message: Schema.String,
+    }),
+  },
+  { httpApiStatus: 400 },
+) {}
+
 export const WorkspacePaths = {
   adapters: `${root}/adapter`,
   list: root,
@@ -78,7 +90,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
         HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
           payload: WarpPayload,
           success: described(HttpApiSchema.NoContent, "Session warped"),
-          error: HttpApiError.BadRequest,
+          error: [ApiWorkspaceWarpError, ApiVcsApplyError],
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "experimental.workspace.warp",

+ 27 - 0
packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts

@@ -9,6 +9,7 @@ import { Skill } from "@/skill"
 import { Effect } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../api"
+import { ApiVcsApplyError } from "../groups/instance"
 import { markInstanceForDisposal } from "../lifecycle"
 
 export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) =>
@@ -41,10 +42,33 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
       return { branch, default_branch }
     })
 
+    const getVcsStatus = Effect.fn("InstanceHttpApi.vcsStatus")(function* () {
+      return yield* vcs.status()
+    })
+
     const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
       return yield* vcs.diff(ctx.query.mode)
     })
 
+    const getVcsDiffRaw = Effect.fn("InstanceHttpApi.vcsDiffRaw")(function* () {
+      return yield* vcs.diffRaw()
+    })
+
+    const applyVcs = Effect.fn("InstanceHttpApi.vcsApply")(function* (ctx: { payload: Vcs.ApplyInput }) {
+      return yield* vcs.apply(ctx.payload).pipe(
+        Effect.mapError(
+          (error) =>
+            new ApiVcsApplyError({
+              name: "VcsApplyError",
+              data: {
+                message: error.message,
+                reason: error.reason,
+              },
+            }),
+        ),
+      )
+    })
+
     const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
       return yield* command.list()
     })
@@ -69,7 +93,10 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
       .handle("dispose", dispose)
       .handle("path", getPath)
       .handle("vcs", getVcs)
+      .handle("vcsStatus", getVcsStatus)
       .handle("vcsDiff", getVcsDiff)
+      .handle("vcsDiffRaw", getVcsDiffRaw)
+      .handle("vcsApply", applyVcs)
       .handle("command", getCommand)
       .handle("agent", getAgent)
       .handle("skill", getSkill)

+ 23 - 2
packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts

@@ -1,10 +1,12 @@
 import { listAdapters } from "@/control-plane/adapters"
 import { Workspace } from "@/control-plane/workspace"
 import * as InstanceState from "@/effect/instance-state"
+import { Vcs } from "@/project/vcs"
 import { Effect } from "effect"
 import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../api"
-import { CreatePayload, WarpPayload } from "../groups/workspace"
+import { ApiVcsApplyError } from "../groups/instance"
+import { ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace"
 
 export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
   Effect.gen(function* () {
@@ -44,8 +46,27 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
         .sessionWarp({
           workspaceID: ctx.payload.id,
           sessionID: ctx.payload.sessionID,
+          copyChanges: ctx.payload.copyChanges,
         })
-        .pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
+        .pipe(
+          Effect.mapError((error) => {
+            if (error instanceof Vcs.PatchApplyError) {
+              return new ApiVcsApplyError({
+                name: "VcsApplyError",
+                data: {
+                  message: error.message,
+                  reason: error.reason,
+                },
+              })
+            }
+            return new ApiWorkspaceWarpError({
+              name: "WorkspaceWarpError",
+              data: {
+                message: error.message,
+              },
+            })
+          }),
+        )
     })
 
     return handlers

+ 97 - 1
packages/opencode/src/server/routes/instance/index.ts

@@ -27,7 +27,7 @@ import { ProviderRoutes } from "./provider"
 import { EventRoutes } from "./event"
 import { SyncRoutes } from "./sync"
 import { InstanceMiddleware } from "./middleware"
-import { jsonRequest } from "./trace"
+import { jsonRequest, runRequest } from "./trace"
 import { ExperimentalHttpApiServer } from "./httpapi/server"
 import { EventPaths } from "./httpapi/event"
 import { ExperimentalPaths } from "./httpapi/groups/experimental"
@@ -40,6 +40,7 @@ import { SyncPaths } from "./httpapi/groups/sync"
 import { TuiPaths } from "./httpapi/groups/tui"
 import { WorkspacePaths } from "./httpapi/groups/workspace"
 import type { CorsOptions } from "@/server/cors"
+import { errors } from "@/server/error"
 
 export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => {
   const app = new Hono()
@@ -86,7 +87,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
     app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
     app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.vcsStatus, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.vcsDiffRaw, (c) => handler(c.req.raw, context))
+    app.post(InstancePaths.vcsApply, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
@@ -288,6 +292,98 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
           return yield* vcs.diff(c.req.valid("query").mode)
         }),
     )
+    .get(
+      "/vcs/status",
+      describeRoute({
+        summary: "Get VCS status",
+        description: "Retrieve changed files in the current working tree without patches.",
+        operationId: "vcs.status",
+        responses: {
+          200: {
+            description: "VCS status",
+            content: {
+              "application/json": {
+                schema: resolver(Vcs.FileStatus.zod.array()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) =>
+        jsonRequest("InstanceRoutes.vcs.status", c, function* () {
+          const vcs = yield* Vcs.Service
+          return yield* vcs.status()
+        }),
+    )
+    .get(
+      "/vcs/diff/raw",
+      describeRoute({
+        summary: "Get raw VCS diff",
+        description: "Retrieve a raw patch for current uncommitted changes.",
+        operationId: "vcs.diff.raw",
+        responses: {
+          200: {
+            description: "Raw VCS diff",
+            content: {
+              "text/x-diff": {
+                schema: resolver(z.string()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        const patch = await runRequest(
+          "InstanceRoutes.vcs.diffRaw",
+          c,
+          Vcs.Service.use((vcs) => vcs.diffRaw()),
+        )
+        return c.text(patch, 200, { "content-type": "text/x-diff; charset=utf-8" })
+      },
+    )
+    .post(
+      "/vcs/apply",
+      describeRoute({
+        summary: "Apply VCS patch",
+        description: "Apply a raw patch to the current working tree.",
+        operationId: "vcs.apply",
+        responses: {
+          200: {
+            description: "VCS patch applied",
+            content: {
+              "application/json": {
+                schema: resolver(Vcs.ApplyResult.zod),
+              },
+            },
+          },
+          ...errors(400),
+        },
+      }),
+      validator("json", Vcs.ApplyInput.zodObject),
+      async (c) => {
+        const result = await runRequest(
+          "InstanceRoutes.vcs.apply",
+          c,
+          Vcs.Service.use((vcs) => vcs.apply(c.req.valid("json") as Vcs.ApplyInput)).pipe(
+            Effect.match({
+              onFailure: (error) => ({ ok: false as const, error }),
+              onSuccess: (value) => ({ ok: true as const, value }),
+            }),
+          ),
+        )
+        if (result.ok) return c.json(result.value)
+        return c.json(
+          {
+            name: "VcsApplyError",
+            data: {
+              message: result.error.message,
+              reason: result.error.reason,
+            },
+          },
+          400,
+        )
+      },
+    )
     .get(
       "/command",
       describeRoute({

+ 5 - 0
packages/opencode/src/util/locale.ts

@@ -63,6 +63,11 @@ export function truncate(str: string, len: number): string {
   return str.slice(0, len - 1) + "…"
 }
 
+export function truncateLeft(str: string, len: number): string {
+  if (str.length <= len) return str
+  return "…" + str.slice(-(len - 1))
+}
+
 export function truncateMiddle(str: string, maxLength: number = 35): string {
   if (str.length <= maxLength) return str
 

+ 25 - 0
packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts

@@ -35,4 +35,29 @@ describe("recentConnectedWorkspaces", () => {
 
     expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"])
   })
+
+  test("omits the active workspace before limiting recent workspaces", () => {
+    const workspaces = [
+      { id: "wrk_a", name: "alpha" },
+      { id: "wrk_b", name: "beta" },
+      { id: "wrk_c", name: "gamma" },
+      { id: "wrk_d", name: "delta" },
+    ]
+
+    const { recent, hasMore } = recentConnectedWorkspaces({
+      sessions: [
+        { workspaceID: "wrk_a", time: { updated: 400 } },
+        { workspaceID: "wrk_b", time: { updated: 300 } },
+        { workspaceID: "wrk_c", time: { updated: 200 } },
+        { workspaceID: "wrk_d", time: { updated: 100 } },
+      ],
+      get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
+      status: () => "connected",
+      limit: 3,
+      omitWorkspaceID: "wrk_a",
+    })
+
+    expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"])
+    expect(hasMore).toBe(false)
+  })
 })

+ 54 - 4
packages/opencode/test/control-plane/workspace.test.ts

@@ -1,4 +1,5 @@
 import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
+import { $ } from "bun"
 import fs from "node:fs/promises"
 import Http from "node:http"
 import path from "node:path"
@@ -29,12 +30,17 @@ import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
 import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
 import * as WorkspaceOld from "../../src/control-plane/workspace"
 import { AppRuntime } from "@/effect/app-runtime"
+import { InstanceStore } from "@/project/instance-store"
+import { InstanceBootstrap } from "@/project/bootstrap"
 
 void Log.init({ print: false })
 
 const testServerLayer = Layer.mergeAll(
   NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
-  WorkspaceOld.defaultLayer,
+  WorkspaceOld.defaultLayer.pipe(
+    Layer.provide(InstanceStore.defaultLayer),
+    Layer.provide(InstanceBootstrap.defaultLayer),
+  ),
   SessionNs.defaultLayer,
 )
 const it = testEffect(testServerLayer)
@@ -107,6 +113,18 @@ async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
   })
 }
 
+async function initGitRepo(dir: string) {
+  await fs.mkdir(dir, { recursive: true })
+  await $`git init`.cwd(dir).quiet()
+  await $`git config core.fsmonitor false`.cwd(dir).quiet()
+  await $`git config commit.gpgsign false`.cwd(dir).quiet()
+  await $`git config user.email "test@opencode.test"`.cwd(dir).quiet()
+  await $`git config user.name "Test"`.cwd(dir).quiet()
+  await fs.writeFile(path.join(dir, "tracked.txt"), "base\n")
+  await $`git add tracked.txt`.cwd(dir).quiet()
+  await $`git commit -m "base"`.cwd(dir).quiet()
+}
+
 const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
 const createWorkspace = (input: WorkspaceOld.CreateInput) =>
   runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
@@ -644,6 +662,33 @@ describe("workspace-old CRUD", () => {
     })
   })
 
+  test("sessionWarp applies source workspace patch to local target workspace", async () => {
+    await withInstance(async (dir) => {
+      const previousType = unique("warp-patch-prev-local")
+      const targetType = unique("warp-patch-target-local")
+      const previousDir = path.join(dir, "warp-patch-prev-local")
+      const targetDir = path.join(dir, "warp-patch-target-local")
+      await initGitRepo(previousDir)
+      await initGitRepo(targetDir)
+      await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n")
+      await fs.writeFile(path.join(previousDir, "new.txt"), "new\n")
+
+      const previous = workspaceInfo(Instance.project.id, previousType)
+      const target = workspaceInfo(Instance.project.id, targetType)
+      insertWorkspace(previous)
+      insertWorkspace(target)
+      registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
+      registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
+      const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
+      attachSessionToWorkspace(session.id, previous.id)
+
+      await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
+
+      expect(await fs.readFile(path.join(targetDir, "tracked.txt"), "utf8")).toBe("changed\n")
+      expect(await fs.readFile(path.join(targetDir, "new.txt"), "utf8")).toBe("new\n")
+    })
+  })
+
   test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
     await withInstance(async (dir) => {
       const previousType = unique("warp-detach-local")
@@ -696,10 +741,12 @@ describe("workspace-old CRUD", () => {
               },
             ])
           }
+          if (call.url.pathname === "/warp-source/vcs/diff/raw") return HttpServerResponse.text("remote patch")
           if (call.url.pathname === "/warp-target/sync/replay")
             return yield* HttpServerResponse.json({ sessionID: "ok" })
           if (call.url.pathname === "/warp-target/sync/steal")
             return yield* HttpServerResponse.json({ sessionID: "ok" })
+          if (call.url.pathname === "/warp-target/vcs/apply") return yield* HttpServerResponse.json({ applied: true })
           return HttpServerResponse.text("unexpected", { status: 500 })
         }),
       )
@@ -722,15 +769,18 @@ describe("workspace-old CRUD", () => {
             historySessionID = session.id
             historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
 
-            yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
+            yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
 
             expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
               "POST /warp-source/sync/history",
+              "GET /warp-source/vcs/diff/raw",
+              "POST /warp-target/vcs/apply",
               "POST /warp-target/sync/replay",
               "POST /warp-target/sync/steal",
             ])
             expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
-            expect(calls[1].json).toMatchObject({
+            expect(calls[2].json).toEqual({ patch: "remote patch" })
+            expect(calls[3].json).toMatchObject({
               directory: "remote-target-dir",
               events: [
                 {
@@ -745,7 +795,7 @@ describe("workspace-old CRUD", () => {
                 },
               ],
             })
-            expect(calls[2].json).toEqual({ sessionID: session.id })
+            expect(calls[4].json).toEqual({ sessionID: session.id })
             expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
             expect(sessionSequenceOwner(session.id)).toBe(target.id)
           }),

+ 7 - 1
packages/opencode/test/plugin/workspace-adapter.test.ts

@@ -12,8 +12,14 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
 const { Flag } = await import("@opencode-ai/core/flag/flag")
 const { Plugin } = await import("../../src/plugin/index")
 const { Workspace } = await import("../../src/control-plane/workspace")
+const { InstanceBootstrap } = await import("../../src/project/bootstrap")
 const { Instance } = await import("../../src/project/instance")
-const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, Workspace.defaultLayer, CrossSpawnSpawner.defaultLayer))
+const { InstanceStore } = await import("../../src/project/instance-store")
+const workspaceLayer = Workspace.defaultLayer.pipe(
+  Layer.provide(InstanceStore.defaultLayer),
+  Layer.provide(InstanceBootstrap.defaultLayer),
+)
+const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer))
 
 const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
 

+ 8 - 1
packages/opencode/test/server/httpapi-instance-context.test.ts

@@ -10,8 +10,10 @@ import { registerAdapter } from "../../src/control-plane/adapters"
 import type { WorkspaceAdapter } from "../../src/control-plane/types"
 import { Workspace } from "../../src/control-plane/workspace"
 import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
+import { InstanceBootstrap } from "../../src/project/bootstrap"
 import { Instance } from "../../src/project/instance"
 import { InstanceLayer } from "../../src/project/instance-layer"
+import { InstanceStore } from "../../src/project/instance-store"
 import { Project } from "../../src/project/project"
 import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
 import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
@@ -36,6 +38,11 @@ const testStateLayer = Layer.effectDiscard(
   }),
 )
 
+const workspaceLayer = Workspace.defaultLayer.pipe(
+  Layer.provide(InstanceStore.defaultLayer),
+  Layer.provide(InstanceBootstrap.defaultLayer),
+)
+
 const it = testEffect(
   Layer.mergeAll(
     testStateLayer,
@@ -43,7 +50,7 @@ const it = testEffect(
     NodeServices.layer,
     InstanceLayer.layer,
     Project.defaultLayer,
-    Workspace.defaultLayer,
+    workspaceLayer,
   ),
 )
 

+ 8 - 2
packages/opencode/test/server/httpapi-session.test.ts

@@ -1,7 +1,7 @@
 import { afterEach, describe, expect } from "bun:test"
 import { mkdir } from "node:fs/promises"
 import path from "node:path"
-import { Effect } from "effect"
+import { Effect, Layer } from "effect"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { registerAdapter } from "../../src/control-plane/adapters"
 import type { WorkspaceAdapter } from "../../src/control-plane/types"
@@ -9,6 +9,8 @@ import { Workspace } from "../../src/control-plane/workspace"
 import { PermissionID } from "../../src/permission/schema"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { WithInstance } from "../../src/project/with-instance"
+import { InstanceBootstrap } from "../../src/project/bootstrap"
+import { InstanceStore } from "../../src/project/instance-store"
 import { Project } from "../../src/project/project"
 import { Server } from "../../src/server/server"
 import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
@@ -30,6 +32,10 @@ void Log.init({ print: false })
 
 const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
 const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
+const workspaceLayer = Workspace.defaultLayer.pipe(
+  Layer.provide(InstanceStore.defaultLayer),
+  Layer.provide(InstanceBootstrap.defaultLayer),
+)
 
 function app(experimental = true) {
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
@@ -106,7 +112,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
         extra: null,
         projectID: input.projectID,
       }),
-    ).pipe(Effect.provide(Workspace.defaultLayer))
+    ).pipe(Effect.provide(workspaceLayer))
   })
 
 function request(path: string, init?: RequestInit) {

+ 8 - 1
packages/opencode/test/server/httpapi-workspace-routing.test.ts

@@ -20,6 +20,8 @@ import { WorkspaceID } from "../../src/control-plane/schema"
 import type { WorkspaceAdapter } from "../../src/control-plane/types"
 import { Workspace } from "../../src/control-plane/workspace"
 import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
+import { InstanceBootstrap } from "../../src/project/bootstrap"
+import { InstanceStore } from "../../src/project/instance-store"
 import { Project } from "../../src/project/project"
 import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
 import {
@@ -45,13 +47,18 @@ const testStateLayer = Layer.effectDiscard(
   }),
 )
 
+const workspaceLayer = Workspace.defaultLayer.pipe(
+  Layer.provide(InstanceStore.defaultLayer),
+  Layer.provide(InstanceBootstrap.defaultLayer),
+)
+
 const it = testEffect(
   Layer.mergeAll(
     testStateLayer,
     NodeHttpServer.layerTest,
     NodeServices.layer,
     Project.defaultLayer,
-    Workspace.defaultLayer,
+    workspaceLayer,
     Socket.layerWebSocketConstructorGlobal,
   ),
 )

+ 6 - 2
packages/opencode/test/server/httpapi-workspace.test.ts

@@ -14,6 +14,8 @@ import { Server } from "../../src/server/server"
 import { resetDatabase } from "../fixture/db"
 import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
 import { Instance } from "../../src/project/instance"
+import { InstanceBootstrap } from "../../src/project/bootstrap"
+import { InstanceStore } from "../../src/project/instance-store"
 import { Project } from "../../src/project/project"
 import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
 import { WorkspaceRef } from "../../src/effect/instance-ref"
@@ -23,9 +25,11 @@ void Log.init({ print: false })
 
 const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
 const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
-const it = testEffect(
-  Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer),
+const workspaceLayer = Workspace.defaultLayer.pipe(
+  Layer.provide(InstanceStore.defaultLayer),
+  Layer.provide(InstanceBootstrap.defaultLayer),
 )
+const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
 
 function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) {
   return Effect.promise(() => {

+ 110 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -202,8 +202,12 @@ import type {
   V2SessionMessagesResponses,
   V2SessionPromptResponses,
   V2SessionWaitResponses,
+  VcsApplyErrors,
+  VcsApplyResponses,
+  VcsDiffRawResponses,
   VcsDiffResponses,
   VcsGetResponses,
+  VcsStatusResponses,
   WorktreeCreateErrors,
   WorktreeCreateInput,
   WorktreeCreateResponses,
@@ -1022,6 +1026,7 @@ export class Workspace extends HeyApiClient {
       workspace?: string
       id?: string | null
       sessionID?: string
+      copyChanges?: boolean
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -1034,6 +1039,7 @@ export class Workspace extends HeyApiClient {
             { in: "query", key: "workspace" },
             { in: "body", key: "id" },
             { in: "body", key: "sessionID" },
+            { in: "body", key: "copyChanges" },
           ],
         },
       ],
@@ -1555,6 +1561,38 @@ export class Path extends HeyApiClient {
   }
 }
 
+export class Diff extends HeyApiClient {
+  /**
+   * Get raw VCS diff
+   *
+   * Retrieve a raw patch for current uncommitted changes.
+   */
+  public raw<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).get<VcsDiffRawResponses, unknown, ThrowOnError>({
+      url: "/vcs/diff/raw",
+      ...options,
+      ...params,
+    })
+  }
+}
+
 export class Vcs extends HeyApiClient {
   /**
    * Get VCS info
@@ -1586,6 +1624,36 @@ export class Vcs extends HeyApiClient {
     })
   }
 
+  /**
+   * Get VCS status
+   *
+   * Retrieve changed files in the current working tree without patches.
+   */
+  public status<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).get<VcsStatusResponses, unknown, ThrowOnError>({
+      url: "/vcs/status",
+      ...options,
+      ...params,
+    })
+  }
+
   /**
    * Get VCS diff
    *
@@ -1617,6 +1685,48 @@ export class Vcs extends HeyApiClient {
       ...params,
     })
   }
+
+  /**
+   * Apply VCS patch
+   *
+   * Apply a raw patch to the current working tree.
+   */
+  public apply<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+      patch?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+            { in: "body", key: "patch" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<VcsApplyResponses, VcsApplyErrors, ThrowOnError>({
+      url: "/vcs/apply",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
+  private _diff?: Diff
+  get diff2(): Diff {
+    return (this._diff ??= new Diff({ client: this.client }))
+  }
 }
 
 export class Command extends HeyApiClient {

+ 95 - 2
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -1474,6 +1474,13 @@ export type VcsInfo = {
   default_branch?: string
 }
 
+export type VcsFileStatus = {
+  file: string
+  additions: number
+  deletions: number
+  status: "added" | "deleted" | "modified"
+}
+
 export type VcsFileDiff = {
   file: string
   patch: string
@@ -1482,6 +1489,14 @@ export type VcsFileDiff = {
   status?: "added" | "deleted" | "modified"
 }
 
+export type VcsApplyError = {
+  name: "VcsApplyError"
+  data: {
+    message: string
+    reason: "non-git" | "not-clean"
+  }
+}
+
 export type Command = {
   name: string
   description?: string
@@ -1736,6 +1751,13 @@ export type Workspace = {
   projectID: string
 }
 
+export type WorkspaceWarpError = {
+  name: "WorkspaceWarpError"
+  data: {
+    message: string
+  }
+}
+
 export type SyncEventMessageUpdated = {
   type: "sync"
   name: "message.updated.1"
@@ -4020,6 +4042,25 @@ export type VcsGetResponses = {
 
 export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
 
+export type VcsStatusData = {
+  body?: never
+  path?: never
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/vcs/status"
+}
+
+export type VcsStatusResponses = {
+  /**
+   * VCS status
+   */
+  200: Array<VcsFileStatus>
+}
+
+export type VcsStatusResponse = VcsStatusResponses[keyof VcsStatusResponses]
+
 export type VcsDiffData = {
   body?: never
   path?: never
@@ -4040,6 +4081,57 @@ export type VcsDiffResponses = {
 
 export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]
 
+export type VcsDiffRawData = {
+  body?: never
+  path?: never
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/vcs/diff/raw"
+}
+
+export type VcsDiffRawResponses = {
+  /**
+   * Raw VCS diff
+   */
+  200: string
+}
+
+export type VcsDiffRawResponse = VcsDiffRawResponses[keyof VcsDiffRawResponses]
+
+export type VcsApplyData = {
+  body?: {
+    patch: string
+  }
+  path?: never
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/vcs/apply"
+}
+
+export type VcsApplyErrors = {
+  /**
+   * VcsApplyError
+   */
+  400: VcsApplyError
+}
+
+export type VcsApplyError2 = VcsApplyErrors[keyof VcsApplyErrors]
+
+export type VcsApplyResponses = {
+  /**
+   * VCS patch applied
+   */
+  200: {
+    applied: boolean
+  }
+}
+
+export type VcsApplyResponse = VcsApplyResponses[keyof VcsApplyResponses]
+
 export type CommandListData = {
   body?: never
   path?: never
@@ -6667,6 +6759,7 @@ export type ExperimentalWorkspaceWarpData = {
   body?: {
     id: string | null
     sessionID: string
+    copyChanges?: boolean
   }
   path?: never
   query?: {
@@ -6678,9 +6771,9 @@ export type ExperimentalWorkspaceWarpData = {
 
 export type ExperimentalWorkspaceWarpErrors = {
   /**
-   * Bad request
+   * WorkspaceWarpError | VcsApplyError
    */
-  400: BadRequestError
+  400: WorkspaceWarpError | VcsApplyError
 }
 
 export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors]