Преглед изворни кода

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

James Long пре 3 месеци
родитељ
комит
9067218b74

+ 1 - 1
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)

+ 3 - 5
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,
     }))
   },

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

+ 30 - 19
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<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
     })

+ 54 - 9
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 })

+ 6 - 6
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

+ 4 - 4
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
           }
         },