Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
c726640
feat(vcs): add stash and stashPop endpoints
haxllo Jul 8, 2026
fdef076
feat(workspace): add name to CreateInput, directory to SessionWarpInput
haxllo Jul 8, 2026
f9f189c
fix(worktree): return branch and set detached=false in configure
haxllo Jul 8, 2026
369553b
feat(workspace): reorder startSync flag, detach sessions on remove
haxllo Jul 8, 2026
8a1421a
fix(workspace): use instance.directory when warping to local
haxllo Jul 8, 2026
b4e06d1
fix(worktree): rename to Linked workspace, pass name, return branch
haxllo Jul 8, 2026
b839564
fix(session): add directory to setWorkspace interface
haxllo Jul 8, 2026
c733975
fix(workspace-adapter-runtime): pass workspaceID in create context
haxllo Jul 8, 2026
4fc081f
feat(cli): register WorktreeCommand
haxllo Jul 8, 2026
5860041
fix(tui): add VCS to bootstrap, use syncKeepCurrent, workspace name i…
haxllo Jul 8, 2026
9c2028a
feat(tui): redesign workspace dialog with stash-based warp
haxllo Jul 8, 2026
374a0c8
feat(tui): stash-based warp with name prompt and existence check
haxllo Jul 8, 2026
9042516
fix(tui): remove Flag import and experimental workspaces check, use l…
haxllo Jul 8, 2026
4a5611d
feat(tui): add optimistic VCS on session navigation
haxllo Jul 8, 2026
77031e3
fix(tui): use useDirectory hook in footers
haxllo Jul 8, 2026
dd8a92e
fix(tui): remove workspace.list command and Flag imports
haxllo Jul 8, 2026
fe74470
fix(tui): add alignItems center to dialog-select
haxllo Jul 8, 2026
81f3bbb
test(tui): update dialog-workspace-create tests for buildDetails
haxllo Jul 8, 2026
fe972f2
feat(cli): add worktree CLI command
haxllo Jul 8, 2026
5c4e459
fix(tui): fix stash pop target and add error handling
haxllo Jul 8, 2026
d0c0ef7
chore(sdk): regenerate SDK with stash/stashPop methods
haxllo Jul 8, 2026
2a6db7d
fix(tui): add file status feedback for local transitions, fix stash p…
haxllo Jul 8, 2026
890b779
fix(tui): fix stashPop typecheck error
haxllo Jul 9, 2026
dbcfbf6
fix(tui): restore popped.data guard, remove scratch files
haxllo Jul 9, 2026
d8da7ab
fix(cli): register worktree command before default to fix help visibi…
haxllo Jul 9, 2026
3b9a3b3
fix(sync): add .catch() to vcsPromise to prevent bootstrap crash
haxllo Jul 9, 2026
e3f3a25
fix(workspace): restore original session deletion on workspace remove
haxllo Jul 9, 2026
3fb8696
fix(tui): replace ? with • in workspace status indicator
haxllo Jul 9, 2026
4c28fc0
fix(cli): resolve worktree name to directory in remove and reset
haxllo Jul 9, 2026
927d7f9
fix(vcs): exact stash message match in stashPop instead of substring
haxllo Jul 15, 2026
8fadf00
fix(tui): guard session bootstrap against stale async writes on fast …
haxllo Jul 15, 2026
01bf8e8
fix(tui): restore stashed changes on warp failure instead of losing them
haxllo Jul 15, 2026
812284b
fix(tui): remove stale workspace.list keybinding
haxllo Jul 15, 2026
fc6dc73
fix(tui): add timeout to sync.start() to prevent SSE stall
haxllo Jul 15, 2026
951f6f6
fix(tui): add missing error handler on workspace.sync() in delete flow
haxllo Jul 15, 2026
d8716e4
refactor(cli): rename resolveWorktreeName to resolveWorktreeTarget, a…
haxllo Jul 15, 2026
5921b0d
fix(server): add ApiVcsStashError contract to stash endpoints
haxllo Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions packages/opencode/src/cli/cmd/worktree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import type { Argv } from "yargs"
import { EOL } from "os"
import { Effect } from "effect"
import { cmd } from "./cmd"
import { effectCmd, fail } from "../effect-cmd"
import { Worktree } from "@/worktree"
import { UI } from "../ui"
import { GlobalBus, type GlobalEvent } from "@/bus/global"

