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 */
16import { VcsUnsupportedOperationError, type CheckpointRef } from "@t3tools/contracts";
17import * as Context from "effect/Context";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20
21import type { CheckpointStoreError } from "./Errors.ts";
22import type { VcsCheckpointOps } from "../vcs/VcsDriver.ts";
23import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
24
25export interface CaptureCheckpointInput {
26 readonly cwd: string;
27 readonly checkpointRef: CheckpointRef;
28}
29
30export interface RestoreCheckpointInput {
31 readonly cwd: string;
32 readonly checkpointRef: CheckpointRef;
33 readonly fallbackToHead?: boolean;
34}
35
36export 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
47export interface ListAuthoredPathsInput {
48 readonly cwd: string;
49 readonly fromCheckpointRef: CheckpointRef;
50 readonly toCheckpointRef: CheckpointRef;
51}
52
53export interface DeleteCheckpointRefsInput {
54 readonly cwd: string;
55 readonly checkpointRefs: ReadonlyArray<CheckpointRef>;
56}
57
58/** Service tag for checkpoint persistence and restore operations. */
59export 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. */
123export 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
202export const layer = Layer.effect(CheckpointStore, make);