From 75c1d6fb47e12af2065cbf59886bb155e25d0298 Mon Sep 17 00:00:00 2001 From: jim Date: Tue, 17 Mar 2026 10:37:04 +0000 Subject: [PATCH] fix(project): use path-aware project identity to prevent clone collisions When multiple local clones of the same git repo exist, the project ID was derived solely from the root commit hash, causing all clones to share one snapshot directory and project record. The last-opened clone would overwrite the worktree path, breaking file tracking for others. Combine the root commit with the worktree path (Hash.fast) so that separate clones get distinct project IDs while git worktrees of the same repo continue to share one ID via their common root. Fixes #17940 --- packages/opencode/src/project/project.ts | 16 +++++++++++----- packages/opencode/test/project/project.test.ts | 8 ++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 1cef41c85c91..bbc46162a051 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -13,6 +13,7 @@ import { GlobalBus } from "@/bus/global" import { existsSync } from "fs" import { git } from "../util/git" import { Glob } from "../util/glob" +import { Hash } from "../util/hash" import { which } from "../util/which" import { ProjectID } from "./schema" @@ -107,10 +108,8 @@ export namespace Project { const gitBinary = which("git") - // cached id calculation - let id = await readCachedId(dotgit) - if (!gitBinary) { + const id = await readCachedId(dotgit) return { id: id ?? ProjectID.global, worktree: sandbox, @@ -130,6 +129,7 @@ export namespace Project { .catch(() => undefined) if (!worktree) { + const id = await readCachedId(dotgit) return { id: id ?? ProjectID.global, worktree: sandbox, @@ -138,10 +138,13 @@ export namespace Project { } } + // cached id calculation + let id = await readCachedId(dotgit) + // 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 - if (id == null) { + if (id == null && sandbox !== worktree) { id = await readCachedId(path.join(worktree, ".git")) } @@ -168,7 +171,10 @@ export namespace Project { } } - id = roots[0] ? ProjectID.make(roots[0]) : undefined + // Combine the root commit with the worktree path so that + // separate clones (different worktree paths) get distinct IDs + // while git worktrees (same common root) share one ID. + id = roots[0] ? ProjectID.make(Hash.fast(roots[0] + "\0" + worktree)) : 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) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index a71fe0528f02..14b4b9fade85 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -198,7 +198,7 @@ describe("Project.fromDirectory with worktrees", () => { } }) - test("separate clones of the same repo should share project ID", async () => { + test("separate clones of the same repo should get distinct project IDs", async () => { const p = await loadProject() await using tmp = await tmpdir({ git: true }) @@ -212,7 +212,11 @@ describe("Project.fromDirectory with worktrees", () => { const { project: a } = await p.fromDirectory(tmp.path) const { project: b } = await p.fromDirectory(clone) - expect(b.id).toBe(a.id) + // Separate clones must get distinct IDs so they each have their + // own snapshot, icon, and name -- preventing the bug where the + // last-opened clone's worktree overwrites the snapshot path for + // all clones. + expect(b.id).not.toBe(a.id) } finally { await $`rm -rf ${bare} ${clone}`.quiet().nothrow() }