Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
164 changes: 164 additions & 0 deletions packages/opencode/src/cli/cmd/worktree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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 resolveWorktreeName = Effect.fnUntraced(function* (svc: Worktree.Interface, name: string) {
const list = yield* svc.list().pipe(Effect.catch((e) => fail(e.message)))
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* resolveWorktreeName(svc, args.name)
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* resolveWorktreeName(svc, args.name)
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
25 changes: 25 additions & 0 deletions packages/opencode/src/project/vcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,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 @@ -330,6 +335,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 @@ -458,6 +465,24 @@ 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 ref = list
.text()
.split(/\r?\n/)
.find((line) => line.includes(input.message))
?.split(":")[0]
if (!ref) return false
yield* git.run(["stash", "pop", ref], { cwd: ctx.directory })
return true
}),
Comment on lines +473 to +485

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

stashPop substring match can pop the wrong stash

line.includes(input.message) is a substring search across git stash list output. If multiple stash entries contain the same substring (e.g., message "fix" matches "fix" and "fix-bug"), find returns the first match — which may not be the stash created by the corresponding stash call. Popping the wrong stash overwrites the working tree with unintended content, risking data loss.

Use an exact message match or a more precise delimiter-based extraction to avoid ambiguity.

🛡️ Proposed fix: match full stash message after last colon
       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 ref = list
-          .text()
-          .split(/\r?\n/)
-          .find((line) => line.includes(input.message))
-          ?.split(":")[0]
+        const entry = list
+          .text()
+          .split(/\r?\n/)
+          .find((line) => {
+            // git stash list format: stash@{N}: WIP on branch: hash: message
+            // Extract message as everything after the 3rd colon (or 2nd for non-WIP)
+            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
       }),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 ref = list
.text()
.split(/\r?\n/)
.find((line) => line.includes(input.message))
?.split(":")[0]
if (!ref) return false
yield* git.run(["stash", "pop", ref], { cwd: ctx.directory })
return true
}),
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) => {
// git stash list format: stash@{N}: WIP on branch: hash: message
// Extract message as everything after the 3rd colon (or 2nd for non-WIP)
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
}),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/project/vcs.ts` around lines 429 - 441, Update stashPop
to avoid substring matching when selecting a stash: parse each git stash list
entry and compare input.message exactly against the full stash message after the
identifying delimiter, while preserving the existing ref extraction and return
behavior.

})
}),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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 +138,28 @@ 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"),
}).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"),
}).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
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
)
})

const stashVcs = Effect.fn("InstanceHttpApi.vcsStash")(function* (ctx: { payload: Vcs.StashInput }) {
yield* vcs.stash(ctx.payload)
})

const stashPopVcs = Effect.fn("InstanceHttpApi.vcsStashPop")(function* (ctx: { payload: Vcs.StashInput }) {
return yield* vcs.stashPop(ctx.payload)
})

const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
return yield* command.list()
})
Expand Down Expand Up @@ -101,6 +109,8 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
.handle("vcsDiff", getVcsDiff)
.handle("vcsDiffRaw", getVcsDiffRaw)
.handle("vcsApply", applyVcs)
.handle("vcsStash", stashVcs)
.handle("vcsStashPop", stashPopVcs)
.handle("command", getCommand)
.handle("agent", getAgent)
.handle("skill", getSkill)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,13 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
})

const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) {
const instance = yield* InstanceState.context
yield* workspace
.sessionWarp({
workspaceID: ctx.payload.id,
sessionID: ctx.payload.sessionID,
copyChanges: ctx.payload.copyChanges,
directory: ctx.payload.id === null ? instance.directory : undefined,
})
.pipe(
Effect.mapError((error) => {
Expand Down
Loading
Loading