pi-rewind-hook 1.0.0 → 1.1.1
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 +36 -0
- package/README.md +6 -0
- package/index.ts +186 -51
- package/package.json +1 -1
- package/rewind1.png +0 -0
- package/rewind2.png +0 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [1.1.1] - 2024-12-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- Use `before_branch` event instead of `branch` for proper hook timing (thanks @badlogic)
|
|
9
|
+
- Cancel branch when user dismisses restore options menu
|
|
10
|
+
|
|
11
|
+
## [1.1.0] - 2024-12-27
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- "Undo last file rewind" option - restore files to state before last rewind
|
|
15
|
+
- Checkpoints now capture uncommitted and untracked files (not just HEAD)
|
|
16
|
+
- Git repo detection - hook gracefully skips in non-git directories
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Checkpoints use `git write-tree` with temp index to capture working directory state
|
|
20
|
+
- Pruning excludes before-restore ref and current session's resume checkpoint
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Code-only restore options now properly skip conversation restore
|
|
24
|
+
|
|
25
|
+
## [1.0.0] - 2024-12-19
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- Initial release
|
|
29
|
+
- Automatic checkpoints at session start and each turn
|
|
30
|
+
- `/branch` integration with restore options:
|
|
31
|
+
- Restore all (files + conversation)
|
|
32
|
+
- Conversation only (keep current files)
|
|
33
|
+
- Code only (restore files, keep conversation)
|
|
34
|
+
- Resume checkpoint for pre-session messages
|
|
35
|
+
- Automatic pruning (keeps last 100 checkpoints)
|
|
36
|
+
- Cross-platform installation via `npx pi-rewind-hook`
|
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
A Pi agent hook that enables rewinding file changes during coding sessions. Creates automatic checkpoints using git refs, allowing you to restore files to previous states while optionally preserving conversation history.
|
|
4
4
|
|
|
5
|
+
## Screenshots
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
5
11
|
## Requirements
|
|
6
12
|
|
|
7
13
|
- Pi agent v0.18.0+
|
package/index.ts
CHANGED
|
@@ -1,28 +1,139 @@
|
|
|
1
1
|
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
|
2
|
+
import { exec as execCb } from "child_process";
|
|
3
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { promisify } from "util";
|
|
7
|
+
|
|
8
|
+
const execAsync = promisify(execCb);
|
|
2
9
|
|
|
3
10
|
const REF_PREFIX = "refs/pi-checkpoints/";
|
|
11
|
+
const BEFORE_RESTORE_PREFIX = "before-restore-";
|
|
4
12
|
const MAX_CHECKPOINTS = 100;
|
|
5
13
|
|
|
14
|
+
type ExecFn = (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number }>;
|
|
15
|
+
|
|
6
16
|
export default function (pi: HookAPI) {
|
|
7
17
|
const checkpoints = new Map<number, string>();
|
|
8
18
|
let resumeCheckpoint: string | null = null;
|
|
19
|
+
let repoRoot: string | null = null;
|
|
20
|
+
let isGitRepo = false;
|
|
9
21
|
|
|
10
22
|
console.error(`[rewind] Hook loaded`);
|
|
11
23
|
|
|
12
|
-
|
|
13
|
-
|
|
24
|
+
async function findBeforeRestoreRef(exec: ExecFn): Promise<{ refName: string; commitSha: string } | null> {
|
|
25
|
+
try {
|
|
26
|
+
const result = await exec("git", [
|
|
27
|
+
"for-each-ref",
|
|
28
|
+
"--sort=-creatordate",
|
|
29
|
+
"--count=1",
|
|
30
|
+
"--format=%(refname) %(objectname)",
|
|
31
|
+
`${REF_PREFIX}${BEFORE_RESTORE_PREFIX}*`,
|
|
32
|
+
]);
|
|
14
33
|
|
|
15
|
-
|
|
34
|
+
const line = result.stdout.trim();
|
|
35
|
+
if (!line) return null;
|
|
36
|
+
|
|
37
|
+
const [refName, commitSha] = line.split(" ");
|
|
38
|
+
return { refName, commitSha };
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function getRepoRoot(exec: ExecFn): Promise<string> {
|
|
45
|
+
if (repoRoot) return repoRoot;
|
|
46
|
+
const result = await exec("git", ["rev-parse", "--show-toplevel"]);
|
|
47
|
+
repoRoot = result.stdout.trim();
|
|
48
|
+
return repoRoot;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function captureWorktree(exec: ExecFn): Promise<string> {
|
|
52
|
+
const root = await getRepoRoot(exec);
|
|
53
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "pi-rewind-"));
|
|
54
|
+
const tmpIndex = join(tmpDir, "index");
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
|
|
58
|
+
await execAsync("git add -A", { cwd: root, env });
|
|
59
|
+
const { stdout: treeSha } = await execAsync("git write-tree", { cwd: root, env });
|
|
60
|
+
|
|
61
|
+
const { stdout: commitSha } = await execAsync(
|
|
62
|
+
`git commit-tree ${treeSha.trim()} -m "rewind backup"`,
|
|
63
|
+
{ cwd: root }
|
|
64
|
+
);
|
|
65
|
+
return commitSha.trim();
|
|
66
|
+
} finally {
|
|
67
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
16
70
|
|
|
71
|
+
async function restoreWithBackup(
|
|
72
|
+
exec: ExecFn,
|
|
73
|
+
targetRef: string,
|
|
74
|
+
notify: (msg: string, level: "success" | "error" | "info") => void
|
|
75
|
+
): Promise<boolean> {
|
|
17
76
|
try {
|
|
18
|
-
await
|
|
77
|
+
const existingBackup = await findBeforeRestoreRef(exec);
|
|
78
|
+
|
|
79
|
+
const backupCommit = await captureWorktree(exec);
|
|
80
|
+
const newBackupId = `${BEFORE_RESTORE_PREFIX}${Date.now()}`;
|
|
81
|
+
await exec("git", [
|
|
82
|
+
"update-ref",
|
|
83
|
+
`${REF_PREFIX}${newBackupId}`,
|
|
84
|
+
backupCommit,
|
|
85
|
+
]);
|
|
86
|
+
console.error(`[rewind] Created backup: ${newBackupId}`);
|
|
87
|
+
|
|
88
|
+
if (existingBackup) {
|
|
89
|
+
await exec("git", ["update-ref", "-d", existingBackup.refName]);
|
|
90
|
+
console.error(`[rewind] Deleted old backup: ${existingBackup.refName}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await exec("git", ["checkout", targetRef, "--", "."]);
|
|
94
|
+
return true;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[rewind] Failed to restore: ${err}`);
|
|
97
|
+
notify(`Failed to restore files: ${err}`, "error");
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function createCheckpointFromWorktree(exec: ExecFn, checkpointId: string): Promise<boolean> {
|
|
103
|
+
try {
|
|
104
|
+
const commitSha = await captureWorktree(exec);
|
|
105
|
+
await exec("git", [
|
|
19
106
|
"update-ref",
|
|
20
107
|
`${REF_PREFIX}${checkpointId}`,
|
|
21
|
-
|
|
108
|
+
commitSha,
|
|
22
109
|
]);
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
pi.on("session", async (event, ctx) => {
|
|
117
|
+
if (event.reason !== "start") return;
|
|
118
|
+
if (!ctx.hasUI) return;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const result = await ctx.exec("git", ["rev-parse", "--is-inside-work-tree"]);
|
|
122
|
+
isGitRepo = result.stdout.trim() === "true";
|
|
123
|
+
} catch {
|
|
124
|
+
isGitRepo = false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!isGitRepo) return;
|
|
23
128
|
|
|
24
|
-
|
|
25
|
-
|
|
129
|
+
const checkpointId = `checkpoint-resume-${Date.now()}`;
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const success = await createCheckpointFromWorktree(ctx.exec, checkpointId);
|
|
133
|
+
if (success) {
|
|
134
|
+
resumeCheckpoint = checkpointId;
|
|
135
|
+
console.error(`[rewind] Created resume checkpoint: ${checkpointId}`);
|
|
136
|
+
}
|
|
26
137
|
} catch (err) {
|
|
27
138
|
console.error(`[rewind] Failed to create resume checkpoint: ${err}`);
|
|
28
139
|
}
|
|
@@ -30,29 +141,28 @@ export default function (pi: HookAPI) {
|
|
|
30
141
|
|
|
31
142
|
pi.on("turn_start", async (event, ctx) => {
|
|
32
143
|
if (!ctx.hasUI) return;
|
|
144
|
+
if (!isGitRepo) return;
|
|
33
145
|
|
|
34
146
|
const checkpointId = `checkpoint-${event.timestamp}`;
|
|
35
147
|
|
|
36
148
|
try {
|
|
37
|
-
await ctx.exec
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
`[rewind] Created checkpoint ${checkpointId} for turn ${event.turnIndex}`
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
await pruneCheckpoints(ctx);
|
|
149
|
+
const success = await createCheckpointFromWorktree(ctx.exec, checkpointId);
|
|
150
|
+
if (success) {
|
|
151
|
+
checkpoints.set(event.turnIndex, checkpointId);
|
|
152
|
+
console.error(
|
|
153
|
+
`[rewind] Created checkpoint ${checkpointId} for turn ${event.turnIndex}`
|
|
154
|
+
);
|
|
155
|
+
await pruneCheckpoints(ctx.exec);
|
|
156
|
+
}
|
|
49
157
|
} catch (err) {
|
|
50
158
|
console.error(`[rewind] Failed to create checkpoint: ${err}`);
|
|
51
159
|
}
|
|
52
160
|
});
|
|
53
161
|
|
|
54
|
-
pi.on("
|
|
162
|
+
pi.on("session", async (event, ctx) => {
|
|
163
|
+
if (event.reason !== "before_branch") return;
|
|
55
164
|
if (!ctx.hasUI) return;
|
|
165
|
+
if (!isGitRepo) return;
|
|
56
166
|
|
|
57
167
|
let checkpointId = checkpoints.get(event.targetTurnIndex);
|
|
58
168
|
let usingResumeCheckpoint = false;
|
|
@@ -62,63 +172,84 @@ export default function (pi: HookAPI) {
|
|
|
62
172
|
usingResumeCheckpoint = true;
|
|
63
173
|
}
|
|
64
174
|
|
|
65
|
-
|
|
175
|
+
const beforeRestoreRef = await findBeforeRestoreRef(ctx.exec);
|
|
176
|
+
const hasUndo = !!beforeRestoreRef;
|
|
177
|
+
|
|
178
|
+
if (!checkpointId && !hasUndo) {
|
|
66
179
|
ctx.ui.notify(
|
|
67
180
|
"No checkpoint available for this message",
|
|
68
181
|
"info"
|
|
69
182
|
);
|
|
70
|
-
return
|
|
183
|
+
return;
|
|
71
184
|
}
|
|
72
185
|
|
|
73
|
-
const options =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
186
|
+
const options: string[] = [];
|
|
187
|
+
|
|
188
|
+
if (checkpointId) {
|
|
189
|
+
if (usingResumeCheckpoint) {
|
|
190
|
+
options.push("Restore to session start (files + conversation)");
|
|
191
|
+
options.push("Conversation only (keep current files)");
|
|
192
|
+
options.push("Restore to session start (files only, keep conversation)");
|
|
193
|
+
} else {
|
|
194
|
+
options.push("Restore all (files + conversation)");
|
|
195
|
+
options.push("Conversation only (keep current files)");
|
|
196
|
+
options.push("Code only (restore files, keep conversation)");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (hasUndo) {
|
|
201
|
+
options.push("Undo last file rewind");
|
|
202
|
+
}
|
|
84
203
|
|
|
85
204
|
const choice = await ctx.ui.select("Restore Options", options);
|
|
86
205
|
|
|
87
206
|
if (!choice) {
|
|
88
207
|
ctx.ui.notify("Rewind cancelled", "info");
|
|
89
|
-
return {
|
|
208
|
+
return { cancel: true };
|
|
90
209
|
}
|
|
91
210
|
|
|
92
211
|
if (choice.startsWith("Conversation only")) {
|
|
93
|
-
return
|
|
212
|
+
return;
|
|
94
213
|
}
|
|
95
214
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
215
|
+
const isCodeOnly = choice === "Code only (restore files, keep conversation)" ||
|
|
216
|
+
choice === "Restore to session start (files only, keep conversation)";
|
|
217
|
+
|
|
218
|
+
if (choice === "Undo last file rewind") {
|
|
219
|
+
const success = await restoreWithBackup(
|
|
220
|
+
ctx.exec,
|
|
221
|
+
beforeRestoreRef!.commitSha,
|
|
222
|
+
ctx.ui.notify.bind(ctx.ui)
|
|
223
|
+
);
|
|
224
|
+
if (success) {
|
|
225
|
+
ctx.ui.notify("Files restored to before last rewind", "success");
|
|
226
|
+
}
|
|
227
|
+
return { skipConversationRestore: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const ref = `${REF_PREFIX}${checkpointId}`;
|
|
231
|
+
const success = await restoreWithBackup(
|
|
232
|
+
ctx.exec,
|
|
233
|
+
ref,
|
|
234
|
+
ctx.ui.notify.bind(ctx.ui)
|
|
235
|
+
);
|
|
236
|
+
if (success) {
|
|
100
237
|
ctx.ui.notify(
|
|
101
238
|
usingResumeCheckpoint
|
|
102
239
|
? "Files restored to session start"
|
|
103
240
|
: "Files restored from checkpoint",
|
|
104
241
|
"success"
|
|
105
242
|
);
|
|
106
|
-
} catch (err) {
|
|
107
|
-
console.error(`[rewind] Failed to restore: ${err}`);
|
|
108
|
-
ctx.ui.notify(`Failed to restore files: ${err}`, "error");
|
|
109
|
-
return { skipConversationRestore: true };
|
|
110
243
|
}
|
|
111
244
|
|
|
112
|
-
if (
|
|
245
|
+
if (isCodeOnly) {
|
|
113
246
|
return { skipConversationRestore: true };
|
|
114
247
|
}
|
|
115
|
-
|
|
116
|
-
return undefined;
|
|
117
248
|
});
|
|
118
249
|
|
|
119
|
-
async function pruneCheckpoints(
|
|
250
|
+
async function pruneCheckpoints(exec: ExecFn) {
|
|
120
251
|
try {
|
|
121
|
-
const result = await
|
|
252
|
+
const result = await exec("git", [
|
|
122
253
|
"for-each-ref",
|
|
123
254
|
"--sort=creatordate",
|
|
124
255
|
"--format=%(refname)",
|
|
@@ -126,11 +257,15 @@ export default function (pi: HookAPI) {
|
|
|
126
257
|
]);
|
|
127
258
|
|
|
128
259
|
const refs = result.stdout.trim().split("\n").filter(Boolean);
|
|
260
|
+
const currentResumeRef = resumeCheckpoint ? `${REF_PREFIX}${resumeCheckpoint}` : null;
|
|
261
|
+
const checkpointRefs = refs.filter(r =>
|
|
262
|
+
!r.includes(BEFORE_RESTORE_PREFIX) && r !== currentResumeRef
|
|
263
|
+
);
|
|
129
264
|
|
|
130
|
-
if (
|
|
131
|
-
const toDelete =
|
|
265
|
+
if (checkpointRefs.length > MAX_CHECKPOINTS) {
|
|
266
|
+
const toDelete = checkpointRefs.slice(0, checkpointRefs.length - MAX_CHECKPOINTS);
|
|
132
267
|
for (const ref of toDelete) {
|
|
133
|
-
await
|
|
268
|
+
await exec("git", ["update-ref", "-d", ref]);
|
|
134
269
|
console.error(`[rewind] Pruned old checkpoint: ${ref}`);
|
|
135
270
|
}
|
|
136
271
|
}
|
package/package.json
CHANGED
package/rewind1.png
ADDED
|
Binary file
|
package/rewind2.png
ADDED
|
Binary file
|