From 602cdb7f582055ad53c6da63dbb999a411719aec Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 14 Feb 2026 13:58:43 +0900 Subject: [PATCH] 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. --- packages/opencode/src/snapshot/index.ts | 45 +++++++--- .../opencode/test/snapshot/snapshot.test.ts | 89 +++++++++++++++++++ 2 files changed, 122 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index a1c2b57812e8..414d933c8dfc 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -35,17 +35,38 @@ export namespace Snapshot { if (!exists) return const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}` .quiet() - .cwd(Instance.directory) + .cwd(Instance.worktree) .nothrow() - if (result.exitCode !== 0) { + if (result.exitCode !== 0) log.warn("cleanup failed", { exitCode: result.exitCode, stderr: result.stderr.toString(), stdout: result.stdout.toString(), }) - return + if (result.exitCode === 0) log.info("cleanup", { prune }) + await pruneStale(git) + } + + async function pruneStale(git: string) { + const dir = path.join(git, "objects", "pack") + const entries = await fs.readdir(dir).catch((err) => { + if (err.code !== "ENOENT") log.warn("pruneStale readdir failed", { dir, error: String(err) }) + return [] as string[] + }) + const now = Date.now() + const day = 24 * 60 * 60 * 1000 + for (const entry of entries) { + if (!entry.startsWith("tmp_pack_")) continue + const full = path.join(dir, entry) + const stat = await fs.stat(full).catch(() => undefined) + if (!stat || !stat.isFile()) continue + if (now - stat.mtimeMs < day) continue + const ok = await fs + .unlink(full) + .then(() => true) + .catch(() => false) + if (ok) log.info("removed stale tmp", { entry, hours: Math.round((now - stat.mtimeMs) / 1000 / 60 / 60) }) } - log.info("cleanup", { prune }) } export async function track() { @@ -66,13 +87,13 @@ export namespace Snapshot { await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow() log.info("initialized") } - await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow() + await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.worktree).nothrow() const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree` .quiet() - .cwd(Instance.directory) + .cwd(Instance.worktree) .nothrow() .text() - log.info("tracking", { hash, cwd: Instance.directory, git }) + log.info("tracking", { hash, cwd: Instance.worktree, git }) return hash.trim() } @@ -84,11 +105,11 @@ export namespace Snapshot { export async function patch(hash: string): Promise { const git = gitdir() - await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow() + await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.worktree).nothrow() const result = await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .` .quiet() - .cwd(Instance.directory) + .cwd(Instance.worktree) .nothrow() // If git diff fails, return empty patch @@ -162,7 +183,7 @@ export namespace Snapshot { export async function diff(hash: string) { const git = gitdir() - await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow() + await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.worktree).nothrow() const result = await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .` .quiet() @@ -203,7 +224,7 @@ export namespace Snapshot { const statuses = 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} -- .` .quiet() - .cwd(Instance.directory) + .cwd(Instance.worktree) .nothrow() .text() @@ -217,7 +238,7 @@ export namespace Snapshot { 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} -- .` .quiet() - .cwd(Instance.directory) + .cwd(Instance.worktree) .nothrow() .lines()) { if (!line) continue diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 091469ec7619..0ded1a0336f4 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1,7 +1,10 @@ import { test, expect } from "bun:test" import { $ } from "bun" +import path from "path" +import fs from "fs/promises" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" +import { Global } from "../../src/global" import { tmpdir } from "../fixture/fixture" async function bootstrap() { @@ -1038,3 +1041,89 @@ test("diffFull with whitespace changes", async () => { }, }) }) + +test("snapshot from subdirectory covers worktree and respects gitignore", async () => { + await using tmp = await bootstrap() + const sub = `${tmp.path}/sub` + await $`mkdir -p ${sub}`.quiet() + await Bun.write(`${tmp.path}/.gitignore`, "ignored/\n") + await $`git add .gitignore`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add gitignore"`.cwd(tmp.path).quiet() + + await Instance.provide({ + directory: sub, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // file in subdirectory — should be tracked + await Bun.write(`${sub}/tracked.txt`, "tracked") + // file in ignored directory — should NOT be tracked + await $`mkdir -p ${sub}/ignored`.quiet() + await Bun.write(`${sub}/ignored/file.txt`, "ignored") + // file at worktree root — should be tracked (worktree-scoped) + await Bun.write(`${tmp.path}/root.txt`, "root level") + + const patch = await Snapshot.patch(before!) + expect(patch.files).toContain(`${tmp.path}/sub/tracked.txt`) + expect(patch.files).not.toContain(`${sub}/ignored/file.txt`) + expect(patch.files).toContain(`${tmp.path}/root.txt`) + }, + }) +}) + +test("cleanup removes stale tmp files", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Snapshot.track() + + const git = path.join(Global.Path.data, "snapshot", Instance.project.id) + const packDir = path.join(git, "objects", "pack") + await fs.mkdir(packDir, { recursive: true }) + + const stale = path.join(packDir, "tmp_pack_stale") + await Bun.write(stale, "stale data") + const past = Date.now() - 25 * 60 * 60 * 1000 + await fs.utimes(stale, past / 1000, past / 1000) + + const fresh = path.join(packDir, "tmp_pack_fresh") + await Bun.write(fresh, "fresh data") + const recent = Date.now() - 1 * 60 * 60 * 1000 + await fs.utimes(fresh, recent / 1000, recent / 1000) + + await Snapshot.cleanup() + + expect(await Bun.file(stale).exists()).toBe(false) + expect(await Bun.file(fresh).exists()).toBe(true) + }, + }) +}) + +test("cleanup prunes stale tmp files even when gc fails", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Snapshot.track() + + const git = path.join(Global.Path.data, "snapshot", Instance.project.id) + + // corrupt the git repo so gc fails + await Bun.write(path.join(git, "HEAD"), "garbage") + + const packDir = path.join(git, "objects", "pack") + await fs.mkdir(packDir, { recursive: true }) + + const stale = path.join(packDir, "tmp_pack_stale") + await Bun.write(stale, "stale data") + const past = Date.now() - 25 * 60 * 60 * 1000 + await fs.utimes(stale, past / 1000, past / 1000) + + await Snapshot.cleanup() + + expect(await Bun.file(stale).exists()).toBe(false) + }, + }) +})