mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-15 00:52:35 +00:00
fix(core): always start worktrees as detached (#26931)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
},
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user