function formatTable(entries: (Omit<Worktree.Info, "branch"> & { branch?: string })[]): string {
const maxName = Math.max(4, ...entries.map((e) => e.name.length))
const maxBranch = Math.max(6, ...entries.map((e) => (e.branch ?? "-").length))
const fmt = ` ${"Name".padEnd(maxName)} ${"Branch".padEnd(maxBranch)} Directory`
const sep = " " + "─".repeat(maxName) + " " + "─".repeat(maxBranch) + " " + "─".repeat(80)
const rows = entries.map(
(e) => ` ${e.name.padEnd(maxName)} ${(e.branch ?? "-").padEnd(maxBranch)} ${e.directory}`,
)
return [fmt, sep, ...rows].join(EOL)
}

export const WorktreeCommand = cmd({
command: "worktree",
describe: "manage git worktrees",
builder: (yargs: Argv) =>
yargs
.command(WorktreeCreateCommand)
.command(WorktreeListCommand)
.command(WorktreeRemoveCommand)
.command(WorktreeResetCommand)
.demandCommand(),
async handler() {},
})

const waitWorktreeEvent = (directory: string) =>
Effect.callback<"ready" | "failed", never>((resume) => {
const handler = (event: GlobalEvent) => {
if (event.directory !== directory) return
if (event.payload.type === Worktree.Event.Ready.type) {
GlobalBus.off("event", handler)
resume(Effect.succeed("ready" as const))
}
if (event.payload.type === Worktree.Event.Failed.type) {
GlobalBus.off("event", handler)
resume(Effect.succeed("failed" as const))
}
}

GlobalBus.on("event", handler)
return Effect.sync(() => GlobalBus.off("event", handler))
})

export const WorktreeCreateCommand = effectCmd({
command: "create",
describe: "create a worktree",
builder: (yargs) =>
yargs
.option("name", {
describe: "worktree name (slug; generated if omitted)",
type: "string",
})
.option("start-command", {
describe: "additional startup script",
type: "string",
}),
handler: Effect.fn("Cli.worktree.create")(function* (args) {
const svc = yield* Worktree.Service
const info = yield* svc.create({ name: args.name, startCommand: args.startCommand }).pipe(
Effect.catch((e) => fail(e.message)),
)

UI.println(UI.Style.TEXT_DIM + "Worktree directory: " + UI.Style.TEXT_NORMAL + info.directory)
if (info.branch) {
UI.println(UI.Style.TEXT_DIM + "Branch: " + UI.Style.TEXT_NORMAL + info.branch)
}
UI.empty()

const result = yield* waitWorktreeEvent(info.directory).pipe(
Effect.timeoutOrElse({
duration: "120 seconds",
orElse: () => Effect.succeed("timeout" as const),
}),
)

if (result === "timeout") {
UI.println(UI.Style.TEXT_WARNING_BOLD + "Worktree created but bootstrap still running" + UI.Style.TEXT_NORMAL)
return
}

if (result === "failed") {
return yield* fail("Worktree bootstrap failed")
}

UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Worktree ready" + UI.Style.TEXT_NORMAL)
}),
})

export const WorktreeListCommand = effectCmd({
command: "list",
describe: "list worktrees",
builder: (yargs) =>
yargs.option("format", {
describe: "output format",
type: "string",
choices: ["table", "json"],
default: "table",
}),
handler: Effect.fn("Cli.worktree.list")(function* (args) {
const svc = yield* Worktree.Service
const list = yield* svc.list().pipe(
Effect.catch((e) => fail(e.message)),
)
if (list.length === 0) return
if (args.format === "json") {
console.log(JSON.stringify(list, null, 2))
} else {
console.log(formatTable(list))
}
}),
})

const resolveWorktreeTarget = Effect.fnUntraced(function* (svc: Worktree.Interface, name: string) {
const list = yield* svc.list()
const match = list.find((w) => w.name === name)
if (!match) return name
return match.directory
})

