Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
c6de6a8
feat: allow snapshot config to accept positive integer for retention …
ariane-emory Jan 26, 2026
96f59e1
feat: allow snapshot config to accept 0 to disable snapshots
ariane-emory Jan 26, 2026
68a9de7
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Jan 26, 2026
565b83a
Implement configurable snapshot lifespan cleanup logic
ariane-emory Jan 27, 2026
c5d8b80
Refactor snapshot config tests to verify retention calculation
ariane-emory Jan 27, 2026
3cf4e02
Fix TypeScript type errors in snapshot config tests
ariane-emory Jan 27, 2026
78cf530
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Jan 27, 2026
aca58d5
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Jan 29, 2026
45cf6f7
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Jan 29, 2026
d5d17c9
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Jan 30, 2026
2a30f41
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 1, 2026
756a969
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 2, 2026
0695ff5
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 3, 2026
851422a
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 4, 2026
71dcec0
Merge dev into feat/configurable-snapshot-lifespan
ariane-emory Feb 4, 2026
44d80d1
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 6, 2026
b773ac9
Merge dev into feat/configurable-snapshot-lifespan
ariane-emory Feb 9, 2026
17ba66f
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 10, 2026
aba59c2
Merge dev into feat/configurable-snapshot-lifespan
ariane-emory Feb 11, 2026
7587d2e
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 12, 2026
80c20d3
Merge dev into feat/configurable-snapshot-lifespan
ariane-emory Feb 13, 2026
a6a2ca0
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 14, 2026
a9c314b
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 15, 2026
03d2776
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 15, 2026
70c77de
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 17, 2026
fc8b783
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 18, 2026
814f9f7
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 20, 2026
7070cf3
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 20, 2026
e6a4fb7
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 20, 2026
211e6a2
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 21, 2026
c27ddef
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 26, 2026
d88c079
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Feb 26, 2026
97a2fa3
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Mar 5, 2026
ee14040
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Mar 5, 2026
94026da
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Mar 8, 2026
cfb2f95
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Mar 8, 2026
c1aaad8
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Mar 13, 2026
642a483
Merge dev into feat/configurable-snapshot-lifespan
ariane-emory Mar 20, 2026
d66dde1
Merge dev into feat/configurable-snapshot-lifespan
ariane-emory Mar 20, 2026
2ddc8bb
Fix: Remove leftover merge conflict marker from config.ts
ariane-emory Mar 20, 2026
be557ea
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Mar 24, 2026
d019955
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Mar 24, 2026
d204ac4
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Mar 24, 2026
9cb040a
Merge dev into feat/configurable-snapshot-lifespan
ariane-emory Apr 3, 2026
0cd1199
fix: docstring
ariane-emory Apr 4, 2026
fd0b2a1
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Apr 12, 2026
7904f7f
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Apr 17, 2026
cb34b8a
fix(snapshot): use git prune instead of gc to actually delete old sna…
ariane-emory Apr 22, 2026
35f1d84
fix: replace git gc --prune with git prune --expire for proper cleanup
ariane-emory Apr 22, 2026
6b907b9
fix: continue cleanup after git prune errors
ariane-emory Apr 22, 2026
d9ec333
Merge branch 'dev' into feat/configurable-snapshot-lifespan
ariane-emory Apr 23, 2026
2cca69a
Merge dev into feat/configurable-snapshot-lifespan
ariane-emory May 5, 2026
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
6 changes: 4 additions & 2 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,11 @@ export const Info = Schema.Struct({
ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
}),
),
snapshot: Schema.optional(Schema.Boolean).annotate({
snapshot: Schema.optional(
Schema.Union([Schema.Boolean, NonNegativeInt])
).annotate({
description:
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true. Can also be set to a number to specify how many days snapshots should be retained for.",
}),
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPlugin.Spec))),
Expand Down
50 changes: 41 additions & 9 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export const FileDiff = Schema.Struct({
export type FileDiff = typeof FileDiff.Type

const log = Log.create({ service: "snapshot" })
const prune = "7.days"
const limit = 2 * 1024 * 1024
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const cfg = ["-c", "core.autocrlf=false", ...core]
Expand All @@ -42,6 +41,7 @@ interface GitResult {
readonly stderr: string
}

const defaultRetentionDays = 7
type State = Omit<Interface, "init">

export interface Interface {
Expand Down Expand Up @@ -181,7 +181,14 @@ export const layer: Layer.Layer<

const enabled = Effect.fnUntraced(function* () {
if (state.vcs !== "git") return false
return (yield* config.get()).snapshot !== false
const snapshot = (yield* config.get()).snapshot
return snapshot !== false && snapshot !== 0
})

const retentionDays = Effect.fnUntraced(function* () {
const snapshot = (yield* config.get()).snapshot
if (typeof snapshot === "number") return snapshot
return defaultRetentionDays
})

const excludes = Effect.fnUntraced(function* () {
Expand Down Expand Up @@ -277,15 +284,40 @@ export const layer: Layer.Layer<
Effect.gen(function* () {
if (!(yield* enabled())) return
if (!(yield* exists(state.gitdir))) return
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
const days = yield* retentionDays()

// Remove pack files so old objects can't survive in packs
const packDir = path.join(state.gitdir, "objects", "pack")
if (yield* exists(packDir)) {
const entries = yield* fs.readDirectoryEntries(packDir).pipe(Effect.orDie)
for (const entry of entries) {
yield* fs.remove(path.join(packDir, entry.name)).pipe(Effect.catch(() => Effect.void))
}
}

// Prune loose objects older than retention period
const result = yield* git(args(["prune", `--expire=${days}.days`]))
if (result.code !== 0) {
log.warn("cleanup failed", {
log.warn("prune encountered errors (continuing cleanup)", {
exitCode: result.code,
stderr: result.stderr,
})
return
}
log.info("cleanup", { prune })

// Remove empty object directories
const objectsDir = path.join(state.gitdir, "objects")
const entries = yield* fs.readDirectoryEntries(objectsDir).pipe(Effect.orDie)
for (const entry of entries) {
if (entry.type === "directory" && entry.name !== "pack" && entry.name !== "info") {
const dirPath = path.join(objectsDir, entry.name)
const dirEntries = yield* fs.readDirectoryEntries(dirPath).pipe(Effect.orDie)
if (dirEntries.length === 0) {
yield* fs.remove(dirPath).pipe(Effect.catch(() => Effect.void))
}
}
}

log.info("cleanup", { retentionDays: days })
}),
)
})
Expand Down Expand Up @@ -363,7 +395,7 @@ export const layer: Layer.Layer<
exitCode: checkout.code,
stderr: checkout.stderr,
})
return

}
log.error("failed to restore snapshot", {
snapshot,
Expand Down Expand Up @@ -402,7 +434,7 @@ export const layer: Layer.Layer<
})
if (tree.code === 0 && tree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash })
return

}
log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
yield* remove(op.file)
Expand Down Expand Up @@ -583,7 +615,7 @@ export const layer: Layer.Layer<
stderr: err,
refs: refs.length,
})
return

}

const fail = (msg: string, extra?: Record<string, string>) => {
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/test/snapshot/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,18 @@ test("diffFull with whitespace changes", async () => {
})
})

test("snapshot config with boolean true uses default 7-day retention", async () => {
const cfg = { snapshot: true as true | number }
const retentionDays = cfg.snapshot === true ? 7 : cfg.snapshot
expect(retentionDays).toBe(7)
})

test("snapshot config with positive integer uses specified retention", async () => {
const cfg = { snapshot: 3 as true | number }
const retentionDays = cfg.snapshot === true ? 7 : cfg.snapshot
expect(retentionDays).toBe(3)
})

test("revert with overlapping files across patches uses first patch hash", async () => {
await using tmp = await bootstrap()
await WithInstance.provide({
Expand Down
5 changes: 4 additions & 1 deletion packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1124,7 +1124,10 @@ export type Config = {
watcher?: {
ignore?: Array<string>
}
snapshot?: boolean
/**
* Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true. Can also be set to a number to specify how many days snapshots should be retained for.
*/
snapshot?: boolean | number
plugin?: Array<
| string
| [
Expand Down
Loading