fix(core): always start worktrees as detached (#26931)

This commit is contained in:
James Long
2026-05-11 16:22:25 -04:00
committed by GitHub
parent 42a0453945
commit 9067218b74
7 changed files with 101 additions and 47 deletions

View File

@@ -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,
}))
},

View File

@@ -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<Schema.Schema.Type<typeof WorkspaceInfo>>

View File

@@ -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<typeof Info>
@@ -143,7 +143,7 @@ function failedRemoves(...chunks: string[]) {
// ---------------------------------------------------------------------------
export interface Interface {
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect<Info>
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
readonly create: (input?: CreateInput) => Effect.Effect<Info>
readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { 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
})