export const WorktreeRemoveCommand = effectCmd({
command: "remove <name>",
describe: "remove a worktree",
builder: (yargs) =>
yargs.positional("name", {
describe: "worktree name or directory",
type: "string",
demandOption: true,
}),
handler: Effect.fn("Cli.worktree.remove")(function* (args) {
const svc = yield* Worktree.Service
const directory = yield* resolveWorktreeTarget(svc, args.name).pipe(
Effect.catch((e) => fail(e.message)),
)
yield* svc.remove({ directory }).pipe(
Effect.catch((e) => fail(e.message)),
)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Worktree removed" + UI.Style.TEXT_NORMAL)
}),
})

export const WorktreeResetCommand = effectCmd({
command: "reset <name>",
describe: "reset a worktree",
builder: (yargs) =>
yargs.positional("name", {
describe: "worktree name or directory",
type: "string",
demandOption: true,
}),
handler: Effect.fn("Cli.worktree.reset")(function* (args) {
const svc = yield* Worktree.Service
const directory = yield* resolveWorktreeTarget(svc, args.name).pipe(
Effect.catch((e) => fail(e.message)),
)
yield* svc.reset({ directory }).pipe(
Effect.catch((e) => fail(e.message)),
)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Worktree reset" + UI.Style.TEXT_NORMAL)
}),
})
7 changes: 5 additions & 2 deletions packages/opencode/src/control-plane/adapters/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,23 @@ const provideContext = <A, E, R>(effect: Effect.Effect<A, E, R>, context: Worksp
)

export const WorktreeAdapter: WorkspaceAdapter = {
name: "Worktree",
name: "Linked workspace",
description: "Create a git worktree",
async configure(info, context) {
const { AppRuntime, Worktree } = await loadWorktree()
const next = await AppRuntime.runPromise(
provideContext(
Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })),
Worktree.Service.use((svc) =>
svc.makeWorktreeInfo({ detached: false, ...(info.name ? { name: info.name } : {}) }),
),
context,
),
)
return {
...info,
name: next.name,
directory: next.directory,
branch: next.branch ?? null,
}
},
async create(info, _env, _from, context) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export const create = (
) =>
Effect.gen(function* () {
const ctx = yield* context
return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, ctx))
const workspaceID = ctx.workspaceID ?? info.id
return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, { ...ctx, workspaceID }))
})

export const list = (adapter: WorkspaceAdapter) =>
Expand Down
10 changes: 6 additions & 4 deletions packages/opencode/src/control-plane/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const CreateInput = Schema.Struct({
type: Info.fields.type,
branch: Info.fields.branch,
projectID: ProjectV2.ID,
name: Schema.optional(Info.fields.name),
extra: Schema.optional(Info.fields.extra),
})
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
Expand All @@ -72,6 +73,7 @@ export const SessionWarpInput = Schema.Struct({
workspaceID: Schema.NullOr(WorkspaceV2.ID),
sessionID: SessionID,
copyChanges: Schema.optional(Schema.Boolean),
directory: Schema.optional(Schema.String),
})
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>

Expand Down Expand Up @@ -439,8 +441,6 @@ const layer = Layer.effect(
})

const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
if (!flags.experimentalWorkspaces) return

const target = yield* WorkspaceAdapterRuntime.target(space).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
Expand All @@ -460,6 +460,8 @@ const layer = Layer.effect(
return
}

if (!flags.experimentalWorkspaces) return

const exists = yield* FiberMap.has(syncFibers, space.id)
if (exists && connections.get(space.id)?.status !== "error") return

Expand Down Expand Up @@ -495,7 +497,7 @@ const layer = Layer.effect(
const config = yield* WorkspaceAdapterRuntime.configure(adapter, {
...input,
id,
name: Slug.create(),
name: input.name || Slug.create(),
directory: null,
extra: input.extra ?? null,
})
Expand Down Expand Up @@ -621,7 +623,7 @@ const layer = Layer.effect(
}

if (input.workspaceID === null) {
yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined })
yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined, directory: input.directory })

