| 1 | /** |
| 2 | * CheckpointStore - Repository interface for filesystem-backed workspace checkpoints. |
| 3 | * |
| 4 | * Owns hidden Git-ref checkpoint capture/restore and diff computation for a |
| 5 | * workspace thread timeline. It does not store user-facing checkpoint metadata |
| 6 | * and does not coordinate provider conversation rollback. |
| 7 | * |
| 8 | * The live adapter resolves the active VCS driver once per checkpoint operation |
| 9 | * and delegates to the driver's optional checkpoint capability. |
| 10 | * |
| 11 | * Uses Effect `Context.Service` for dependency injection and exposes typed |
| 12 | * domain errors for checkpoint storage operations. |
| 13 | * |
| 14 | * @module CheckpointStore |
| 15 | */ |
| 16 | import { VcsUnsupportedOperationError, type CheckpointRef } from "@t3tools/contracts"; |
| 17 | import * as Context from "effect/Context"; |
| 18 | import * as Effect from "effect/Effect"; |
| 19 | import * as Layer from "effect/Layer"; |
| 20 | |
| 21 | import type { CheckpointStoreError } from "./Errors.ts"; |
| 22 | import type { VcsCheckpointOps } from "../vcs/VcsDriver.ts"; |
| 23 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 24 | |
| 25 | export interface CaptureCheckpointInput { |
| 26 | readonly cwd: string; |
| 27 | readonly checkpointRef: CheckpointRef; |
| 28 | } |
| 29 | |
| 30 | export interface RestoreCheckpointInput { |
| 31 | readonly cwd: string; |
| 32 | readonly checkpointRef: CheckpointRef; |
| 33 | readonly fallbackToHead?: boolean; |
| 34 | } |
| 35 | |
| 36 | export interface DiffCheckpointsInput { |
| 37 | readonly cwd: string; |
| 38 | readonly fromCheckpointRef: CheckpointRef; |
| 39 | readonly toCheckpointRef: CheckpointRef; |
| 40 | readonly fallbackFromToHead?: boolean; |
| 41 | readonly ignoreWhitespace: boolean; |
| 42 | readonly format?: "patch" | "numstat"; |
| 43 | /** Limits the diff to these exact paths. An empty list yields an empty diff. */ |
| 44 | readonly filePaths?: ReadonlyArray<string>; |
| 45 | } |
| 46 | |
| 47 | export interface ListAuthoredPathsInput { |
| 48 | readonly cwd: string; |
| 49 | readonly fromCheckpointRef: CheckpointRef; |
| 50 | readonly toCheckpointRef: CheckpointRef; |
| 51 | } |
| 52 | |
| 53 | export interface DeleteCheckpointRefsInput { |
| 54 | readonly cwd: string; |
| 55 | readonly checkpointRefs: ReadonlyArray<CheckpointRef>; |
| 56 | } |
| 57 | |
| 58 | /** Service tag for checkpoint persistence and restore operations. */ |
| 59 | export class CheckpointStore extends Context.Service< |
| 60 | CheckpointStore, |
| 61 | { |
| 62 | /** Check whether cwd is inside a Git worktree. */ |
| 63 | readonly isGitRepository: (cwd: string) => Effect.Effect<boolean, CheckpointStoreError>; |
| 64 | |
| 65 | /** |
| 66 | * Capture a checkpoint commit and store it at the provided checkpoint ref. |
| 67 | * |
| 68 | * Uses an isolated temporary Git index and writes a hidden ref. |
| 69 | */ |
| 70 | readonly captureCheckpoint: ( |
| 71 | input: CaptureCheckpointInput, |
| 72 | ) => Effect.Effect<void, CheckpointStoreError>; |
| 73 | |
| 74 | /** Check whether a checkpoint ref exists. */ |
| 75 | readonly hasCheckpointRef: ( |
| 76 | input: Omit<RestoreCheckpointInput, "fallbackToHead">, |
| 77 | ) => Effect.Effect<boolean, CheckpointStoreError>; |
| 78 | |
| 79 | /** |
| 80 | * Restore workspace and staging state to a checkpoint. |
| 81 | * |
| 82 | * Optionally falls back to current `HEAD` when the checkpoint ref is missing. |
| 83 | */ |
| 84 | readonly restoreCheckpoint: ( |
| 85 | input: RestoreCheckpointInput, |
| 86 | ) => Effect.Effect<boolean, CheckpointStoreError>; |
| 87 | |
| 88 | /** |
| 89 | * Compute a diff between two checkpoint refs. Defaults to a full patch. |
| 90 | * |
| 91 | * Numstat output has NUL-delimited paths for file summaries. |
| 92 | * Can optionally treat a missing "from" ref as `HEAD`. |
| 93 | */ |
| 94 | readonly diffCheckpoints: ( |
| 95 | input: DiffCheckpointsInput, |
| 96 | ) => Effect.Effect<string, CheckpointStoreError>; |
| 97 | |
| 98 | /** |
| 99 | * List paths changed by work done after the "from" checkpoint: uncommitted |
| 100 | * edits at either checkpoint, commits made after "from", and commits that |
| 101 | * left HEAD. Commits a pull, merge, or rebase brought in are older than |
| 102 | * "from", so their paths are not listed. |
| 103 | * |
| 104 | * Returns null when HEAD did not move or a checkpoint does not record HEAD. |
| 105 | * Then every changed path belongs to the turn. |
| 106 | */ |
| 107 | readonly listAuthoredPaths: ( |
| 108 | input: ListAuthoredPathsInput, |
| 109 | ) => Effect.Effect<ReadonlySet<string> | null, CheckpointStoreError>; |
| 110 | |
| 111 | /** |
| 112 | * Delete the provided checkpoint refs. |
| 113 | * |
| 114 | * Best-effort delete: missing refs are tolerated. |
| 115 | */ |
| 116 | readonly deleteCheckpointRefs: ( |
| 117 | input: DeleteCheckpointRefsInput, |
| 118 | ) => Effect.Effect<void, CheckpointStoreError>; |
| 119 | } |
| 120 | >()("t3/checkpointing/CheckpointStore") {} |
| 121 | |
| 122 | /** @public Service construction is part of the canonical Effect module API. */ |
| 123 | export const make = Effect.gen(function* () { |
| 124 | const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; |
| 125 | |
| 126 | const resolveCheckpoints = Effect.fn("CheckpointStore.resolveCheckpoints")(function* ( |
| 127 | operation: string, |
| 128 | cwd: string, |
| 129 | ) { |
| 130 | const handle = yield* vcsRegistry.resolve({ cwd }); |
| 131 | if (!handle.driver.checkpoints) { |
| 132 | return yield* new VcsUnsupportedOperationError({ |
| 133 | operation, |
| 134 | kind: handle.kind, |
| 135 | detail: `${handle.kind} driver does not implement checkpoint operations.`, |
| 136 | }); |
| 137 | } |
| 138 | return handle.driver.checkpoints satisfies VcsCheckpointOps; |
| 139 | }); |
| 140 | |
| 141 | const isGitRepository: CheckpointStore["Service"]["isGitRepository"] = (cwd) => |
| 142 | vcsRegistry |
| 143 | .detect({ cwd, requestedKind: "git" }) |
| 144 | .pipe(Effect.map((repository) => repository !== null)); |
| 145 | |
| 146 | const captureCheckpoint: CheckpointStore["Service"]["captureCheckpoint"] = Effect.fn( |
| 147 | "captureCheckpoint", |
| 148 | )(function* (input) { |
| 149 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.captureCheckpoint", input.cwd); |
| 150 | return yield* checkpoints.captureCheckpoint(input); |
| 151 | }); |
| 152 | |
| 153 | const hasCheckpointRef: CheckpointStore["Service"]["hasCheckpointRef"] = Effect.fn( |
| 154 | "hasCheckpointRef", |
| 155 | )(function* (input) { |
| 156 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.hasCheckpointRef", input.cwd); |
| 157 | return yield* checkpoints.hasCheckpointRef(input); |
| 158 | }); |
| 159 | |
| 160 | const restoreCheckpoint: CheckpointStore["Service"]["restoreCheckpoint"] = Effect.fn( |
| 161 | "restoreCheckpoint", |
| 162 | )(function* (input) { |
| 163 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.restoreCheckpoint", input.cwd); |
| 164 | return yield* checkpoints.restoreCheckpoint(input); |
| 165 | }); |
| 166 | |
| 167 | const diffCheckpoints: CheckpointStore["Service"]["diffCheckpoints"] = Effect.fn( |
| 168 | "diffCheckpoints", |
| 169 | )(function* (input) { |
| 170 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.diffCheckpoints", input.cwd); |
| 171 | return yield* checkpoints.diffCheckpoints(input); |
| 172 | }); |
| 173 | |
| 174 | const listAuthoredPaths: CheckpointStore["Service"]["listAuthoredPaths"] = Effect.fn( |
| 175 | "listAuthoredPaths", |
| 176 | )(function* (input) { |
| 177 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.listAuthoredPaths", input.cwd); |
| 178 | return yield* checkpoints.listAuthoredPaths(input); |
| 179 | }); |
| 180 | |
| 181 | const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn( |
| 182 | "deleteCheckpointRefs", |
| 183 | )(function* (input) { |
| 184 | const checkpoints = yield* resolveCheckpoints( |
| 185 | "CheckpointStore.deleteCheckpointRefs", |
| 186 | input.cwd, |
| 187 | ); |
| 188 | return yield* checkpoints.deleteCheckpointRefs(input); |
| 189 | }); |
| 190 | |
| 191 | return CheckpointStore.of({ |
| 192 | isGitRepository, |
| 193 | captureCheckpoint, |
| 194 | hasCheckpointRef, |
| 195 | restoreCheckpoint, |
| 196 | diffCheckpoints, |
| 197 | listAuthoredPaths, |
| 198 | deleteCheckpointRefs, |
| 199 | }); |
| 200 | }); |
| 201 | |
| 202 | export const layer = Layer.effect(CheckpointStore, make); |