Skip to content

Commit 602cdb7

Browse files
committed
fix(snapshot): unify cwd to worktree root and prune stale tmp pack files
Unify all snapshot git commands to use Instance.worktree as cwd for consistency with restore() and revert() which already use worktree root. Previously track(), patch(), diff(), and diffFull() used Instance.directory (which can be a subdirectory), creating scope inconsistency. Add pruneStale() to cleanup() that removes tmp_pack_* files older than 24 hours from objects/pack/ after gc, preventing disk bloat from failed gc runs.
1 parent 6080784 commit 602cdb7

2 files changed

Lines changed: 122 additions & 12 deletions

File tree

packages/opencode/src/snapshot/index.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,38 @@ export namespace Snapshot {
3535
if (!exists) return
3636
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
3737
.quiet()
38-
.cwd(Instance.directory)
38+
.cwd(Instance.worktree)
3939
.nothrow()
40-
if (result.exitCode !== 0) {
40+
if (result.exitCode !== 0)
4141
log.warn("cleanup failed", {
4242
exitCode: result.exitCode,
4343
stderr: result.stderr.toString(),
4444
stdout: result.stdout.toString(),
4545
})
46-
return
46+
if (result.exitCode === 0) log.info("cleanup", { prune })
47+
await pruneStale(git)
48+
}
49+
50+
async function pruneStale(git: string) {
51+
const dir = path.join(git, "objects", "pack")
52+
const entries = await fs.readdir(dir).catch((err) => {
53+
if (err.code !== "ENOENT") log.warn("pruneStale readdir failed", { dir, error: String(err) })
54+
return [] as string[]
55+
})
56+
const now = Date.now()
57+
const day = 24 * 60 * 60 * 1000
58+
for (const entry of entries) {
59+
if (!entry.startsWith("tmp_pack_")) continue
60+
const full = path.join(dir, entry)
61+
const stat = await fs.stat(full).catch(() => undefined)
62+
if (!stat || !stat.isFile()) continue
63+
if (now - stat.mtimeMs < day) continue
64+
const ok = await fs
65+
.unlink(full)
66+
.then(() => true)
67+
.catch(() => false)
68+
if (ok) log.info("removed stale tmp", { entry, hours: Math.round((now - stat.mtimeMs) / 1000 / 60 / 60) })
4769
}
48-
log.info("cleanup", { prune })
4970
}
5071

5172
export async function track() {
@@ -66,13 +87,13 @@ export namespace Snapshot {
6687
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
6788
log.info("initialized")
6889
}
69-
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
90+
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.worktree).nothrow()
7091
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
7192
.quiet()
72-
.cwd(Instance.directory)
93+
.cwd(Instance.worktree)
7394
.nothrow()
7495
.text()
75-
log.info("tracking", { hash, cwd: Instance.directory, git })
96+
log.info("tracking", { hash, cwd: Instance.worktree, git })
7697
return hash.trim()
7798
}
7899

