From 2361fc43ec279071b360781a0dc0b1ce4c41c444 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Mar 2026 13:33:41 -0600 Subject: [PATCH] fix(project): use git common dir for project ID cache to prevent worktree session loss Worktrees resolve .git to a per-worktree file, so caching the project ID there fails silently. Each restart recomputes the ID via rev-list --all, which is unstable for repos with orphan branches (gh-pages, stash roots). This causes sessions to become invisible after restarting opencode in a worktree. - Resolve --show-toplevel and --git-common-dir before reading the cache - Store and read the project ID cache from the shared .git directory - Switch rev-list from --all to HEAD to exclude orphan branches - Set local git config in test fixture for reproducible test runs --- packages/opencode/src/project/project.ts | 103 ++++++++---------- packages/opencode/test/fixture/fixture.ts | 2 + .../opencode/test/project/project.test.ts | 48 +++++++- 3 files changed, 93 insertions(+), 60 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 9cc12a0a4fc..b0b3b8eb1bc 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -96,27 +96,57 @@ export namespace Project { const dotgit = await matches.next().then((x) => x.value) await matches.return() if (dotgit) { - let sandbox = path.dirname(dotgit) + const raw = path.dirname(dotgit) const gitBinary = which("git") - // cached id calculation - let id = await Filesystem.readText(path.join(dotgit, "opencode")) - .then((x) => x.trim()) - .catch(() => undefined) - if (!gitBinary) { + const id = await Filesystem.readText(path.join(dotgit, "opencode")) + .then((x) => x.trim()) + .catch(() => undefined) return { id: id ?? "global", - worktree: sandbox, - sandbox: sandbox, + worktree: raw, + sandbox: raw, vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), } } - // generate id from root commit + // Resolve the worktree root and git common dir so the project ID + // cache is read from and written to the shared .git directory. This + // ensures all worktrees for the same repo resolve the same project ID. + const top = await git(["rev-parse", "--show-toplevel"], { + cwd: raw, + }) + .then(async (result) => gitpath(raw, await result.text())) + .catch(() => undefined) + + const sandbox = top ?? raw + + const common = await git(["rev-parse", "--git-common-dir"], { + cwd: sandbox, + }) + .then(async (result) => { + const dir = gitpath(sandbox, await result.text()) + return dir === sandbox ? undefined : dir + }) + .catch(() => undefined) + + const worktree = common ? path.dirname(common) : sandbox + + // Read cached project ID from the git common dir (shared across + // worktrees). Falls back to dotgit for repos where git-common-dir + // resolution failed. + const cache = common ? path.join(common, "opencode") : path.join(dotgit, "opencode") + let id = await Filesystem.readText(cache) + .then((x) => x.trim()) + .catch(() => undefined) + + // Generate id from root commit. Use HEAD instead of --all to avoid + // orphan branches (gh-pages, stash roots) from changing the sorted + // first root commit across restarts. if (!id) { - const roots = await git(["rev-list", "--max-parents=0", "--all"], { + const roots = await git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox, }) .then(async (result) => @@ -128,63 +158,18 @@ export namespace Project { ) .catch(() => undefined) - if (!roots) { - return { - id: "global", - worktree: sandbox, - sandbox: sandbox, - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), - } - } - - id = roots[0] + id = roots?.[0] if (id) { - await Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined) + await Filesystem.write(cache, id).catch(() => undefined) } } if (!id) { return { id: "global", - worktree: sandbox, - sandbox: sandbox, - vcs: "git", - } - } - - const top = await git(["rev-parse", "--show-toplevel"], { - cwd: sandbox, - }) - .then(async (result) => gitpath(sandbox, await result.text())) - .catch(() => undefined) - - if (!top) { - return { - id, - sandbox, - worktree: sandbox, - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), - } - } - - sandbox = top - - const worktree = await git(["rev-parse", "--git-common-dir"], { - cwd: sandbox, - }) - .then(async (result) => { - const common = gitpath(sandbox, await result.text()) - // Avoid going to parent of sandbox when git-common-dir is empty. - return common === sandbox ? sandbox : path.dirname(common) - }) - .catch(() => undefined) - - if (!worktree) { - return { - id, + worktree, sandbox, - worktree: sandbox, - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), + vcs: top ? "git" : Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), } } diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 63f93bcafe9..f2f864e8b19 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -42,6 +42,8 @@ export async function tmpdir(options?: TmpDirOptions) { if (options?.git) { await $`git init`.cwd(dirpath).quiet() await $`git config core.fsmonitor false`.cwd(dirpath).quiet() + await $`git config user.email "test@opencode.test"`.cwd(dirpath).quiet() + await $`git config user.name "Test"`.cwd(dirpath).quiet() await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet() } if (options?.config) { diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index fef9e4190e2..202a3725fab 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -22,7 +22,7 @@ mock.module("../../src/util/git", () => ({ mode === "rev-list-fail" && cmd.includes("git rev-list") && cmd.includes("--max-parents=0") && - cmd.includes("--all") + cmd.includes("HEAD") ) { return Promise.resolve({ exitCode: 128, @@ -171,6 +171,52 @@ describe("Project.fromDirectory with worktrees", () => { } }) + test("worktree should share project ID with main repo", async () => { + const p = await loadProject() + await using tmp = await tmpdir({ git: true }) + + const { project: main } = await p.fromDirectory(tmp.path) + + const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared") + try { + await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet() + + const { project: wt } = await p.fromDirectory(worktreePath) + + expect(wt.id).toBe(main.id) + + // Cache should live in the common .git dir, not the worktree's .git file + const cache = path.join(tmp.path, ".git", "opencode") + const exists = await Filesystem.exists(cache) + expect(exists).toBe(true) + } finally { + await $`git worktree remove ${worktreePath}` + .cwd(tmp.path) + .quiet() + .catch(() => {}) + } + }) + + test("separate clones of the same repo should share project ID", async () => { + const p = await loadProject() + await using tmp = await tmpdir({ git: true }) + + // Create a bare remote, push, then clone into a second directory + const bare = tmp.path + "-bare" + const clone = tmp.path + "-clone" + try { + await $`git clone --bare ${tmp.path} ${bare}`.quiet() + await $`git clone ${bare} ${clone}`.quiet() + + const { project: a } = await p.fromDirectory(tmp.path) + const { project: b } = await p.fromDirectory(clone) + + expect(b.id).toBe(a.id) + } finally { + await $`rm -rf ${bare} ${clone}`.quiet().nothrow() + } + }) + test("should accumulate multiple worktrees in sandboxes", async () => { const p = await loadProject() await using tmp = await tmpdir({ git: true })