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) + }, + }) +})