summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-10-01 05:05:50 -0400
committerDax Raad <[email protected]>2025-10-01 05:06:37 -0400
commit6a7eeb39c362cff4707079f15bbbe3ec5a556a37 (patch)
tree17541dcb092fe480d957ed441aab2839bc84073a
parent35a608cd53fa253e5962921fd5fe1a3c70e23274 (diff)
downloadopencode-6a7eeb39c362cff4707079f15bbbe3ec5a556a37.tar.gz
opencode-6a7eeb39c362cff4707079f15bbbe3ec5a556a37.zip
core: prevent file deletion when reverting changes to existing files
-rw-r--r--packages/opencode/src/snapshot/index.ts13
-rw-r--r--packages/opencode/test/snapshot/snapshot.test.ts52
2 files changed, 63 insertions, 2 deletions
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index f301c81f3..fb49ae739 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -105,8 +105,17 @@ export namespace Snapshot {
.cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) {
- log.info("file not found in history, deleting", { file })
- await fs.unlink(file).catch(() => {})
+ const relativePath = path.relative(Instance.worktree, file)
+ const checkTree = await $`git --git-dir=${git} 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 })
+ } else {
+ log.info("file did not exist in snapshot, deleting", { file })
+ await fs.unlink(file).catch(() => {})
+ }
}
files.add(file)
}
diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts
index bafe6d6e3..1398162e3 100644
--- a/packages/opencode/test/snapshot/snapshot.test.ts
+++ b/packages/opencode/test/snapshot/snapshot.test.ts
@@ -532,3 +532,55 @@ test("restore function", async () => {
},
})
})
+
+test("revert should not delete files that existed but were deleted in snapshot", async () => {
+ await using tmp = await bootstrap()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const snapshot1 = await Snapshot.track()
+ expect(snapshot1).toBeTruthy()
+
+ await $`rm ${tmp.path}/a.txt`.quiet()
+
+ const snapshot2 = await Snapshot.track()
+ expect(snapshot2).toBeTruthy()
+
+ await Bun.write(`${tmp.path}/a.txt`, "recreated content")
+
+ const patch = await Snapshot.patch(snapshot2!)
+ expect(patch.files).toContain(`${tmp.path}/a.txt`)
+
+ await Snapshot.revert([patch])
+
+ expect(await Bun.file(`${tmp.path}/a.txt`).exists()).toBe(false)
+ },
+ })
+})
+
+test("revert preserves file that existed in snapshot when deleted then recreated", async () => {
+ await using tmp = await bootstrap()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await Bun.write(`${tmp.path}/existing.txt`, "original content")
+
+ const snapshot = await Snapshot.track()
+ expect(snapshot).toBeTruthy()
+
+ await $`rm ${tmp.path}/existing.txt`.quiet()
+ await Bun.write(`${tmp.path}/existing.txt`, "recreated")
+ await Bun.write(`${tmp.path}/newfile.txt`, "new")
+
+ const patch = await Snapshot.patch(snapshot!)
+ expect(patch.files).toContain(`${tmp.path}/existing.txt`)
+ expect(patch.files).toContain(`${tmp.path}/newfile.txt`)
+
+ await Snapshot.revert([patch])
+
+ expect(await Bun.file(`${tmp.path}/newfile.txt`).exists()).toBe(false)
+ expect(await Bun.file(`${tmp.path}/existing.txt`).exists()).toBe(true)
+ expect(await Bun.file(`${tmp.path}/existing.txt`).text()).toBe("original content")
+ },
+ })
+})