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
|
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,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>>
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user