Przeglądaj źródła

core: add sandbox support for git worktrees to allow working in multiple directories per project

Dax Raad 8 miesięcy temu
rodzic
commit
0b4af95223

+ 2 - 2
packages/opencode/src/project/instance.ts

@@ -19,10 +19,10 @@ export const Instance = {
     if (!existing) {
       Log.Default.info("creating instance", { directory: input.directory })
       existing = iife(async () => {
-        const project = await Project.fromDirectory(input.directory)
+        const { project, sandbox } = await Project.fromDirectory(input.directory)
         const ctx = {
           directory: input.directory,
-          worktree: project.worktree,
+          worktree: sandbox,
           project,
         }
         await context.provide(ctx, async () => {

+ 35 - 27
packages/opencode/src/project/project.ts

@@ -32,7 +32,7 @@ export namespace Project {
         updated: z.number(),
         initialized: z.number().optional(),
       }),
-      sandboxes: z.array(z.string()).optional(),
+      sandboxes: z.array(z.string()),
     })
     .meta({
       ref: "Project",
@@ -46,21 +46,25 @@ export namespace Project {
   export async function fromDirectory(directory: string) {
     log.info("fromDirectory", { directory })
 
-    const { id, worktree, vcs } = await iife(async () => {
+    const { id, sandbox, worktree, vcs } = await iife(async () => {
       const matches = Filesystem.up({ targets: [".git"], start: directory })
       const git = await matches.next().then((x) => x.value)
       await matches.return()
       if (git) {
-        let worktree = path.dirname(git)
+        let sandbox = path.dirname(git)
+
+        // cached id calculation
         let id = await Bun.file(path.join(git, "opencode"))
           .text()
           .then((x) => x.trim())
           .catch(() => {})
+
+        // generate id from root commit
         if (!id) {
           const roots = await $`git rev-list --max-parents=0 --all`
             .quiet()
             .nothrow()
-            .cwd(worktree)
+            .cwd(sandbox)
             .text()
             .then((x) =>
               x
@@ -72,28 +76,43 @@ export namespace Project {
           id = roots[0]
           if (id) Bun.file(path.join(git, "opencode")).write(id)
         }
+
         if (!id)
           return {
             id: "global",
-            worktree,
+            worktree: sandbox,
+            sandbox: sandbox,
             vcs: "git",
           }
-        worktree = await $`git rev-parse --git-common-dir`
+
+        sandbox = await $`git rev-parse --show-toplevel`
+          .quiet()
+          .nothrow()
+          .cwd(sandbox)
+          .text()
+          .then((x) => path.resolve(sandbox, x.trim()))
+        const worktree = await $`git rev-parse --git-common-dir`
           .quiet()
           .nothrow()
-          .cwd(worktree)
+          .cwd(sandbox)
           .text()
           .then((x) => {
             const dirname = path.dirname(x.trim())
-            if (dirname === ".") return worktree
+            if (dirname === ".") return sandbox
             return dirname
           })
-        return { id, worktree, vcs: "git" }
+        return {
+          id,
+          sandbox,
+          worktree,
+          vcs: "git",
+        }
       }
 
       return {
         id: "global",
         worktree: "/",
+        sandbox: "/",
         vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
       }
     })
@@ -104,6 +123,7 @@ export namespace Project {
         id,
         worktree,
         vcs: vcs as Info["vcs"],
+        sandboxes: [],
         time: {
           created: Date.now(),
           updated: Date.now(),
@@ -113,6 +133,10 @@ export namespace Project {
         await migrateFromGlobal(id, worktree)
       }
     }
+
+    // migrate old projects before sandboxes
+    if (!existing.sandboxes) existing.sandboxes = []
+
     if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
     const result: Info = {
       ...existing,
@@ -123,6 +147,7 @@ export namespace Project {
         updated: Date.now(),
       },
     }
+    if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox)
     await Storage.write<Info>(["project", id], result)
     GlobalBus.emit("event", {
       payload: {
@@ -130,7 +155,7 @@ export namespace Project {
         properties: result,
       },
     })
-    return result
+    return { project: result, sandbox }
   }
 
   export async function discover(input: Info) {
@@ -225,23 +250,6 @@ export namespace Project {
     },
   )
 
-  export async function addSandbox(projectID: string, directory: string) {
-    const result = await Storage.update<Info>(["project", projectID], (draft) => {
-      if (!draft.sandboxes) draft.sandboxes = []
-      if (!draft.sandboxes.includes(directory)) {
-        draft.sandboxes.push(directory)
-      }
-      draft.time.updated = Date.now()
-    })
-    GlobalBus.emit("event", {
-      payload: {
-        type: Event.Updated.type,
-        properties: result,
-      },
-    })
-    return result
-  }
-
   export async function sandboxes(projectID: string) {
     const project = await Storage.read<Info>(["project", projectID]).catch(() => undefined)
     if (!project?.sandboxes) return []

+ 0 - 2
packages/opencode/src/worktree/index.ts

@@ -200,8 +200,6 @@ export namespace Worktree {
       throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
     }
 
-    await Project.addSandbox(Instance.project.id, info.directory)
-
     const cmd = input?.startCommand?.trim()
     if (!cmd) return info
 

+ 52 - 4
packages/opencode/test/project/project.test.ts

@@ -13,7 +13,7 @@ describe("Project.fromDirectory", () => {
     await using tmp = await tmpdir()
     await $`git init`.cwd(tmp.path).quiet()
 
-    const project = await Project.fromDirectory(tmp.path)
+    const { project } = await Project.fromDirectory(tmp.path)
 
     expect(project).toBeDefined()
     expect(project.id).toBe("global")
@@ -28,7 +28,7 @@ describe("Project.fromDirectory", () => {
   test("should handle git repository with commits", async () => {
     await using tmp = await tmpdir({ git: true })
 
-    const project = await Project.fromDirectory(tmp.path)
+    const { project } = await Project.fromDirectory(tmp.path)
 
     expect(project).toBeDefined()
     expect(project.id).not.toBe("global")
@@ -41,10 +41,58 @@ describe("Project.fromDirectory", () => {
   })
 })
 
+describe("Project.fromDirectory with worktrees", () => {
+  test("should set worktree to root when called from root", async () => {
+    await using tmp = await tmpdir({ git: true })
+
+    const { project, sandbox } = await Project.fromDirectory(tmp.path)
+
+    expect(project.worktree).toBe(tmp.path)
+    expect(sandbox).toBe(tmp.path)
+    expect(project.sandboxes).not.toContain(tmp.path)
+  })
+
+  test("should set worktree to root when called from a worktree", async () => {
+    await using tmp = await tmpdir({ git: true })
+
+    const worktreePath = path.join(tmp.path, "..", "worktree-test")
+    await $`git worktree add ${worktreePath} -b test-branch`.cwd(tmp.path).quiet()
+
+    const { project, sandbox } = await Project.fromDirectory(worktreePath)
+
+    expect(project.worktree).toBe(tmp.path)
+    expect(sandbox).toBe(worktreePath)
+    expect(project.sandboxes).toContain(worktreePath)
+    expect(project.sandboxes).not.toContain(tmp.path)
+
+    await $`git worktree remove ${worktreePath}`.cwd(tmp.path).quiet()
+  })
+
+  test("should accumulate multiple worktrees in sandboxes", async () => {
+    await using tmp = await tmpdir({ git: true })
+
+    const worktree1 = path.join(tmp.path, "..", "worktree-1")
+    const worktree2 = path.join(tmp.path, "..", "worktree-2")
+    await $`git worktree add ${worktree1} -b branch-1`.cwd(tmp.path).quiet()
+    await $`git worktree add ${worktree2} -b branch-2`.cwd(tmp.path).quiet()
+
+    await Project.fromDirectory(worktree1)
+    const { project } = await Project.fromDirectory(worktree2)
+
+    expect(project.worktree).toBe(tmp.path)
+    expect(project.sandboxes).toContain(worktree1)
+    expect(project.sandboxes).toContain(worktree2)
+    expect(project.sandboxes).not.toContain(tmp.path)
+
+    await $`git worktree remove ${worktree1}`.cwd(tmp.path).quiet()
+    await $`git worktree remove ${worktree2}`.cwd(tmp.path).quiet()
+  })
+})
+
 describe("Project.discover", () => {
   test("should discover favicon.png in root", async () => {
     await using tmp = await tmpdir({ git: true })
-    const project = await Project.fromDirectory(tmp.path)
+    const { project } = await Project.fromDirectory(tmp.path)
 
     const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
     await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
@@ -60,7 +108,7 @@ describe("Project.discover", () => {
 
   test("should not discover non-image files", async () => {
     await using tmp = await tmpdir({ git: true })
-    const project = await Project.fromDirectory(tmp.path)
+    const { project } = await Project.fromDirectory(tmp.path)
 
     await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")