Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 44 additions & 59 deletions packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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),
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/test/fixture/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
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) {
Expand Down
48 changes: 47 additions & 1 deletion packages/opencode/test/project/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 })
Expand Down
Loading