From 69a45ef7d73691f6ed1f01f4e603ca554a9575d7 Mon Sep 17 00:00:00 2001 From: Chris Olszewski <135140628+chrisolszewski@users.noreply.github.com> Date: Sat, 15 Nov 2025 02:02:00 -0500 Subject: [PATCH] fix: snapshot history when running from git worktrees (#4312) --- packages/opencode/src/snapshot/index.ts | 52 ++++++--- .../opencode/test/snapshot/snapshot.test.ts | 109 ++++++++++++++++++ 2 files changed, 143 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index cf051def..e8500e09 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -26,8 +26,12 @@ export namespace Snapshot { .nothrow() log.info("initialized") } - await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow() - const hash = await $`git --git-dir ${git} write-tree`.quiet().cwd(Instance.directory).nothrow().text() + await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow() + const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree` + .quiet() + .cwd(Instance.directory) + .nothrow() + .text() log.info("tracking", { hash, cwd: Instance.directory, git }) return hash.trim() } @@ -40,8 +44,11 @@ export namespace Snapshot { export async function patch(hash: string): Promise { const git = gitdir() - await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow() - const result = await $`git --git-dir ${git} diff --name-only ${hash} -- .`.quiet().cwd(Instance.directory).nothrow() + await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow() + const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} diff --name-only ${hash} -- .` + .quiet() + .cwd(Instance.directory) + .nothrow() // If git diff fails, return empty patch if (result.exitCode !== 0) { @@ -64,10 +71,11 @@ export namespace Snapshot { export async function restore(snapshot: string) { log.info("restore", { commit: snapshot }) const git = gitdir() - const result = await $`git --git-dir=${git} read-tree ${snapshot} && git --git-dir=${git} checkout-index -a -f` - .quiet() - .cwd(Instance.worktree) - .nothrow() + const result = + await $`git --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f` + .quiet() + .cwd(Instance.worktree) + .nothrow() if (result.exitCode !== 0) { log.error("failed to restore snapshot", { @@ -86,16 +94,17 @@ export namespace Snapshot { for (const file of item.files) { if (files.has(file)) continue log.info("reverting", { file, hash: item.hash }) - const result = await $`git --git-dir=${git} checkout ${item.hash} -- ${file}` + const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}` .quiet() .cwd(Instance.worktree) .nothrow() if (result.exitCode !== 0) { const relativePath = path.relative(Instance.worktree, file) - const checkTree = await $`git --git-dir=${git} ls-tree ${item.hash} -- ${relativePath}` - .quiet() - .cwd(Instance.worktree) - .nothrow() + const checkTree = + await $`git --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}` + .quiet() + .cwd(Instance.worktree) + .nothrow() if (checkTree.exitCode === 0 && checkTree.text().trim()) { log.info("file existed in snapshot but checkout failed, keeping", { file, @@ -112,8 +121,11 @@ export namespace Snapshot { export async function diff(hash: string) { const git = gitdir() - await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow() - const result = await $`git --git-dir=${git} diff ${hash} -- .`.quiet().cwd(Instance.worktree).nothrow() + await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow() + const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} diff ${hash} -- .` + .quiet() + .cwd(Instance.worktree) + .nothrow() if (result.exitCode !== 0) { log.warn("failed to get diff", { @@ -143,7 +155,7 @@ export namespace Snapshot { export async function diffFull(from: string, to: string): Promise { const git = gitdir() const result: FileDiff[] = [] - for await (const line of $`git --git-dir=${git} diff --no-renames --numstat ${from} ${to} -- .` + for await (const line of $`git --git-dir ${git} --work-tree ${Instance.worktree} diff --no-renames --numstat ${from} ${to} -- .` .quiet() .cwd(Instance.directory) .nothrow() @@ -151,8 +163,12 @@ export namespace Snapshot { if (!line) continue const [additions, deletions, file] = line.split("\t") const isBinaryFile = additions === "-" && deletions === "-" - const before = isBinaryFile ? "" : await $`git --git-dir=${git} show ${from}:${file}`.quiet().nothrow().text() - const after = isBinaryFile ? "" : await $`git --git-dir=${git} show ${to}:${file}`.quiet().nothrow().text() + const before = isBinaryFile + ? "" + : await $`git --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`.quiet().nothrow().text() + const after = isBinaryFile + ? "" + : await $`git --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`.quiet().nothrow().text() result.push({ file, before, diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index b72717cd..cf933f81 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -469,6 +469,115 @@ test("snapshot state isolation between projects", async () => { }) }) +test("patch detects changes in secondary worktree", async () => { + await using tmp = await bootstrap() + const worktreePath = `${tmp.path}-worktree` + await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(await Snapshot.track()).toBeTruthy() + }, + }) + + await Instance.provide({ + directory: worktreePath, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + const worktreeFile = `${worktreePath}/worktree.txt` + await Bun.write(worktreeFile, "worktree content") + + const patch = await Snapshot.patch(before!) + expect(patch.files).toContain(worktreeFile) + }, + }) + } finally { + await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow() + await $`rm -rf ${worktreePath}`.quiet() + } +}) + +test("revert only removes files in invoking worktree", async () => { + await using tmp = await bootstrap() + const worktreePath = `${tmp.path}-worktree` + await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(await Snapshot.track()).toBeTruthy() + }, + }) + const primaryFile = `${tmp.path}/worktree.txt` + await Bun.write(primaryFile, "primary content") + + await Instance.provide({ + directory: worktreePath, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + const worktreeFile = `${worktreePath}/worktree.txt` + await Bun.write(worktreeFile, "worktree content") + + const patch = await Snapshot.patch(before!) + await Snapshot.revert([patch]) + + expect(await Bun.file(worktreeFile).exists()).toBe(false) + }, + }) + + expect(await Bun.file(primaryFile).text()).toBe("primary content") + } finally { + await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow() + await $`rm -rf ${worktreePath}`.quiet() + await $`rm -f ${tmp.path}/worktree.txt`.quiet() + } +}) + +test("diff reports worktree-only/shared edits and ignores primary-only", async () => { + await using tmp = await bootstrap() + const worktreePath = `${tmp.path}-worktree` + await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(await Snapshot.track()).toBeTruthy() + }, + }) + + await Instance.provide({ + directory: worktreePath, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + await Bun.write(`${worktreePath}/worktree-only.txt`, "worktree diff content") + await Bun.write(`${worktreePath}/shared.txt`, "worktree edit") + await Bun.write(`${tmp.path}/shared.txt`, "primary edit") + await Bun.write(`${tmp.path}/primary-only.txt`, "primary change") + + const diff = await Snapshot.diff(before!) + expect(diff).toContain("worktree-only.txt") + expect(diff).toContain("shared.txt") + expect(diff).not.toContain("primary-only.txt") + }, + }) + } finally { + await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow() + await $`rm -rf ${worktreePath}`.quiet() + await $`rm -f ${tmp.path}/shared.txt`.quiet() + await $`rm -f ${tmp.path}/primary-only.txt`.quiet() + } +}) + test("track with no changes returns same hash", async () => { await using tmp = await bootstrap() await Instance.provide({