summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2026-01-03 18:52:53 -0500
committerDax Raad <[email protected]>2026-01-03 18:52:53 -0500
commit0b4af952239a7745f14810d3b8e71038167c967a (patch)
treef339134b1f56f3335b54bc61fa6bc25ff29478c9
parentf6cc84747a7adc26d8f27fbe6cd73ba2956dccf7 (diff)
downloadopencode-0b4af952239a7745f14810d3b8e71038167c967a.tar.gz
opencode-0b4af952239a7745f14810d3b8e71038167c967a.zip
core: add sandbox support for git worktrees to allow working in multiple directories per project
-rw-r--r--packages/opencode/src/project/instance.ts4
-rw-r--r--packages/opencode/src/project/project.ts62
-rw-r--r--packages/opencode/src/worktree/index.ts2
-rw-r--r--packages/opencode/test/project/project.test.ts56
4 files changed, 89 insertions, 35 deletions
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index 5291995a3..d668037c0 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/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 () => {
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index 42e285565..8b78553bf 100644
--- a/packages/opencode/src/project/project.ts
+++ b/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 []
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index 45ca54b04..0c05feb17 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/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
diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts
index 65126d1bf..d44e60674 100644
--- a/packages/opencode/test/project/project.test.ts
+++ b/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")