Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 33 additions & 12 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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()
}

Expand All @@ -84,11 +105,11 @@ export namespace Snapshot {

export async function patch(hash: string): Promise<Patch> {
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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand Down
89 changes: 89 additions & 0 deletions packages/opencode/test/snapshot/snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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)
},
})
})
Loading