return
}
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
import { WorktreeCommand } from "./cli/cmd/worktree"
import { DbCommand } from "./cli/cmd/db"
import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
Expand Down Expand Up @@ -80,6 +81,7 @@ const cli = yargs(args)
.completion("completion", "generate shell completion script")
.command(AcpCommand)
.command(McpCommand)
.command(WorktreeCommand)
.command(TuiThreadCommand)
.command(AttachCommand)
.command(RunCommand)
Expand Down
29 changes: 29 additions & 0 deletions packages/opencode/src/project/vcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ export class PatchApplyError extends Schema.TaggedErrorClass<PatchApplyError>()(
reason: Schema.Literals(["non-git", "not-clean"]),
}) {}

export const StashInput = Schema.Struct({
message: Schema.String,
})
export type StashInput = Schema.Schema.Type<typeof StashInput>

export interface Interface {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
Expand All @@ -286,6 +291,8 @@ export interface Interface {
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff[]>
readonly diffRaw: () => Effect.Effect<string>
readonly apply: (input: ApplyInput) => Effect.Effect<ApplyResult, PatchApplyError>
readonly stash: (input: StashInput) => Effect.Effect<void>
readonly stashPop: (input: StashInput) => Effect.Effect<boolean>
}

interface State {
Expand Down Expand Up @@ -414,6 +421,28 @@ const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Service> =
}
return { applied: true }
}),
stash: Effect.fn("Vcs.stash")(function* (input: StashInput) {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return
yield* git.run(["stash", "push", "-m", input.message], { cwd: ctx.directory })
}),
stashPop: Effect.fn("Vcs.stashPop")(function* (input: StashInput) {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return false
const list = yield* git.run(["stash", "list"], { cwd: ctx.directory })
const entry = list
.text()
.split(/\r?\n/)
.find((line) => {
const parts = line.split(": ")
const message = parts.slice(2).join(": ")
return message === input.message
})
const ref = entry?.split(":")[0]
if (!ref) return false
yield* git.run(["stash", "pop", ref], { cwd: ctx.directory })
return true
}),
})
}),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ export class ApiVcsApplyError extends Schema.ErrorClass<ApiVcsApplyError>("VcsAp
{ httpApiStatus: 400 },
) {}

export class ApiVcsStashError extends Schema.ErrorClass<ApiVcsStashError>("VcsStashError")(
{
name: Schema.Literal("VcsStashError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 400 },
) {}

export const InstancePaths = {
dispose: "/instance/dispose",
path: "/path",
Expand All @@ -48,6 +58,8 @@ export const InstancePaths = {
vcsDiff: "/vcs/diff",
vcsDiffRaw: "/vcs/diff/raw",
vcsApply: "/vcs/apply",
vcsStash: "/vcs/stash",
vcsStashPop: "/vcs/stash-pop",
command: "/command",
agent: "/agent",
skill: "/skill",
Expand Down Expand Up @@ -136,6 +148,30 @@ export const InstanceApi = HttpApi.make("instance")
description: "Apply a raw patch to the current working tree.",
}),
),
HttpApiEndpoint.post("vcsStash", InstancePaths.vcsStash, {
query: WorkspaceRoutingQuery,
payload: Vcs.StashInput,
success: described(HttpApiSchema.NoContent, "Changes stashed"),
error: ApiVcsStashError,
}).annotateMerge(
OpenApi.annotations({
identifier: "vcs.stash",
summary: "Stash changes",
description: "Stash uncommitted changes with a message.",
}),
),
HttpApiEndpoint.post("vcsStashPop", InstancePaths.vcsStashPop, {
query: WorkspaceRoutingQuery,
payload: Vcs.StashInput,
success: described(Schema.Boolean, "Stash popped"),
error: ApiVcsStashError,
}).annotateMerge(
OpenApi.annotations({
identifier: "vcs.stashPop",
summary: "Pop stashed changes",
description: "Pop a stash with a matching message.",
}),
),
HttpApiEndpoint.get("command", InstancePaths.command, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Command.Info), "List of commands"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const WarpPayload = Schema.Struct({
id: Schema.NullOr(Workspace.Info.fields.id),
sessionID: Workspace.SessionWarpInput.fields.sessionID,
copyChanges: Workspace.SessionWarpInput.fields.copyChanges,
directory: Workspace.SessionWarpInput.fields.directory,
})

export class ApiWorkspaceWarpError extends Schema.ErrorClass<ApiWorkspaceWarpError>("WorkspaceWarpError")(
Expand Down
Loading
Loading