@bachi/pi-coder 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/AGENTS.md +100 -0
- package/config/pi-statusline.json +140 -0
- package/config/settings.json +38 -0
- package/config/web-search.json +5 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +123 -0
- package/docs/development.md +177 -0
- package/docs/extensions.md +292 -0
- package/docs/handbook.zh.md +432 -0
- package/docs/installation.md +124 -0
- package/docs/themes.md +107 -0
- package/extensions/ask-user-question/answers.test.ts +104 -0
- package/extensions/ask-user-question/answers.ts +72 -0
- package/extensions/ask-user-question/dialog.test.ts +180 -0
- package/extensions/ask-user-question/dialog.ts +102 -0
- package/extensions/ask-user-question/index.ts +253 -0
- package/extensions/ask-user-question/model.test.ts +275 -0
- package/extensions/ask-user-question/model.ts +259 -0
- package/extensions/ask-user-question/schema.ts +49 -0
- package/extensions/ask-user-question/types.ts +86 -0
- package/extensions/ask-user-question/validate.test.ts +183 -0
- package/extensions/ask-user-question/validate.ts +110 -0
- package/extensions/ask-user-question/view.ts +262 -0
- package/extensions/auto-default-model/default-model.test.ts +268 -0
- package/extensions/auto-default-model/index.ts +87 -0
- package/extensions/bash-command-collapse.ts +1476 -0
- package/extensions/below-editor-after-statusline.ts +118 -0
- package/extensions/clear-command.ts +29 -0
- package/extensions/cwd-statusline.ts +39 -0
- package/extensions/exit-command.ts +59 -0
- package/extensions/fenceless-code-block/index.test.ts +208 -0
- package/extensions/fenceless-code-block/index.ts +28 -0
- package/extensions/fenceless-code-block/render.test.ts +177 -0
- package/extensions/fenceless-code-block/render.ts +142 -0
- package/extensions/folder-history.ts +197 -0
- package/extensions/init-command.ts +163 -0
- package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
- package/extensions/prompt-editor/bash-prompt.ts +59 -0
- package/extensions/prompt-editor/render.test.ts +283 -0
- package/extensions/prompt-editor.ts +212 -0
- package/extensions/read-path-collapse.ts +474 -0
- package/extensions/recap/index.test.ts +348 -0
- package/extensions/recap/index.ts +462 -0
- package/extensions/recap/subagents.test.ts +144 -0
- package/extensions/recap/subagents.ts +128 -0
- package/extensions/rewind/README.md +229 -0
- package/extensions/rewind/checkpoints.test.ts +560 -0
- package/extensions/rewind/checkpoints.ts +820 -0
- package/extensions/rewind/flow.test.ts +756 -0
- package/extensions/rewind/flow.ts +362 -0
- package/extensions/rewind/index.ts +400 -0
- package/extensions/rewind/picker.ts +135 -0
- package/extensions/rewind/viewport.test.ts +76 -0
- package/extensions/rewind/viewport.ts +48 -0
- package/extensions/simple-task/gap.test.ts +147 -0
- package/extensions/simple-task/gap.ts +122 -0
- package/extensions/simple-task/index.ts +439 -0
- package/extensions/simple-task/types.ts +53 -0
- package/extensions/simple-task/widget.ts +86 -0
- package/extensions/startup-logo/header-guard.test.ts +274 -0
- package/extensions/startup-logo/header-guard.ts +166 -0
- package/extensions/startup-logo/index.test.ts +305 -0
- package/extensions/startup-logo/index.ts +194 -0
- package/extensions/startup-logo/loaded-sections.test.ts +257 -0
- package/extensions/startup-logo/loaded-sections.ts +267 -0
- package/extensions/startup-logo/logo.test.ts +124 -0
- package/extensions/startup-logo/logo.ts +124 -0
- package/extensions/statusline/footer-guard.test.ts +273 -0
- package/extensions/statusline/footer-guard.ts +171 -0
- package/extensions/statusline/git.test.ts +174 -0
- package/extensions/statusline/git.ts +142 -0
- package/extensions/statusline/index.ts +294 -0
- package/extensions/statusline/line.test.ts +316 -0
- package/extensions/statusline/line.ts +201 -0
- package/extensions/subagent-log-guard/filter.test.ts +85 -0
- package/extensions/subagent-log-guard/filter.ts +32 -0
- package/extensions/subagent-log-guard/index.ts +112 -0
- package/extensions/theme-command.ts +263 -0
- package/extensions/thinking-collapse/window.test.ts +321 -0
- package/extensions/thinking-collapse/window.ts +354 -0
- package/extensions/thinking-collapse.ts +60 -0
- package/extensions/tool-diff/title-row.test.ts +254 -0
- package/extensions/tool-diff/title-row.ts +191 -0
- package/extensions/tool-diff.ts +1276 -0
- package/extensions/working-indicator/bash-spinner.test.ts +135 -0
- package/extensions/working-indicator/bash-spinner.ts +114 -0
- package/extensions/working-indicator/index.test.ts +579 -0
- package/extensions/working-indicator/index.ts +940 -0
- package/extensions/working-indicator/spinner-frames.test.ts +219 -0
- package/extensions/working-indicator/spinner-frames.ts +156 -0
- package/extensions/working-indicator/summary-request.test.ts +195 -0
- package/extensions/working-indicator/summary-request.ts +207 -0
- package/extensions/working-indicator/working-summary.test.ts +499 -0
- package/extensions/working-indicator/working-summary.ts +375 -0
- package/package.json +71 -0
- package/themes/ayu.json +97 -0
- package/themes/catppuccin.json +103 -0
- package/themes/summer-night.json +87 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the pi-rewind checkpoint core.
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test ~/.pi/agent/extensions/rewind/checkpoints.test.ts
|
|
5
|
+
*
|
|
6
|
+
* Each test gets its own throwaway project + agent dir under the OS temp dir,
|
|
7
|
+
* so neither the real repo nor ~/.pi is touched.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
mkdtempSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { afterEach, describe, it } from "node:test";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
MAX_CHECKPOINTS_PER_SESSION,
|
|
27
|
+
RETENTION_DAYS,
|
|
28
|
+
attachEntryId,
|
|
29
|
+
captureExternalFile,
|
|
30
|
+
captureFileStates,
|
|
31
|
+
createCheckpoint,
|
|
32
|
+
deleteCheckpoint,
|
|
33
|
+
diffExternalFiles,
|
|
34
|
+
diffTrees,
|
|
35
|
+
externalRestorePlan,
|
|
36
|
+
findUnresolvedCheckpoint,
|
|
37
|
+
gc,
|
|
38
|
+
git,
|
|
39
|
+
isWorktreeCovered,
|
|
40
|
+
latestSafetyCheckpoint,
|
|
41
|
+
listCheckpoints,
|
|
42
|
+
listCheckpointsNewestFirst,
|
|
43
|
+
listProjectDirs,
|
|
44
|
+
mergeDiffs,
|
|
45
|
+
openStore,
|
|
46
|
+
pruneSession,
|
|
47
|
+
pruneStaleSessions,
|
|
48
|
+
restoreCheckpoint,
|
|
49
|
+
snapshotTree,
|
|
50
|
+
summarizeDiff,
|
|
51
|
+
type RewindStore,
|
|
52
|
+
} from "./checkpoints.ts";
|
|
53
|
+
|
|
54
|
+
const created: string[] = [];
|
|
55
|
+
|
|
56
|
+
/** Fresh project + agent dir pair; the temp base is removed after each test. */
|
|
57
|
+
function fixture(): { root: string; agentDir: string; store: RewindStore } {
|
|
58
|
+
const base = mkdtempSync(join(tmpdir(), "pi-rewind-test-"));
|
|
59
|
+
created.push(base);
|
|
60
|
+
const root = join(base, "project");
|
|
61
|
+
const agentDir = join(base, "agent");
|
|
62
|
+
mkdirSync(root, { recursive: true });
|
|
63
|
+
mkdirSync(agentDir, { recursive: true });
|
|
64
|
+
return { root, agentDir, store: openStore(root, agentDir) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function write(root: string, rel: string, content: string): void {
|
|
68
|
+
const full = join(root, rel);
|
|
69
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
70
|
+
writeFileSync(full, content, "utf8");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Write a file outside the project root (the agent dir is a sibling of it). */
|
|
74
|
+
function writeOutside(store: RewindStore, rel: string, content: string): string {
|
|
75
|
+
const full = join(dirname(store.root), "agent", rel);
|
|
76
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
77
|
+
writeFileSync(full, content, "utf8");
|
|
78
|
+
return full;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Bytes stored under a pre-image blob name. */
|
|
82
|
+
function blobText(store: RewindStore, blob: string | null): string {
|
|
83
|
+
assert.ok(blob, "expected a pre-image blob");
|
|
84
|
+
return readFileSync(join(store.filesDir, blob), "utf8");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function realGit(root: string, args: string[]): string {
|
|
88
|
+
return execFileSync("git", args, {
|
|
89
|
+
cwd: root,
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
env: {
|
|
92
|
+
...process.env,
|
|
93
|
+
GIT_AUTHOR_NAME: "test",
|
|
94
|
+
GIT_AUTHOR_EMAIL: "test@test",
|
|
95
|
+
GIT_COMMITTER_NAME: "test",
|
|
96
|
+
GIT_COMMITTER_EMAIL: "test@test",
|
|
97
|
+
},
|
|
98
|
+
}).trim();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function treeFiles(store: RewindStore, tree: string): Promise<string[]> {
|
|
102
|
+
const out = await git(store, ["ls-tree", "-r", "--name-only", tree]);
|
|
103
|
+
return out ? out.split("\n") : [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("checkpoints core", () => {
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
while (created.length > 0) {
|
|
109
|
+
rmSync(created.pop()!, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("snapshots the worktree without needing a real git repo", async () => {
|
|
114
|
+
const { root, store } = fixture();
|
|
115
|
+
write(root, "a.txt", "hello\n");
|
|
116
|
+
write(root, "sub/b.txt", "world\n");
|
|
117
|
+
|
|
118
|
+
const tree = await snapshotTree(store);
|
|
119
|
+
assert.match(tree, /^[0-9a-f]{40}$/);
|
|
120
|
+
assert.deepEqual(await treeFiles(store, tree), ["a.txt", "sub/b.txt"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("respects .gitignore and never snapshots ignored paths", async () => {
|
|
124
|
+
const { root, store } = fixture();
|
|
125
|
+
write(root, ".gitignore", "node_modules/\n*.log\n");
|
|
126
|
+
write(root, "keep.txt", "keep\n");
|
|
127
|
+
write(root, "node_modules/dep.js", "junk\n");
|
|
128
|
+
write(root, "debug.log", "noise\n");
|
|
129
|
+
|
|
130
|
+
const tree = await snapshotTree(store);
|
|
131
|
+
assert.deepEqual(await treeFiles(store, tree), [".gitignore", "keep.txt"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("reuses the commit when the worktree is unchanged", async () => {
|
|
135
|
+
const { root, store } = fixture();
|
|
136
|
+
write(root, "a.txt", "hello\n");
|
|
137
|
+
|
|
138
|
+
const first = await createCheckpoint(store, { sessionId: "s1", kind: "prompt", prompt: "first" });
|
|
139
|
+
const second = await createCheckpoint(store, { sessionId: "s1", kind: "prompt", prompt: "second" });
|
|
140
|
+
|
|
141
|
+
assert.equal(second.tree, first.tree);
|
|
142
|
+
assert.equal(second.commit, first.commit);
|
|
143
|
+
assert.notEqual(second.id, first.id);
|
|
144
|
+
assert.deepEqual(
|
|
145
|
+
listCheckpoints(store, "s1").map((cp) => cp.prompt),
|
|
146
|
+
["first", "second"],
|
|
147
|
+
);
|
|
148
|
+
assert.deepEqual(
|
|
149
|
+
listCheckpointsNewestFirst(store, "s1").map((cp) => cp.prompt),
|
|
150
|
+
["second", "first"],
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("records a distinct tree once files change and diffs them", async () => {
|
|
155
|
+
const { root, store } = fixture();
|
|
156
|
+
write(root, "a.txt", "v1\n");
|
|
157
|
+
const before = await createCheckpoint(store, { sessionId: "s2", kind: "prompt", prompt: "before" });
|
|
158
|
+
|
|
159
|
+
write(root, "a.txt", "v2\n");
|
|
160
|
+
const after = await createCheckpoint(store, { sessionId: "s2", kind: "prompt", prompt: "after" });
|
|
161
|
+
|
|
162
|
+
assert.notEqual(after.tree, before.tree);
|
|
163
|
+
const diff = await diffTrees(store, before.tree, after.tree);
|
|
164
|
+
assert.deepEqual(diff.entries, [{ status: "M", path: "a.txt" }]);
|
|
165
|
+
assert.equal(summarizeDiff(diff), "1 file: ~1");
|
|
166
|
+
assert.equal((await diffTrees(store, after.tree, after.tree)).empty, true);
|
|
167
|
+
assert.equal(summarizeDiff({ entries: [], empty: true }), "no file changes");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("reports added, modified and deleted files in a diff", async () => {
|
|
171
|
+
const { root, store } = fixture();
|
|
172
|
+
write(root, "a.txt", "a\n");
|
|
173
|
+
write(root, "b.txt", "b\n");
|
|
174
|
+
write(root, "c.txt", "c\n");
|
|
175
|
+
const before = await snapshotTree(store);
|
|
176
|
+
|
|
177
|
+
write(root, "a.txt", "a2\n");
|
|
178
|
+
rmSync(join(root, "b.txt"));
|
|
179
|
+
write(root, "d.txt", "d\n");
|
|
180
|
+
const after = await snapshotTree(store);
|
|
181
|
+
|
|
182
|
+
const diff = await diffTrees(store, before, after);
|
|
183
|
+
const byPath = Object.fromEntries(diff.entries.map((entry) => [entry.path, entry.status]));
|
|
184
|
+
assert.equal(byPath["a.txt"], "M");
|
|
185
|
+
assert.equal(byPath["b.txt"], "D");
|
|
186
|
+
assert.equal(byPath["d.txt"], "A");
|
|
187
|
+
assert.equal(summarizeDiff(diff), "3 files: +1 ~1 -1");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("restores modified, deleted and newly created files while sparing ignored ones", async () => {
|
|
191
|
+
const { root, store } = fixture();
|
|
192
|
+
write(root, ".gitignore", "ignored/\n");
|
|
193
|
+
write(root, "keep.txt", "original\n");
|
|
194
|
+
write(root, "gone.txt", "exists at checkpoint\n");
|
|
195
|
+
write(root, "ignored/build.js", "artifact\n");
|
|
196
|
+
|
|
197
|
+
const checkpoint = await createCheckpoint(store, { sessionId: "s4", kind: "prompt", prompt: "p" });
|
|
198
|
+
|
|
199
|
+
write(root, "keep.txt", "agent rewrote this\n");
|
|
200
|
+
rmSync(join(root, "gone.txt"));
|
|
201
|
+
write(root, "created-by-agent.txt", "should disappear\n");
|
|
202
|
+
write(root, "ignored/new-build.js", "should survive\n");
|
|
203
|
+
|
|
204
|
+
const result = await restoreCheckpoint(store, checkpoint);
|
|
205
|
+
|
|
206
|
+
assert.equal(readFileSync(join(root, "keep.txt"), "utf8"), "original\n");
|
|
207
|
+
assert.equal(readFileSync(join(root, "gone.txt"), "utf8"), "exists at checkpoint\n");
|
|
208
|
+
assert.equal(existsSync(join(root, "created-by-agent.txt")), false);
|
|
209
|
+
assert.deepEqual(result.removed, ["created-by-agent.txt"]);
|
|
210
|
+
assert.equal(existsSync(join(root, "ignored/new-build.js")), true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("never touches the project's own git index, HEAD, status or refs", async () => {
|
|
214
|
+
const { root, agentDir } = fixture();
|
|
215
|
+
realGit(root, ["init", "--quiet"]);
|
|
216
|
+
write(root, "tracked.txt", "v1\n");
|
|
217
|
+
realGit(root, ["add", "-A"]);
|
|
218
|
+
realGit(root, ["commit", "--quiet", "-m", "init"]);
|
|
219
|
+
const headBefore = realGit(root, ["rev-parse", "HEAD"]);
|
|
220
|
+
|
|
221
|
+
const store = openStore(root, agentDir);
|
|
222
|
+
const checkpoint = await createCheckpoint(store, { sessionId: "s5", kind: "prompt", prompt: "p" });
|
|
223
|
+
|
|
224
|
+
write(root, "tracked.txt", "v2\n");
|
|
225
|
+
write(root, "untracked.txt", "new\n");
|
|
226
|
+
realGit(root, ["add", "untracked.txt"]);
|
|
227
|
+
|
|
228
|
+
await restoreCheckpoint(store, checkpoint);
|
|
229
|
+
|
|
230
|
+
assert.equal(readFileSync(join(root, "tracked.txt"), "utf8"), "v1\n");
|
|
231
|
+
assert.equal(existsSync(join(root, "untracked.txt")), false);
|
|
232
|
+
assert.equal(realGit(root, ["rev-parse", "HEAD"]), headBefore);
|
|
233
|
+
// The staged entry is still in the user's index even though the file is gone.
|
|
234
|
+
assert.match(realGit(root, ["ls-files", "--cached"]), /untracked\.txt/);
|
|
235
|
+
// The user's repo carries no checkpoint refs and no checkpoint objects.
|
|
236
|
+
assert.equal(realGit(root, ["for-each-ref", "refs/pi-rewind"]), "");
|
|
237
|
+
assert.equal(realGit(root, ["status", "--porcelain"]).includes("pi-rewind"), false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("adopts the real repo's info/exclude so excluded paths survive a restore", async () => {
|
|
241
|
+
const base = mkdtempSync(join(tmpdir(), "pi-rewind-test-"));
|
|
242
|
+
created.push(base);
|
|
243
|
+
const root = join(base, "project");
|
|
244
|
+
const agentDir = join(base, "agent");
|
|
245
|
+
mkdirSync(root, { recursive: true });
|
|
246
|
+
mkdirSync(agentDir, { recursive: true });
|
|
247
|
+
|
|
248
|
+
// The real repo (and its exclude file) must exist before the store opens,
|
|
249
|
+
// which is the order pi sees in practice.
|
|
250
|
+
realGit(root, ["init", "--quiet"]);
|
|
251
|
+
mkdirSync(join(root, ".git", "info"), { recursive: true });
|
|
252
|
+
writeFileSync(join(root, ".git", "info", "exclude"), "secret/\n", "utf8");
|
|
253
|
+
write(root, "tracked.txt", "v1\n");
|
|
254
|
+
|
|
255
|
+
const store = openStore(root, agentDir);
|
|
256
|
+
const checkpoint = await createCheckpoint(store, { sessionId: "s6", kind: "prompt", prompt: "p" });
|
|
257
|
+
write(root, "secret/keys.txt", "created after the checkpoint\n");
|
|
258
|
+
|
|
259
|
+
// The exclude file really was adopted, so the path is not even snapshotted.
|
|
260
|
+
const tree = await snapshotTree(store);
|
|
261
|
+
assert.equal((await treeFiles(store, tree)).includes("secret/keys.txt"), false);
|
|
262
|
+
|
|
263
|
+
await restoreCheckpoint(store, checkpoint);
|
|
264
|
+
assert.equal(existsSync(join(root, "secret/keys.txt")), true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("attaches entry ids exactly once and finds unresolved checkpoints", async () => {
|
|
268
|
+
const { store } = fixture();
|
|
269
|
+
const checkpoint = await createCheckpoint(store, { sessionId: "s7", kind: "prompt", prompt: "p" });
|
|
270
|
+
|
|
271
|
+
assert.equal(checkpoint.entryId, undefined);
|
|
272
|
+
assert.equal(findUnresolvedCheckpoint(store, "s7")?.id, checkpoint.id);
|
|
273
|
+
assert.equal(attachEntryId(store, "s7", checkpoint.id, "entry-1"), true);
|
|
274
|
+
assert.equal(listCheckpoints(store, "s7")[0].entryId, "entry-1");
|
|
275
|
+
assert.equal(findUnresolvedCheckpoint(store, "s7"), undefined);
|
|
276
|
+
// Never overwrites an existing id, and ignores unknown ids.
|
|
277
|
+
assert.equal(attachEntryId(store, "s7", checkpoint.id, "entry-2"), false);
|
|
278
|
+
assert.equal(listCheckpoints(store, "s7")[0].entryId, "entry-1");
|
|
279
|
+
assert.equal(attachEntryId(store, "s7", "nope", "entry-3"), false);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("keeps safety checkpoints separate and exposes the newest one", async () => {
|
|
283
|
+
const { root, store } = fixture();
|
|
284
|
+
await createCheckpoint(store, { sessionId: "s8", kind: "prompt", prompt: "p" });
|
|
285
|
+
assert.equal(latestSafetyCheckpoint(store, "s8"), undefined);
|
|
286
|
+
|
|
287
|
+
const first = await createCheckpoint(store, { sessionId: "s8", kind: "safety", timestamp: 1000 });
|
|
288
|
+
write(root, "marker.txt", "x\n");
|
|
289
|
+
const second = await createCheckpoint(store, { sessionId: "s8", kind: "safety", timestamp: 2000 });
|
|
290
|
+
|
|
291
|
+
assert.equal(latestSafetyCheckpoint(store, "s8")?.id, second.id);
|
|
292
|
+
assert.notEqual(second.tree, first.tree);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("prunes a session to the cap but keeps safety rows", async () => {
|
|
296
|
+
const { root, store } = fixture();
|
|
297
|
+
write(root, "a.txt", "seed\n");
|
|
298
|
+
const sessionId = "s9";
|
|
299
|
+
const total = MAX_CHECKPOINTS_PER_SESSION + 5;
|
|
300
|
+
|
|
301
|
+
for (let i = 0; i < total; i += 1) {
|
|
302
|
+
await createCheckpoint(store, {
|
|
303
|
+
sessionId,
|
|
304
|
+
kind: "prompt",
|
|
305
|
+
prompt: `prompt ${i}`,
|
|
306
|
+
timestamp: 1_700_000_000_000 + i * 1000,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
const safety = await createCheckpoint(store, { sessionId, kind: "safety", timestamp: 1_799_000_000_000 });
|
|
310
|
+
|
|
311
|
+
assert.equal(await pruneSession(store, sessionId), 5);
|
|
312
|
+
|
|
313
|
+
const remaining = listCheckpoints(store, sessionId);
|
|
314
|
+
assert.equal(remaining.length, MAX_CHECKPOINTS_PER_SESSION + 1);
|
|
315
|
+
assert.equal(remaining.some((cp) => cp.id === safety.id), true);
|
|
316
|
+
assert.equal(remaining.some((cp) => cp.prompt === "prompt 0"), false);
|
|
317
|
+
assert.equal(remaining.some((cp) => cp.prompt === `prompt ${total - 1}`), true);
|
|
318
|
+
|
|
319
|
+
const refs = await git(store, ["for-each-ref", "--format=%(refname)", "refs/pi-rewind"]);
|
|
320
|
+
assert.equal(refs.split("\n").filter(Boolean).length, remaining.length);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("sweeps sessions past the retention window", async () => {
|
|
324
|
+
const { store } = fixture();
|
|
325
|
+
const now = Date.now();
|
|
326
|
+
await createCheckpoint(store, {
|
|
327
|
+
sessionId: "old",
|
|
328
|
+
kind: "prompt",
|
|
329
|
+
prompt: "old",
|
|
330
|
+
timestamp: now - (RETENTION_DAYS + 1) * 24 * 60 * 60 * 1000,
|
|
331
|
+
});
|
|
332
|
+
await createCheckpoint(store, { sessionId: "fresh", kind: "prompt", prompt: "fresh", timestamp: now });
|
|
333
|
+
|
|
334
|
+
assert.equal(await pruneStaleSessions(store, now), 1);
|
|
335
|
+
assert.deepEqual(listCheckpoints(store, "old"), []);
|
|
336
|
+
assert.equal(listCheckpoints(store, "fresh").length, 1);
|
|
337
|
+
assert.equal(await pruneStaleSessions(store, now), 0);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("deletes a single checkpoint and its ref", async () => {
|
|
341
|
+
const { store } = fixture();
|
|
342
|
+
const checkpoint = await createCheckpoint(store, { sessionId: "s10", kind: "prompt", prompt: "p" });
|
|
343
|
+
|
|
344
|
+
await deleteCheckpoint(store, checkpoint);
|
|
345
|
+
|
|
346
|
+
assert.deepEqual(listCheckpoints(store, "s10"), []);
|
|
347
|
+
assert.equal(await git(store, ["for-each-ref", "--format=%(refname)", "refs/pi-rewind"]), "");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("keeps checkpoint objects reachable so gc cannot prune a live checkpoint", async () => {
|
|
351
|
+
const { root, store } = fixture();
|
|
352
|
+
write(root, "a.txt", "checkpointed\n");
|
|
353
|
+
const checkpoint = await createCheckpoint(store, { sessionId: "s11", kind: "prompt", prompt: "p" });
|
|
354
|
+
|
|
355
|
+
await gc(store);
|
|
356
|
+
write(root, "a.txt", "mutated after checkpoint\n");
|
|
357
|
+
await restoreCheckpoint(store, checkpoint);
|
|
358
|
+
|
|
359
|
+
assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "checkpointed\n");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("isolates sessions and lists project dirs", async () => {
|
|
363
|
+
const { agentDir, store } = fixture();
|
|
364
|
+
await createCheckpoint(store, { sessionId: "alpha", kind: "prompt", prompt: "a" });
|
|
365
|
+
await createCheckpoint(store, { sessionId: "beta", kind: "prompt", prompt: "b" });
|
|
366
|
+
|
|
367
|
+
assert.deepEqual(listCheckpoints(store, "alpha").map((cp) => cp.prompt), ["a"]);
|
|
368
|
+
assert.deepEqual(listCheckpoints(store, "beta").map((cp) => cp.prompt), ["b"]);
|
|
369
|
+
assert.deepEqual(listCheckpoints(store, "gamma"), []);
|
|
370
|
+
|
|
371
|
+
const dirs = listProjectDirs(agentDir);
|
|
372
|
+
assert.ok(dirs.some((dir) => dir.endsWith(store.projectKey)));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("starts a fresh list after a corrupt metadata file and keeps working", async () => {
|
|
376
|
+
const { store } = fixture();
|
|
377
|
+
await createCheckpoint(store, { sessionId: "s12", kind: "prompt", prompt: "p" });
|
|
378
|
+
writeFileSync(store.metaFile, "{ not json", "utf8");
|
|
379
|
+
|
|
380
|
+
assert.deepEqual(listCheckpoints(store, "s12"), []);
|
|
381
|
+
|
|
382
|
+
const checkpoint = await createCheckpoint(store, { sessionId: "s12", kind: "prompt", prompt: "q" });
|
|
383
|
+
assert.deepEqual(listCheckpoints(store, "s12").map((cp) => cp.id), [checkpoint.id]);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("reopens the same store for the same root and keeps its checkpoints", async () => {
|
|
387
|
+
const { root, agentDir, store } = fixture();
|
|
388
|
+
write(root, "a.txt", "hello\n");
|
|
389
|
+
const checkpoint = await createCheckpoint(store, { sessionId: "s13", kind: "prompt", prompt: "p" });
|
|
390
|
+
|
|
391
|
+
const reopened = openStore(root, agentDir);
|
|
392
|
+
assert.equal(reopened.gitDir, store.gitDir);
|
|
393
|
+
assert.deepEqual(listCheckpoints(reopened, "s13").map((cp) => cp.id), [checkpoint.id]);
|
|
394
|
+
|
|
395
|
+
// A different root gets a different shadow repo.
|
|
396
|
+
const otherRoot = join(dirname(root), "other");
|
|
397
|
+
mkdirSync(otherRoot, { recursive: true });
|
|
398
|
+
const otherStore = openStore(otherRoot, agentDir);
|
|
399
|
+
assert.notEqual(otherStore.projectKey, store.projectKey);
|
|
400
|
+
assert.deepEqual(listCheckpoints(otherStore, "s13"), []);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The worktree snapshot only sees tracked paths under the project root. Files
|
|
406
|
+
* the agent edits elsewhere (`~/.pi/agent/themes/…`) — or in an ignored path —
|
|
407
|
+
* are captured as pre-image blobs instead, which is what makes `/rewind` show
|
|
408
|
+
* and perform a code restore for them.
|
|
409
|
+
*/
|
|
410
|
+
describe("pre-images for files the worktree snapshot cannot see", () => {
|
|
411
|
+
afterEach(() => {
|
|
412
|
+
while (created.length > 0) {
|
|
413
|
+
rmSync(created.pop()!, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("tells snapshot-covered paths from captured ones", async () => {
|
|
418
|
+
const { root, store } = fixture();
|
|
419
|
+
write(root, ".gitignore", "local.settings\nnode_modules/\n");
|
|
420
|
+
write(root, "a.txt", "a\n");
|
|
421
|
+
write(root, "local.settings", "ignored\n");
|
|
422
|
+
const outside = writeOutside(store, "themes/verdigris.json", "{}\n");
|
|
423
|
+
|
|
424
|
+
assert.equal(isWorktreeCovered(store, join(root, "a.txt")), true);
|
|
425
|
+
assert.equal(isWorktreeCovered(store, join(root, "sub/b.txt")), true);
|
|
426
|
+
assert.equal(isWorktreeCovered(store, join(root, "local.settings")), false);
|
|
427
|
+
assert.equal(isWorktreeCovered(store, outside), false);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("records one pre-image per file per checkpoint, and skips covered paths", async () => {
|
|
431
|
+
const { root, store } = fixture();
|
|
432
|
+
write(root, "a.txt", "in the worktree\n");
|
|
433
|
+
const outside = writeOutside(store, "themes/t.json", "v1\n");
|
|
434
|
+
await createCheckpoint(store, { sessionId: "s", kind: "prompt", prompt: "tweak the theme" });
|
|
435
|
+
|
|
436
|
+
assert.equal(captureExternalFile(store, "s", "a.txt"), false);
|
|
437
|
+
assert.equal(captureExternalFile(store, "s", join(root, "a.txt")), false);
|
|
438
|
+
assert.equal(captureExternalFile(store, "s", outside), true);
|
|
439
|
+
|
|
440
|
+
// The turn keeps writing to it; only the first touch is the pre-image.
|
|
441
|
+
writeFileSync(outside, "v2\n");
|
|
442
|
+
assert.equal(captureExternalFile(store, "s", outside), false);
|
|
443
|
+
|
|
444
|
+
// A file that does not exist yet is recorded as "delete on restore".
|
|
445
|
+
const created = join(dirname(store.root), "agent", "themes", "new.json");
|
|
446
|
+
assert.equal(captureExternalFile(store, "s", created), true);
|
|
447
|
+
|
|
448
|
+
const files = listCheckpoints(store, "s")[0].files!;
|
|
449
|
+
assert.deepEqual(
|
|
450
|
+
files.map((file) => [file.path, file.blob === null]),
|
|
451
|
+
[
|
|
452
|
+
[outside, false],
|
|
453
|
+
[created, true],
|
|
454
|
+
],
|
|
455
|
+
);
|
|
456
|
+
assert.equal(blobText(store, files[0].blob), "v1\n");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("diffs pre-images against the disk state", async () => {
|
|
460
|
+
const { store } = fixture();
|
|
461
|
+
const modified = writeOutside(store, "a.json", "before\n");
|
|
462
|
+
const vanished = writeOutside(store, "b.json", "before\n");
|
|
463
|
+
const created = join(dirname(store.root), "agent", "c.json");
|
|
464
|
+
const untouched = writeOutside(store, "d.json", "same\n");
|
|
465
|
+
await createCheckpoint(store, { sessionId: "s", kind: "prompt", prompt: "p" });
|
|
466
|
+
for (const path of [modified, vanished, created, untouched]) {
|
|
467
|
+
assert.equal(captureExternalFile(store, "s", path), true);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
writeFileSync(modified, "after\n");
|
|
471
|
+
rmSync(vanished);
|
|
472
|
+
writeFileSync(created, "appeared later\n");
|
|
473
|
+
|
|
474
|
+
const diff = diffExternalFiles(store, externalRestorePlan(listCheckpoints(store, "s"), 0));
|
|
475
|
+
assert.deepEqual(diff.entries, [
|
|
476
|
+
{ status: "M", path: modified },
|
|
477
|
+
{ status: "D", path: vanished },
|
|
478
|
+
{ status: "A", path: created },
|
|
479
|
+
]);
|
|
480
|
+
assert.equal(diff.empty, false);
|
|
481
|
+
assert.equal(diffExternalFiles(store, []).empty, true);
|
|
482
|
+
// A tree diff and a pre-image diff of the same path are reported once.
|
|
483
|
+
assert.deepEqual(
|
|
484
|
+
mergeDiffs({ entries: [{ status: "M", path: modified }], empty: false }, diff).entries.length,
|
|
485
|
+
3,
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("restores, recreates and deletes outside-project files", async () => {
|
|
490
|
+
const { store } = fixture();
|
|
491
|
+
const modified = writeOutside(store, "a.json", "before\n");
|
|
492
|
+
const deleted = writeOutside(store, "b.json", "before\n");
|
|
493
|
+
const created = join(dirname(store.root), "agent", "c.json");
|
|
494
|
+
await createCheckpoint(store, { sessionId: "s", kind: "prompt", prompt: "p" });
|
|
495
|
+
for (const path of [modified, deleted, created]) {
|
|
496
|
+
assert.equal(captureExternalFile(store, "s", path), true);
|
|
497
|
+
}
|
|
498
|
+
writeFileSync(modified, "after\n");
|
|
499
|
+
rmSync(deleted);
|
|
500
|
+
writeFileSync(created, "appeared later\n");
|
|
501
|
+
|
|
502
|
+
const targets = listCheckpoints(store, "s");
|
|
503
|
+
const plan = externalRestorePlan(targets, 0);
|
|
504
|
+
const result = await restoreCheckpoint(store, targets[0], { files: plan });
|
|
505
|
+
|
|
506
|
+
assert.equal(readFileSync(modified, "utf8"), "before\n");
|
|
507
|
+
assert.equal(readFileSync(deleted, "utf8"), "before\n");
|
|
508
|
+
assert.equal(existsSync(created), false);
|
|
509
|
+
assert.deepEqual(result.removed, [created]);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("resolves each path from the earliest touch at or after the checkpoint", async () => {
|
|
513
|
+
const { store } = fixture();
|
|
514
|
+
const file = writeOutside(store, "theme.json", "v0\n");
|
|
515
|
+
|
|
516
|
+
const first = await createCheckpoint(store, { sessionId: "s", kind: "prompt", prompt: "one" });
|
|
517
|
+
assert.equal(captureExternalFile(store, "s", file), true);
|
|
518
|
+
writeFileSync(file, "v1\n");
|
|
519
|
+
|
|
520
|
+
const second = await createCheckpoint(store, { sessionId: "s", kind: "prompt", prompt: "two" });
|
|
521
|
+
assert.equal(captureExternalFile(store, "s", file), true);
|
|
522
|
+
writeFileSync(file, "v2\n");
|
|
523
|
+
|
|
524
|
+
const all = listCheckpoints(store, "s");
|
|
525
|
+
assert.equal(all.length, 2);
|
|
526
|
+
assert.equal(blobText(store, externalRestorePlan(all, 0)[0].blob), "v0\n");
|
|
527
|
+
assert.equal(blobText(store, externalRestorePlan(all, 1)[0].blob), "v1\n");
|
|
528
|
+
|
|
529
|
+
// The undo net snapshots the current bytes, not the recorded pre-image.
|
|
530
|
+
const undo = captureFileStates(store, externalRestorePlan(all, 0));
|
|
531
|
+
assert.equal(blobText(store, undo[0].blob), "v2\n");
|
|
532
|
+
assert.equal(listCheckpoints(store, "s").filter((cp) => cp.files?.length).length, 2);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("drops pre-image blobs once no checkpoint references them", async () => {
|
|
536
|
+
const { store } = fixture();
|
|
537
|
+
const file = writeOutside(store, "theme.json", "v1\n");
|
|
538
|
+
const first = await createCheckpoint(store, { sessionId: "s", kind: "prompt", prompt: "one" });
|
|
539
|
+
assert.equal(captureExternalFile(store, "s", file), true);
|
|
540
|
+
writeFileSync(file, "v2\n");
|
|
541
|
+
await createCheckpoint(store, { sessionId: "s", kind: "prompt", prompt: "two" });
|
|
542
|
+
assert.equal(captureExternalFile(store, "s", file), true);
|
|
543
|
+
assert.deepEqual(
|
|
544
|
+
listCheckpoints(store, "s").map((cp) => cp.files?.map((f) => blobText(store, f.blob))),
|
|
545
|
+
[["v1\n"], ["v2\n"]],
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
assert.equal(readdirSync(store.filesDir).length, 2);
|
|
549
|
+
await deleteCheckpoint(store, first);
|
|
550
|
+
|
|
551
|
+
// Blob of the pruned checkpoint is gone; the surviving one is kept.
|
|
552
|
+
const left = readdirSync(store.filesDir);
|
|
553
|
+
assert.equal(left.length, 1);
|
|
554
|
+
assert.equal(readFileSync(join(store.filesDir, left[0]), "utf8"), "v2\n");
|
|
555
|
+
assert.deepEqual(
|
|
556
|
+
listCheckpoints(store, "s").map((cp) => cp.prompt),
|
|
557
|
+
["two"],
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
});
|