pi-rewind-hook 1.0.0 → 1.1.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 ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [1.1.0] - 2024-12-27
6
+
7
+ ### Added
8
+ - "Undo last file rewind" option - restore files to state before last rewind
9
+ - Checkpoints now capture uncommitted and untracked files (not just HEAD)
10
+ - Git repo detection - hook gracefully skips in non-git directories
11
+
12
+ ### Changed
13
+ - Checkpoints use `git write-tree` with temp index to capture working directory state
14
+ - Pruning excludes before-restore ref and current session's resume checkpoint
15
+
16
+ ### Fixed
17
+ - Code-only restore options now properly skip conversation restore
18
+
19
+ ## [1.0.0] - 2024-12-19
20
+
21
+ ### Added
22
+ - Initial release
23
+ - Automatic checkpoints at session start and each turn
24
+ - `/branch` integration with restore options:
25
+ - Restore all (files + conversation)
26
+ - Conversation only (keep current files)
27
+ - Code only (restore files, keep conversation)
28
+ - Resume checkpoint for pre-session messages
29
+ - Automatic pruning (keeps last 100 checkpoints)
30
+ - 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
+ ![Selecting a message to branch from](rewind1.png)
8
+
9
+ ![Choosing a restore option](rewind2.png)
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
- pi.on("session_start", async (event, ctx) => {
13
- if (!ctx.hasUI) return;
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
- const checkpointId = `checkpoint-resume-${Date.now()}`;
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 ctx.exec("git", [
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
- "HEAD",
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
- resumeCheckpoint = checkpointId;
25
- console.error(`[rewind] Created resume checkpoint: ${checkpointId}`);
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("git", [
38
- "update-ref",
39
- `${REF_PREFIX}${checkpointId}`,
40
- "HEAD",
41
- ]);
42
-
43
- checkpoints.set(event.turnIndex, checkpointId);
44
- console.error(
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("branch", async (event, ctx) => {
162
+ pi.on("session", async (event, ctx) => {
163
+ if (event.reason !== "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
- if (!checkpointId) {
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 undefined;
183
+ return;
71
184
  }
72
185
 
73
- const options = usingResumeCheckpoint
74
- ? [
75
- "Restore to session start (files + conversation)",
76
- "Conversation only (keep current files)",
77
- "Restore to session start (files only, keep conversation)",
78
- ]
79
- : [
80
- "Restore all (files + conversation)",
81
- "Conversation only (keep current files)",
82
- "Code only (restore files, keep conversation)",
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 { skipConversationRestore: true };
208
+ return;
90
209
  }
91
210
 
92
211
  if (choice.startsWith("Conversation only")) {
93
- return undefined;
212
+ return;
94
213
  }
95
214
 
96
- try {
97
- const ref = `${REF_PREFIX}${checkpointId}`;
98
- await ctx.exec("git", ["checkout", ref, "--", "."]);
99
- console.error(`[rewind] Restored files from ${checkpointId}`);
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 (choice.includes("files only") || choice.startsWith("Code only")) {
245
+ if (isCodeOnly) {
113
246
  return { skipConversationRestore: true };
114
247
  }
115
-
116
- return undefined;
117
248
  });
118
249
 
119
- async function pruneCheckpoints(ctx: { exec: (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number }> }) {
250
+ async function pruneCheckpoints(exec: ExecFn) {
120
251
  try {
121
- const result = await ctx.exec("git", [
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 (refs.length > MAX_CHECKPOINTS) {
131
- const toDelete = refs.slice(0, refs.length - MAX_CHECKPOINTS);
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 ctx.exec("git", ["update-ref", "-d", ref]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-rewind-hook",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Rewind hook for Pi agent - automatic git checkpoints with file/conversation restore",
5
5
  "bin": {
6
6
  "pi-rewind-hook": "./install.js"
package/rewind1.png ADDED
Binary file
package/rewind2.png ADDED
Binary file