Skip to content

Commit b6adbbd

Browse files
kitlangtonRyu
authored andcommitted
fix(opencode): batch snapshot revert without reordering (anomalyco#20564)
1 parent e2743e9 commit b6adbbd

File tree

2 files changed

+172
-10
lines changed

2 files changed

+172
-10
lines changed

packages/opencode/src/snapshot/index.ts

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -301,28 +301,113 @@ export namespace Snapshot {
301301
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
302302
return yield* locked(
303303
Effect.gen(function* () {
304+
const ops: { hash: string; file: string; rel: string }[] = []
304305
const seen = new Set<string>()
305306
for (const item of patches) {
306307
for (const file of item.files) {
307308
if (seen.has(file)) continue
308309
seen.add(file)
309-
log.info("reverting", { file, hash: item.hash })
310-
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
310+
ops.push({
311+
hash: item.hash,
312+
file,
313+
rel: path.relative(state.worktree, file).replaceAll("\\", "/"),
314+
})
315+
}
316+
}
317+
318+
const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) {
319+
log.info("reverting", { file: op.file, hash: op.hash })
320+
const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], {
321+
cwd: state.worktree,
322+
})
323+
if (result.code === 0) return
324+
const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], {
325+
cwd: state.worktree,
326+
})
327+
if (tree.code === 0 && tree.text.trim()) {
328+
log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash })
329+
return
330+
}
331+
log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
332+
yield* remove(op.file)
333+
})
334+
335+
const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
336+
337+
for (let i = 0; i < ops.length; ) {
338+
const first = ops[i]!
339+
const run = [first]
340+
let j = i + 1
341+
// Only batch adjacent files when their paths cannot affect each other.
342+
while (j < ops.length && run.length < 100) {
343+
const next = ops[j]!
344+
if (next.hash !== first.hash) break
345+
if (run.some((item) => clash(item.rel, next.rel))) break
346+
run.push(next)
347+
j += 1
348+
}
349+
350+
if (run.length === 1) {
351+
yield* single(first)
352+
i = j
353+
continue
354+
}
355+
356+
const tree = yield* git(
357+
[...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])],
358+
{
311359
cwd: state.worktree,
360+
},
361+
)
362+
363+
if (tree.code !== 0) {
364+
log.info("batched ls-tree failed, falling back to single-file revert", {
365+
hash: first.hash,
366+
files: run.length,
312367
})
313-
if (result.code !== 0) {
314-
const rel = path.relative(state.worktree, file)
315-
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
368+
for (const op of run) {
369+
yield* single(op)
370+
}
371+
i = j
372+
continue
373+
}
374+
375+
const have = new Set(
376+
tree.text
377+
.trim()
378+
.split("\n")
379+
.map((item) => item.trim())
380+
.filter(Boolean),
381+
)
382+
const list = run.filter((item) => have.has(item.rel))
383+
if (list.length) {
384+
log.info("reverting", { hash: first.hash, files: list.length })
385+
const result = yield* git(
386+
[...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])],
387+
{
316388
cwd: state.worktree,
389+
},
390+
)
391+
if (result.code !== 0) {
392+
log.info("batched checkout failed, falling back to single-file revert", {
393+
hash: first.hash,
394+
files: list.length,
317395
})
318-
if (tree.code === 0 && tree.text.trim()) {
319-
log.info("file existed in snapshot but checkout failed, keeping", { file })
320-
} else {
321-
log.info("file did not exist in snapshot, deleting", { file })
322-
yield* remove(file)
396+
for (const op of run) {
397+
yield* single(op)
323398
}
399+
i = j
400+
continue
324401
}
325402
}
403+
404+
for (const op of run) {
405+
if (have.has(op.rel)) continue
406+
log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
407+
yield* remove(op.file)
408+
}
409+
410+
i = j
326411
}
327412
}),
328413
)

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,3 +1233,80 @@ test("revert with overlapping files across patches uses first patch hash", async
12331233
},
12341234
})
12351235
})
1236+
1237+
test("revert preserves patch order when the same hash appears again", async () => {
1238+
await using tmp = await bootstrap()
1239+
await Instance.provide({
1240+
directory: tmp.path,
1241+
fn: async () => {
1242+
await $`mkdir -p ${tmp.path}/foo`.quiet()
1243+
await Filesystem.write(`${tmp.path}/foo/bar`, "v1")
1244+
await Filesystem.write(`${tmp.path}/a.txt`, "v1")
1245+
1246+
const snap1 = await Snapshot.track()
1247+
expect(snap1).toBeTruthy()
1248+
1249+
await $`rm -rf ${tmp.path}/foo`.quiet()
1250+
await Filesystem.write(`${tmp.path}/foo`, "v2")
1251+
await Filesystem.write(`${tmp.path}/a.txt`, "v2")
1252+
1253+
const snap2 = await Snapshot.track()
1254+
expect(snap2).toBeTruthy()
1255+
1256+
await $`rm -rf ${tmp.path}/foo`.quiet()
1257+
await Filesystem.write(`${tmp.path}/a.txt`, "v3")
1258+
1259+
await Snapshot.revert([
1260+
{ hash: snap1!, files: [fwd(tmp.path, "a.txt")] },
1261+
{ hash: snap2!, files: [fwd(tmp.path, "foo")] },
1262+
{ hash: snap1!, files: [fwd(tmp.path, "foo", "bar")] },
1263+
])
1264+
1265+
expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe("v1")
1266+
expect((await fs.stat(`${tmp.path}/foo`)).isDirectory()).toBe(true)
1267+
expect(await fs.readFile(`${tmp.path}/foo/bar`, "utf-8")).toBe("v1")
1268+
},
1269+
})
1270+
})
1271+
1272+
test("revert handles large mixed batches across chunk boundaries", async () => {
1273+
await using tmp = await bootstrap()
1274+
await Instance.provide({
1275+
directory: tmp.path,
1276+
fn: async () => {
1277+
const base = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "batch", `${i}.txt`))
1278+
const fresh = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "fresh", `${i}.txt`))
1279+
1280+
await $`mkdir -p ${tmp.path}/batch ${tmp.path}/fresh`.quiet()
1281+
await Promise.all(base.map((file, i) => Filesystem.write(file, `base-${i}`)))
1282+
1283+
const snap = await Snapshot.track()
1284+
expect(snap).toBeTruthy()
1285+
1286+
await Promise.all(base.map((file, i) => Filesystem.write(file, `next-${i}`)))
1287+
await Promise.all(fresh.map((file, i) => Filesystem.write(file, `fresh-${i}`)))
1288+
1289+
const patch = await Snapshot.patch(snap!)
1290+
expect(patch.files.length).toBe(base.length + fresh.length)
1291+
1292+
await Snapshot.revert([patch])
1293+
1294+
await Promise.all(
1295+
base.map(async (file, i) => {
1296+
expect(await fs.readFile(file, "utf-8")).toBe(`base-${i}`)
1297+
}),
1298+
)
1299+
1300+
await Promise.all(
1301+
fresh.map(async (file) => {
1302+
expect(
1303+
await fs
1304+
.access(file)
1305+
.then(() => true)
1306+
.catch(() => false),
1307+
).toBe(false)
1308+
}),
1309+
)
1310+
},
1311+
})
1312+
})

0 commit comments

Comments
 (0)