diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3a933f81e967..2cd2d73ef87b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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))), diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index ea30f5afc7ca..b913f1247cae 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -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] @@ -42,6 +41,7 @@ interface GitResult { readonly stderr: string } +const defaultRetentionDays = 7 type State = Omit export interface Interface { @@ -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* () { @@ -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 }) }), ) }) @@ -363,7 +395,7 @@ export const layer: Layer.Layer< exitCode: checkout.code, stderr: checkout.stderr, }) - return + } log.error("failed to restore snapshot", { snapshot, @@ -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) @@ -583,7 +615,7 @@ export const layer: Layer.Layer< stderr: err, refs: refs.length, }) - return + } const fail = (msg: string, extra?: Record) => { diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 99ddfe72d456..4385ca3510c8 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -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({ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 86c5a762b114..46ed15b94129 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1124,7 +1124,10 @@ export type Config = { watcher?: { ignore?: Array } - 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 | [