summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChris Olszewski <[email protected]>2025-11-15 02:02:00 -0500
committerGitHub <[email protected]>2025-11-15 01:02:00 -0600
commit69a45ef7d73691f6ed1f01f4e603ca554a9575d7 (patch)
tree75f7cda493bf6179f4247b65b563a7815cbf65ca
parent1056b36eae87db6797cd85c49e821474edd7a1f2 (diff)
downloadopencode-69a45ef7d73691f6ed1f01f4e603ca554a9575d7.tar.gz
opencode-69a45ef7d73691f6ed1f01f4e603ca554a9575d7.zip
fix: snapshot history when running from git worktrees (#4312)
-rw-r--r--packages/opencode/src/snapshot/index.ts52
-rw-r--r--packages/opencode/test/snapshot/snapshot.test.ts109
2 files changed, 143 insertions, 18 deletions
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index cf051defb..e8500e09d 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<Patch> {
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<FileDiff[]> {
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 b72717cd1..cf933f812 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({