From 9067218b74874bdffd3a53142c6b2d0ff65bb479 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 11 May 2026 16:22:25 -0400 Subject: [PATCH] fix(core): always start worktrees as detached (#26931) --- packages/app/src/pages/layout.tsx | 2 +- .../src/control-plane/adapters/worktree.ts | 8 +-- packages/opencode/src/control-plane/types.ts | 6 +- packages/opencode/src/worktree/index.ts | 49 +++++++++------ .../opencode/test/project/worktree.test.ts | 63 ++++++++++++++++--- packages/sdk/js/src/v2/gen/types.gen.ts | 12 ++-- packages/sdk/openapi.json | 8 +-- 7 files changed, 101 insertions(+), 47 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index a08372649f..45fcc6ee27 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1934,7 +1934,7 @@ export default function Layout(props: ParentProps) { if (!created?.directory) return - setWorkspaceName(created.directory, created.branch, project.id, created.branch) + setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch) const local = project.worktree const key = pathKey(created.directory) diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index 605d114ace..1c85d125a2 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -22,11 +22,10 @@ export const WorktreeAdapter: WorkspaceAdapter = { description: "Create a git worktree", async configure(info) { const { AppRuntime, Worktree } = await loadWorktree() - const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo())) + const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true }))) return { ...info, name: next.name, - branch: next.branch, directory: next.directory, } }, @@ -38,7 +37,7 @@ export const WorktreeAdapter: WorkspaceAdapter = { svc.createFromInfo({ name: config.name, directory: config.directory, - branch: config.branch ?? config.name, + ...(config.branch ? { branch: config.branch } : {}), }), ), ) @@ -48,9 +47,8 @@ export const WorktreeAdapter: WorkspaceAdapter = { return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({ type: "worktree", name: info.name, - branch: info.branch ?? null, + branch: info.branch, directory: info.directory, - extra: null, projectID: Instance.project.id, })) }, diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index e78d728e04..e55ae2194e 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -7,9 +7,9 @@ export const WorkspaceInfo = Schema.Struct({ id: WorkspaceID, type: Schema.String, name: Schema.String, - branch: Schema.NullOr(Schema.String), - directory: Schema.NullOr(Schema.String), - extra: Schema.NullOr(Schema.Unknown), + branch: Schema.optional(Schema.NullOr(Schema.String)), + directory: Schema.optional(Schema.NullOr(Schema.String)), + extra: Schema.optional(Schema.NullOr(Schema.Unknown)), projectID: ProjectID, }).annotate({ identifier: "Workspace" }) export type WorkspaceInfo = DeepMutable> diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index a6599debdf..439f36e0a9 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -29,7 +29,7 @@ export const Event = { "worktree.ready", Schema.Struct({ name: Schema.String, - branch: Schema.String, + branch: Schema.optional(Schema.String), }), ), Failed: BusEvent.define( @@ -42,7 +42,7 @@ export const Event = { export const Info = Schema.Struct({ name: Schema.String, - branch: Schema.String, + branch: Schema.optional(Schema.String), directory: Schema.String, }).annotate({ identifier: "Worktree" }) export type Info = Schema.Schema.Type @@ -143,7 +143,7 @@ function failedRemoves(...chunks: string[]) { // --------------------------------------------------------------------------- export interface Interface { - readonly makeWorktreeInfo: (name?: string) => Effect.Effect + readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect readonly create: (input?: CreateInput) => Effect.Effect readonly list: () => Effect.Effect<(Omit & { branch?: string })[]> @@ -194,25 +194,34 @@ export const layer: Layer.Layer< ) const MAX_NAME_ATTEMPTS = 26 - const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) { + const candidate = Effect.fn("Worktree.candidate")(function* (input: { + root: string + name?: string + detached?: boolean + }) { const ctx = yield* InstanceState.context for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) { - const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create() - const branch = `opencode/${name}` - const directory = pathSvc.join(root, name) + const name = input.name ? (attempt === 0 ? input.name : `${input.name}-${Slug.create()}`) : Slug.create() + const branch = input.detached ? undefined : `opencode/${name}` + const directory = pathSvc.join(input.root, name) if (yield* fs.exists(directory).pipe(Effect.orDie)) continue - const ref = `refs/heads/${branch}` - const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree }) - if (branchCheck.code === 0) continue + if (branch) { + const ref = `refs/heads/${branch}` + const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree }) + if (branchCheck.code === 0) continue + } - return { name, branch, directory } + return { name, directory, ...(branch ? { branch } : {}) } } throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) }) - const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) { + const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (input?: { + name?: string + detached?: boolean + }) { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) @@ -221,15 +230,17 @@ export const layer: Layer.Layer< const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id) yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie) - const base = name ? slugify(name) : "" - return yield* candidate(root, base || undefined) + return yield* candidate({ root, name: input?.name ? slugify(input.name) : "", detached: input?.detached }) }) const setup = Effect.fnUntraced(function* (info: Info) { const ctx = yield* InstanceState.context - const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { - cwd: ctx.worktree, - }) + const created = yield* git( + info.branch + ? ["worktree", "add", "--no-checkout", "-b", info.branch, info.directory] + : ["worktree", "add", "--no-checkout", "--detach", info.directory, "HEAD"], + { cwd: ctx.worktree }, + ) if (created.code !== 0) { throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) } @@ -280,7 +291,7 @@ export const layer: Layer.Layer< workspace: workspaceID, payload: { type: Event.Ready.type, - properties: { name: info.name, branch: info.branch }, + properties: { name: info.name, ...(info.branch ? { branch: info.branch } : {}) }, }, }) @@ -296,7 +307,7 @@ export const layer: Layer.Layer< }) const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) { - const info = yield* makeWorktreeInfo(input?.name) + const info = yield* makeWorktreeInfo({ name: input?.name }) yield* createFromInfo(info, input?.startCommand) return info }) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 4f0ead54e4..b1b9d22b73 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -21,13 +21,13 @@ function normalize(input: string) { async function waitReady() { const { GlobalBus } = await import("../../src/bus/global") - return await new Promise<{ name: string; branch: string }>((resolve, reject) => { + return await new Promise<{ name: string; branch?: string }>((resolve, reject) => { const timer = setTimeout(() => { GlobalBus.off("event", on) reject(new Error("timed out waiting for worktree.ready")) }, 10_000) - function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch: string } } }) { + function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch?: string } } }) { if (evt.payload.type !== Worktree.Event.Ready.type) return clearTimeout(timer) GlobalBus.off("event", on) @@ -63,7 +63,7 @@ describe("Worktree", () => { () => Effect.gen(function* () { const svc = yield* Worktree.Service - const info = yield* svc.makeWorktreeInfo("my-feature") + const info = yield* svc.makeWorktreeInfo({ name: "my-feature" }) expect(info.name).toBe("my-feature") expect(info.branch).toBe("opencode/my-feature") @@ -77,7 +77,7 @@ describe("Worktree", () => { () => Effect.gen(function* () { const svc = yield* Worktree.Service - const info = yield* svc.makeWorktreeInfo("My Feature Branch!") + const info = yield* svc.makeWorktreeInfo({ name: "My Feature Branch!" }) expect(info.name).toBe("my-feature-branch") }), @@ -85,6 +85,22 @@ describe("Worktree", () => { ), ) + it.live("omits branch for detached info", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const svc = yield* Worktree.Service + yield* Effect.promise(() => $`git branch opencode/my-feature`.cwd(dir).quiet()) + + const info = yield* svc.makeWorktreeInfo({ name: "my-feature", detached: true }) + + expect(info.name).toBe("my-feature") + expect(info.branch).toBeUndefined() + }), + { git: true }, + ), + ) + it.live("throws NotGitError for non-git directories", () => provideTmpdirInstance(() => Effect.gen(function* () { @@ -96,6 +112,35 @@ describe("Worktree", () => { }), ), ) + + wintest("creates detached git worktree when info has no branch", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true }) + const ready = waitReady() + yield* svc.createFromInfo(info) + + const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) + const normalizedList = normalize(list) + const normalizedDir = normalize(info.directory) + expect(normalizedList).toContain(normalizedDir) + + const branch = yield* Effect.promise(() => + $`git symbolic-ref -q --short HEAD`.cwd(info.directory).quiet().nothrow(), + ) + expect(branch.exitCode).not.toBe(0) + + const props = yield* Effect.promise(() => ready) + expect(props.name).toBe(info.name) + expect(props.branch).toBeUndefined() + + yield* svc.remove({ directory: info.directory }) + }), + { git: true }, + ), + ) }) describe("create + remove lifecycle", () => { @@ -107,7 +152,7 @@ describe("Worktree", () => { const info = yield* svc.create() expect(info.name).toBeDefined() - expect(info.branch).toStartWith("opencode/") + expect(info.branch ?? "").toStartWith("opencode/") expect(info.directory).toBeDefined() yield* Effect.promise(() => Bun.sleep(1000)) @@ -128,7 +173,7 @@ describe("Worktree", () => { const info = yield* svc.create() expect(info.name).toBeDefined() - expect(info.branch).toStartWith("opencode/") + expect(info.branch ?? "").toStartWith("opencode/") const text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory)) @@ -183,7 +228,7 @@ describe("Worktree", () => { (dir) => Effect.gen(function* () { const svc = yield* Worktree.Service - const info = yield* svc.makeWorktreeInfo("from-info-test") + const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" }) const ready = waitReady() yield* svc.createFromInfo(info) @@ -216,10 +261,10 @@ describe("Worktree", () => { const list = yield* svc.list() const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target)) - expect(list).toContainEqual({ + expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({ name: path.basename(parent), branch, - directory: directory.toLowerCase(), + directory: normalize(directory), }) yield* svc.remove({ directory: target }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index da80645ad7..c7a479f5ac 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1398,7 +1398,7 @@ export type WorktreeCreateInput = { export type Worktree = { name: string - branch: string + branch?: string directory: string } @@ -1795,9 +1795,9 @@ export type Workspace = { id: string type: string name: string - branch: string | null - directory: string | null - extra: unknown | null + branch?: string | null + directory?: string | null + extra?: unknown | null projectID: string timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } @@ -2566,7 +2566,7 @@ export type EventWorktreeReady = { type: "worktree.ready" properties: { name: string - branch: string + branch?: string } } @@ -6772,7 +6772,7 @@ export type ExperimentalWorkspaceCreateData = { body?: { id?: string type: string - branch: string | null + branch?: string | null extra?: unknown | null } path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index df0427f455..3d452cc9c0 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8540,7 +8540,7 @@ ] } }, - "required": ["type", "branch"], + "required": ["type"], "additionalProperties": false } } @@ -12803,7 +12803,7 @@ "type": "string" } }, - "required": ["name", "branch", "directory"], + "required": ["name", "directory"], "additionalProperties": false }, "WorktreeRemoveInput": { @@ -14027,7 +14027,7 @@ ] } }, - "required": ["id", "type", "name", "branch", "directory", "extra", "projectID", "timeUsed"], + "required": ["id", "type", "name", "projectID", "timeUsed"], "additionalProperties": false }, "WorkspaceWarpError": { @@ -16580,7 +16580,7 @@ "type": "string" } }, - "required": ["name", "branch"], + "required": ["name"], "additionalProperties": false } },