Skip to content

Commit 69a45ef

Browse files
fix: snapshot history when running from git worktrees (anomalyco#4312)
1 parent 1056b36 commit 69a45ef

2 files changed

Lines changed: 143 additions & 18 deletions

File tree

packages/opencode/src/snapshot/index.ts

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,12 @@ export namespace Snapshot {
2626
.nothrow()
2727
log.info("initialized")
2828
}
29-
await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow()
30-
const hash = await $`git --git-dir ${git} write-tree`.quiet().cwd(Instance.directory).nothrow().text()
29+
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
30+
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
31+
.quiet()
32+
.cwd(Instance.directory)
33+
.nothrow()
34+
.text()
3135
log.info("tracking", { hash, cwd: Instance.directory, git })
3236
return hash.trim()
3337
}
@@ -40,8 +44,11 @@ export namespace Snapshot {
4044

4145
export async function patch(hash: string): Promise<Patch> {
4246
const git = gitdir()
43-
await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow()
44-
const result = await $`git --git-dir ${git} diff --name-only ${hash} -- .`.quiet().cwd(Instance.directory).nothrow()
47+
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
48+
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} diff --name-only ${hash} -- .`
49+
.quiet()
50+
.cwd(Instance.directory)
51+
.nothrow()
4552

4653
// If git diff fails, return empty patch
4754
if (result.exitCode !== 0) {
@@ -64,10 +71,11 @@ export namespace Snapshot {
6471
export async function restore(snapshot: string) {
6572
log.info("restore", { commit: snapshot })
6673
const git = gitdir()
67-
const result = await $`git --git-dir=${git} read-tree ${snapshot} && git --git-dir=${git} checkout-index -a -f`
68-
.quiet()
69-
.cwd(Instance.worktree)
70-
.nothrow()
74+
const result =
75+
await $`git --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
76+
.quiet()
77+
.cwd(Instance.worktree)
78+
.nothrow()
7179

7280
if (result.exitCode !== 0) {
7381
log.error("failed to restore snapshot", {
@@ -86,16 +94,17 @@ export namespace Snapshot {
8694
for (const file of item.files) {
8795
if (files.has(file)) continue
8896
log.info("reverting", { file, hash: item.hash })
89-
const result = await $`git --git-dir=${git} checkout ${item.hash} -- ${file}`
97+
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
9098
.quiet()
9199
.cwd(Instance.worktree)
92100
.nothrow()
93101
if (result.exitCode !== 0) {
94102
const relativePath = path.relative(Instance.worktree, file)
95-
const checkTree = await $`git --git-dir=${git} ls-tree ${item.hash} -- ${relativePath}`
96-
.quiet()
97-
.cwd(Instance.worktree)
98-
.nothrow()
103+
const checkTree =
104+
await $`git --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
105+
.quiet()
106+
.cwd(Instance.worktree)
107+
.nothrow()
99108
if (checkTree.exitCode === 0 && checkTree.text().trim()) {
100109
log.info("file existed in snapshot but checkout failed, keeping", {
101110
file,
@@ -112,8 +121,11 @@ export namespace Snapshot {
112121

113122
export async function diff(hash: string) {
114123
const git = gitdir()
115-
await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow()
116-
const result = await $`git --git-dir=${git} diff ${hash} -- .`.quiet().cwd(Instance.worktree).nothrow()
124+
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
125+
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} diff ${hash} -- .`
126+
.quiet()
127+
.cwd(Instance.worktree)
128+
.nothrow()
117129

118130
if (result.exitCode !== 0) {
119131
log.warn("failed to get diff", {
@@ -143,16 +155,20 @@ export namespace Snapshot {
143155
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
144156
const git = gitdir()
145157
const result: FileDiff[] = []
146-
for await (const line of $`git --git-dir=${git} diff --no-renames --numstat ${from} ${to} -- .`
158+
for await (const line of $`git --git-dir ${git} --work-tree ${Instance.worktree} diff --no-renames --numstat ${from} ${to} -- .`
147159
.quiet()
148160
.cwd(Instance.directory)
149161
.nothrow()
150162
.lines()) {
151163
if (!line) continue
152164
const [additions, deletions, file] = line.split("\t")
153165
const isBinaryFile = additions === "-" && deletions === "-"
154-
const before = isBinaryFile ? "" : await $`git --git-dir=${git} show ${from}:${file}`.quiet().nothrow().text()
155-
const after = isBinaryFile ? "" : await $`git --git-dir=${git} show ${to}:${file}`.quiet().nothrow().text()
166+
const before = isBinaryFile
167+
? ""
168+
: await $`git --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`.quiet().nothrow().text()
169+
const after = isBinaryFile
170+
? ""
171+
: await $`git --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`.quiet().nothrow().text()
156172
result.push({
157173
file,
158174
before,

packages/opencode/test/snapshot/snapshot.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,115 @@ test("snapshot state isolation between projects", async () => {
469469
})
470470
})
471471

472+
test("patch detects changes in secondary worktree", async () => {
473+
await using tmp = await bootstrap()
474+
const worktreePath = `${tmp.path}-worktree`
475+
await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
476+
477+
try {
478+
await Instance.provide({
479+
directory: tmp.path,
480+
fn: async () => {
481+
expect(await Snapshot.track()).toBeTruthy()
482+
},
483+
})
484+
485+
await Instance.provide({
486+
directory: worktreePath,
487+
fn: async () => {
488+
const before = await Snapshot.track()
489+
expect(before).toBeTruthy()
490+
491+
const worktreeFile = `${worktreePath}/worktree.txt`
492+
await Bun.write(worktreeFile, "worktree content")
493+
494+
const patch = await Snapshot.patch(before!)
495+
expect(patch.files).toContain(worktreeFile)
496+
},
497+
})
498+
} finally {
499+
await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
500+
await $`rm -rf ${worktreePath}`.quiet()
501+
}
502+
})
503+
504+
test("revert only removes files in invoking worktree", async () => {
505+
await using tmp = await bootstrap()
506+
const worktreePath = `${tmp.path}-worktree`
507+
await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
508+
509+
try {
510+
await Instance.provide({
511+
directory: tmp.path,
512+
fn: async () => {
513+
expect(await Snapshot.track()).toBeTruthy()
514+
},
515+
})
516+
const primaryFile = `${tmp.path}/worktree.txt`
517+
await Bun.write(primaryFile, "primary content")
518+
519+
await Instance.provide({
520+
directory: worktreePath,
521+
fn: async () => {
522+
const before = await Snapshot.track()
523+
expect(before).toBeTruthy()
524+
525+
const worktreeFile = `${worktreePath}/worktree.txt`
526+
await Bun.write(worktreeFile, "worktree content")
527+
528+
const patch = await Snapshot.patch(before!)
529+
await Snapshot.revert([patch])
530+
531+
expect(await Bun.file(worktreeFile).exists()).toBe(false)
532+
},
533+
})
534+
535+
expect(await Bun.file(primaryFile).text()).toBe("primary content")
536+
} finally {
537+
await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
538+
await $`rm -rf ${worktreePath}`.quiet()
539+
await $`rm -f ${tmp.path}/worktree.txt`.quiet()
540+
}
541+
})
542+
543+
test("diff reports worktree-only/shared edits and ignores primary-only", async () => {
544+
await using tmp = await bootstrap()
545+
const worktreePath = `${tmp.path}-worktree`
546+
await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
547+
548+
try {
549+
await Instance.provide({
550+
directory: tmp.path,
551+
fn: async () => {
552+
expect(await Snapshot.track()).toBeTruthy()
553+
},
554+
})
555+
556+
await Instance.provide({
557+
directory: worktreePath,
558+
fn: async () => {
559+
const before = await Snapshot.track()
560+
expect(before).toBeTruthy()
561+
562+
await Bun.write(`${worktreePath}/worktree-only.txt`, "worktree diff content")
563+
await Bun.write(`${worktreePath}/shared.txt`, "worktree edit")
564+
await Bun.write(`${tmp.path}/shared.txt`, "primary edit")
565+
await Bun.write(`${tmp.path}/primary-only.txt`, "primary change")
566+
567+
const diff = await Snapshot.diff(before!)
568+
expect(diff).toContain("worktree-only.txt")
569+
expect(diff).toContain("shared.txt")
570+
expect(diff).not.toContain("primary-only.txt")
571+
},
572+
})
573+
} finally {
574+
await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
575+
await $`rm -rf ${worktreePath}`.quiet()
576+
await $`rm -f ${tmp.path}/shared.txt`.quiet()
577+
await $`rm -f ${tmp.path}/primary-only.txt`.quiet()
578+
}
579+
})
580+
472581
test("track with no changes returns same hash", async () => {
473582
await using tmp = await bootstrap()
474583
await Instance.provide({

0 commit comments

Comments
 (0)