@@ -84,11 +105,11 @@ export namespace Snapshot {
84105

85106
export async function patch(hash: string): Promise<Patch> {
86107
const git = gitdir()
87-
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
108+
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.worktree).nothrow()
88109
const result =
89110
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
90111
.quiet()
91-
.cwd(Instance.directory)
112+
.cwd(Instance.worktree)
92113
.nothrow()
93114

94115
// If git diff fails, return empty patch
@@ -162,7 +183,7 @@ export namespace Snapshot {
162183

163184
export async function diff(hash: string) {
164185
const git = gitdir()
165-
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
186+
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.worktree).nothrow()
166187
const result =
167188
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
168189
.quiet()
@@ -203,7 +224,7 @@ export namespace Snapshot {
203224
const statuses =
204225
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
205226
.quiet()
206-
.cwd(Instance.directory)
227+
.cwd(Instance.worktree)
207228
.nothrow()
208229
.text()
209230

@@ -217,7 +238,7 @@ export namespace Snapshot {
217238

218239
for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
219240
.quiet()
220-
.cwd(Instance.directory)
241+
.cwd(Instance.worktree)
221242
.nothrow()
222243
.lines()) {
223244
if (!line) continue

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { test, expect } from "bun:test"
22
import { $ } from "bun"
3+
import path from "path"
4+
import fs from "fs/promises"
35
import { Snapshot } from "../../src/snapshot"
46
import { Instance } from "../../src/project/instance"
7+
import { Global } from "../../src/global"
58
import { tmpdir } from "../fixture/fixture"
69

710
async function bootstrap() {
@@ -1038,3 +1041,89 @@ test("diffFull with whitespace changes", async () => {
10381041
},
10391042
})
10401043
})
1044+
1045+
test("snapshot from subdirectory covers worktree and respects gitignore", async () => {
1046+
await using tmp = await bootstrap()
1047+
const sub = `${tmp.path}/sub`
1048+
await $`mkdir -p ${sub}`.quiet()
1049+
await Bun.write(`${tmp.path}/.gitignore`, "ignored/\n")
1050+
await $`git add .gitignore`.cwd(tmp.path).quiet()
1051+
await $`git commit --no-gpg-sign -m "add gitignore"`.cwd(tmp.path).quiet()
1052+
1053+
await Instance.provide({
1054+
directory: sub,
1055+
fn: async () => {
1056+
const before = await Snapshot.track()
1057+
expect(before).toBeTruthy()
1058+
1059+
// file in subdirectory — should be tracked
1060+
await Bun.write(`${sub}/tracked.txt`, "tracked")
1061+
// file in ignored directory — should NOT be tracked
1062+
await $`mkdir -p ${sub}/ignored`.quiet()
1063+
await Bun.write(`${sub}/ignored/file.txt`, "ignored")
1064+
// file at worktree root — should be tracked (worktree-scoped)
1065+
await Bun.write(`${tmp.path}/root.txt`, "root level")
1066+
1067+
const patch = await Snapshot.patch(before!)
1068+
expect(patch.files).toContain(`${tmp.path}/sub/tracked.txt`)
1069+
expect(patch.files).not.toContain(`${sub}/ignored/file.txt`)
1070+
expect(patch.files).toContain(`${tmp.path}/root.txt`)
1071+
},
1072+
})
1073+
})
1074+
1075+
test("cleanup removes stale tmp files", async () => {
1076+
await using tmp = await bootstrap()
1077+
await Instance.provide({
1078+
directory: tmp.path,
1079+
fn: async () => {
1080+
await Snapshot.track()
1081+
1082+
const git = path.join(Global.Path.data, "snapshot", Instance.project.id)
1083+
const packDir = path.join(git, "objects", "pack")
1084+
await fs.mkdir(packDir, { recursive: true })
1085+
1086+
const stale = path.join(packDir, "tmp_pack_stale")
1087+
await Bun.write(stale, "stale data")
1088+
const past = Date.now() - 25 * 60 * 60 * 1000
1089+
await fs.utimes(stale, past / 1000, past / 1000)
1090+
1091+
const fresh = path.join(packDir, "tmp_pack_fresh")
1092+
await Bun.write(fresh, "fresh data")
1093+
const recent = Date.now() - 1 * 60 * 60 * 1000
1094+
await fs.utimes(fresh, recent / 1000, recent / 1000)
1095+
1096+
await Snapshot.cleanup()
1097+
1098+
expect(await Bun.file(stale).exists()).toBe(false)
1099+
expect(await Bun.file(fresh).exists()).toBe(true)
1100+
},
1101+
})
1102+
})
1103+
1104+
test("cleanup prunes stale tmp files even when gc fails", async () => {
1105+
await using tmp = await bootstrap()
1106+
await Instance.provide({
1107+
directory: tmp.path,
1108+
fn: async () => {
1109+
await Snapshot.track()
1110+
1111+
const git = path.join(Global.Path.data, "snapshot", Instance.project.id)
1112+
1113+
// corrupt the git repo so gc fails
1114+
await Bun.write(path.join(git, "HEAD"), "garbage")
1115+
1116+
const packDir = path.join(git, "objects", "pack")
1117+
await fs.mkdir(packDir, { recursive: true })
1118+
1119+
const stale = path.join(packDir, "tmp_pack_stale")
1120+
await Bun.write(stale, "stale data")
1121+
const past = Date.now() - 25 * 60 * 60 * 1000
1122+
await fs.utimes(stale, past / 1000, past / 1000)
1123+
1124+
await Snapshot.cleanup()
1125+
1126+
expect(await Bun.file(stale).exists()).toBe(false)
1127+
},
1128+
})
1129+
})

0 commit comments

Comments
 (0)