From 80584cd115a29f1c27d4b3621bce57499b7efb1a Mon Sep 17 00:00:00 2001 From: "Steven T. Cramer" Date: Wed, 18 Mar 2026 13:14:29 +0700 Subject: [PATCH] fix(project): cache project ID inside git common dir, not parent org For bare repos (e.g. opencode.git), the previous code used path.dirname(common) to get the worktree, then wrote the cache to worktree/.git/opencode. But path.dirname on 'opencode.git' gives the parent org directory, so all repos under that org shared one cache file and got the same project_id. Now we use the git common dir directly (e.g. opencode.git) for cache reads/writes, ensuring each repo gets its own cache. --- packages/opencode/src/project/project.ts | 28 +++++++++++++------ .../opencode/test/project/project.test.ts | 22 +++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 1cef41c85c91..7b43bf0edc40 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -119,17 +119,25 @@ export namespace Project { } } - const worktree = await git(["rev-parse", "--git-common-dir"], { + const common = await git(["rev-parse", "--git-common-dir"], { cwd: sandbox, }) .then(async (result) => { - const common = gitpath(sandbox, await result.text()) + const resolved = gitpath(sandbox, await result.text()) // Avoid going to parent of sandbox when git-common-dir is empty. - return common === sandbox ? sandbox : path.dirname(common) + return resolved === sandbox ? sandbox : resolved }) .catch(() => undefined) - if (!worktree) { + // worktree is the repo root: parent of .git for normal repos, + // but for bare repos (e.g. opencode.git) path.dirname gives + // the parent directory which may be shared across repos. + // If common resolves to sandbox (empty git-common-dir output), + // preserve sandbox to keep previous fallback behavior. + // Only used for the returned project info, not for cache paths. + const worktree = common ? (common === sandbox ? sandbox : path.dirname(common)) : undefined + + if (!worktree || !common) { return { id: id ?? ProjectID.global, worktree: sandbox, @@ -140,9 +148,11 @@ export namespace Project { // In the case of a git worktree, it can't cache the id // because `.git` is not a folder, but it always needs the - // same project id as the common dir, so we resolve it now + // same project id as the common dir, so we resolve it now. + // Use `common` directly (the git dir) instead of reconstructing + // worktree + "/.git" which breaks for bare repos (e.g. repo.git). if (id == null) { - id = await readCachedId(path.join(worktree, ".git")) + id = await readCachedId(common) } // generate id from root commit @@ -170,8 +180,10 @@ export namespace Project { id = roots[0] ? ProjectID.make(roots[0]) : undefined if (id) { - // Write to common dir so the cache is shared across worktrees. - await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined) + // Write cache inside the git common dir itself, not worktree/.git. + // For bare repos (e.g. opencode.git), worktree/.git would resolve + // to the parent org directory's .git, corrupting other repos. + await Filesystem.write(path.join(common, "opencode"), id).catch(() => undefined) } } diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index a71fe0528f02..8b1216049f4d 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -218,6 +218,28 @@ describe("Project.fromDirectory with worktrees", () => { } }) + test("different bare repos under same parent should not share project ID", async () => { + const p = await loadProject() + await using parent = await tmpdir() + await using repoA = await tmpdir({ git: true }) + await using repoB = await tmpdir({ git: true }) + + const bareA = path.join(parent.path, "repo-a.git") + const bareB = path.join(parent.path, "repo-b.git") + const wtA = path.join(parent.path, "wt-a") + const wtB = path.join(parent.path, "wt-b") + + await $`git clone --bare ${repoA.path} ${bareA}`.quiet() + await $`git clone --bare ${repoB.path} ${bareB}`.quiet() + await $`git --git-dir=${bareA} worktree add ${wtA} -b wt-a-${Date.now()} HEAD`.quiet() + await $`git --git-dir=${bareB} worktree add ${wtB} -b wt-b-${Date.now()} HEAD`.quiet() + + const { project: a } = await p.fromDirectory(wtA) + const { project: b } = await p.fromDirectory(wtB) + + expect(a.id).not.toBe(b.id) + }) + test("should accumulate multiple worktrees in sandboxes", async () => { const p = await loadProject() await using tmp = await tmpdir({ git: true })