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
+1 -1
View File
@@ -1934,7 +1934,7 @@ export default function Layout(props: ParentProps) {
if (!created?.directory) return 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 local = project.worktree
const key = pathKey(created.directory) const key = pathKey(created.directory)
@@ -22,11 +22,10 @@ export const WorktreeAdapter: WorkspaceAdapter = {
description: "Create a git worktree", description: "Create a git worktree",
async configure(info) { async configure(info) {
const { AppRuntime, Worktree } = await loadWorktree() 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 { return {
...info, ...info,
name: next.name, name: next.name,
branch: next.branch,
directory: next.directory, directory: next.directory,
} }
}, },
@@ -38,7 +37,7 @@ export const WorktreeAdapter: WorkspaceAdapter = {
svc.createFromInfo({ svc.createFromInfo({
name: config.name, name: config.name,
directory: config.directory, 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) => ({ return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({
type: "worktree", type: "worktree",
name: info.name, name: info.name,
branch: info.branch ?? null, branch: info.branch,
directory: info.directory, directory: info.directory,
extra: null,
projectID: Instance.project.id, projectID: Instance.project.id,
})) }))
}, },
+3 -3
View File
@@ -7,9 +7,9 @@ export const WorkspaceInfo = Schema.Struct({
id: WorkspaceID, id: WorkspaceID,
type: Schema.String, type: Schema.String,
name: Schema.String, name: Schema.String,
branch: Schema.NullOr(Schema.String), branch: Schema.optional(Schema.NullOr(Schema.String)),
directory: Schema.NullOr(Schema.String), directory: Schema.optional(Schema.NullOr(Schema.String)),
extra: Schema.NullOr(Schema.Unknown), extra: Schema.optional(Schema.NullOr(Schema.Unknown)),
projectID: ProjectID, projectID: ProjectID,
}).annotate({ identifier: "Workspace" }) }).annotate({ identifier: "Workspace" })
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>> export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
+30 -19
View File
@@ -29,7 +29,7 @@ export const Event = {
"worktree.ready", "worktree.ready",
Schema.Struct({ Schema.Struct({
name: Schema.String, name: Schema.String,
branch: Schema.String, branch: Schema.optional(Schema.String),
}), }),
), ),
Failed: BusEvent.define( Failed: BusEvent.define(
@@ -42,7 +42,7 @@ export const Event = {
export const Info = Schema.Struct({ export const Info = Schema.Struct({
name: Schema.String, name: Schema.String,
branch: Schema.String, branch: Schema.optional(Schema.String),
directory: Schema.String, directory: Schema.String,
}).annotate({ identifier: "Worktree" }) }).annotate({ identifier: "Worktree" })
export type Info = Schema.Schema.Type<typeof Info> export type Info = Schema.Schema.Type<typeof Info>
@@ -143,7 +143,7 @@ function failedRemoves(...chunks: string[]) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface Interface { 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 createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
readonly create: (input?: CreateInput) => Effect.Effect<Info> readonly create: (input?: CreateInput) => Effect.Effect<Info>
readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[]> readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[]>
@@ -194,25 +194,34 @@ export const layer: Layer.Layer<
) )
const MAX_NAME_ATTEMPTS = 26 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 const ctx = yield* InstanceState.context
for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) { for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create() const name = input.name ? (attempt === 0 ? input.name : `${input.name}-${Slug.create()}`) : Slug.create()
const branch = `opencode/${name}` const branch = input.detached ? undefined : `opencode/${name}`
const directory = pathSvc.join(root, name) const directory = pathSvc.join(input.root, name)
if (yield* fs.exists(directory).pipe(Effect.orDie)) continue if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
const ref = `refs/heads/${branch}` if (branch) {
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree }) const ref = `refs/heads/${branch}`
if (branchCheck.code === 0) continue 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" }) 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 const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") { if (ctx.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" }) 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) const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id)
yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie) yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
const base = name ? slugify(name) : "" return yield* candidate({ root, name: input?.name ? slugify(input.name) : "", detached: input?.detached })
return yield* candidate(root, base || undefined)
}) })
const setup = Effect.fnUntraced(function* (info: Info) { const setup = Effect.fnUntraced(function* (info: Info) {
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { const created = yield* git(
cwd: ctx.worktree, 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) { if (created.code !== 0) {
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
} }
@@ -280,7 +291,7 @@ export const layer: Layer.Layer<
workspace: workspaceID, workspace: workspaceID,
payload: { payload: {
type: Event.Ready.type, 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 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) yield* createFromInfo(info, input?.startCommand)
return info return info
}) })
@@ -21,13 +21,13 @@ function normalize(input: string) {
async function waitReady() { async function waitReady() {
const { GlobalBus } = await import("../../src/bus/global") 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(() => { const timer = setTimeout(() => {
GlobalBus.off("event", on) GlobalBus.off("event", on)
reject(new Error("timed out waiting for worktree.ready")) reject(new Error("timed out waiting for worktree.ready"))
}, 10_000) }, 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 if (evt.payload.type !== Worktree.Event.Ready.type) return
clearTimeout(timer) clearTimeout(timer)
GlobalBus.off("event", on) GlobalBus.off("event", on)
@@ -63,7 +63,7 @@ describe("Worktree", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service 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.name).toBe("my-feature")
expect(info.branch).toBe("opencode/my-feature") expect(info.branch).toBe("opencode/my-feature")
@@ -77,7 +77,7 @@ describe("Worktree", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service 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") 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", () => it.live("throws NotGitError for non-git directories", () =>
provideTmpdirInstance(() => provideTmpdirInstance(() =>
Effect.gen(function* () { 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", () => { describe("create + remove lifecycle", () => {
@@ -107,7 +152,7 @@ describe("Worktree", () => {
const info = yield* svc.create() const info = yield* svc.create()
expect(info.name).toBeDefined() expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/") expect(info.branch ?? "").toStartWith("opencode/")
expect(info.directory).toBeDefined() expect(info.directory).toBeDefined()
yield* Effect.promise(() => Bun.sleep(1000)) yield* Effect.promise(() => Bun.sleep(1000))
@@ -128,7 +173,7 @@ describe("Worktree", () => {
const info = yield* svc.create() const info = yield* svc.create()
expect(info.name).toBeDefined() 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 text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory)) const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory))
@@ -183,7 +228,7 @@ describe("Worktree", () => {
(dir) => (dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service 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() const ready = waitReady()
yield* svc.createFromInfo(info) yield* svc.createFromInfo(info)
@@ -216,10 +261,10 @@ describe("Worktree", () => {
const list = yield* svc.list() const list = yield* svc.list()
const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target)) 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), name: path.basename(parent),
branch, branch,
directory: directory.toLowerCase(), directory: normalize(directory),
}) })
yield* svc.remove({ directory: target }) yield* svc.remove({ directory: target })
+6 -6
View File
@@ -1398,7 +1398,7 @@ export type WorktreeCreateInput = {
export type Worktree = { export type Worktree = {
name: string name: string
branch: string branch?: string
directory: string directory: string
} }
@@ -1795,9 +1795,9 @@ export type Workspace = {
id: string id: string
type: string type: string
name: string name: string
branch: string | null branch?: string | null
directory: string | null directory?: string | null
extra: unknown | null extra?: unknown | null
projectID: string projectID: string
timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
} }
@@ -2566,7 +2566,7 @@ export type EventWorktreeReady = {
type: "worktree.ready" type: "worktree.ready"
properties: { properties: {
name: string name: string
branch: string branch?: string
} }
} }
@@ -6772,7 +6772,7 @@ export type ExperimentalWorkspaceCreateData = {
body?: { body?: {
id?: string id?: string
type: string type: string
branch: string | null branch?: string | null
extra?: unknown | null extra?: unknown | null
} }
path?: never path?: never
+4 -4
View File
@@ -8540,7 +8540,7 @@
] ]
} }
}, },
"required": ["type", "branch"], "required": ["type"],
"additionalProperties": false "additionalProperties": false
} }
} }
@@ -12803,7 +12803,7 @@
"type": "string" "type": "string"
} }
}, },
"required": ["name", "branch", "directory"], "required": ["name", "directory"],
"additionalProperties": false "additionalProperties": false
}, },
"WorktreeRemoveInput": { "WorktreeRemoveInput": {
@@ -14027,7 +14027,7 @@
] ]
} }
}, },
"required": ["id", "type", "name", "branch", "directory", "extra", "projectID", "timeUsed"], "required": ["id", "type", "name", "projectID", "timeUsed"],
"additionalProperties": false "additionalProperties": false
}, },
"WorkspaceWarpError": { "WorkspaceWarpError": {
@@ -16580,7 +16580,7 @@
"type": "string" "type": "string"
} }
}, },
"required": ["name", "branch"], "required": ["name"],
"additionalProperties": false "additionalProperties": false
} }
}, },