@bridge4dev/runner 0.27.0 → 0.29.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.
@@ -0,0 +1,816 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { createHash } from 'node:crypto';
6
+ import { checkpointsDir } from './paths.js';
7
+ import { isSecretPath } from './policy.js';
8
+ import { log } from './log.js';
9
+ const execFileAsync = promisify(execFile);
10
+ // Ticket #126 — restore points for a session's working tree.
11
+ //
12
+ // A checkpoint is an ordinary git tree, built from the worktree as it stands,
13
+ // and kept in an object store of the RUNNER's, never in the project's `.git`.
14
+ // Three properties come out of that choice, and all three are load-bearing:
15
+ //
16
+ // 1. `git status --porcelain` is byte-identical before and after. Nothing is
17
+ // staged, stashed, committed or checked out to take one — verified on live
18
+ // repositories in the test suite.
19
+ // 2. The blobs are outside `.git/objects`, so `git push --mirror` cannot carry
20
+ // them anywhere and `git log --all -p` inside the worktree is structurally
21
+ // blind to them. The agent's own reads (Bash `git`, `cat`, `grep`) are
22
+ // denied by `SECRET_PATH_PATTERNS`, which already covers the whole
23
+ // `devbridge-runner` tree.
24
+ // 3. Secrets never become objects in the first place. The index is built from
25
+ // an EXPLICIT file list with `update-index`, not from `add -A`, so a `.env`
26
+ // is not blobbed and then filtered out of the tree — it is never read.
27
+ // That distinction matters: an unreferenced blob is still a readable blob.
28
+ //
29
+ // The single source of truth for what counts as a secret is `isSecretPath` in
30
+ // `policy.ts`. There is deliberately no second list here (gotcha #110).
31
+ const GIT_TIMEOUT_MS = 30_000;
32
+ /** `--literal-pathspecs`: a path is a path, never a pattern (QA-105 MAJOR-1). */
33
+ const GIT_GLOBAL_ARGS = ['--literal-pathspecs', '-c', 'core.quotePath=false'];
34
+ /** argv has a ceiling; every path list is fed in batches of this size. */
35
+ const PATH_BATCH = 400;
36
+ /**
37
+ * Ceilings on one checkpoint.
38
+ *
39
+ * An unignored `dist/` would otherwise be blobbed on every single turn. When a
40
+ * ceiling is hit the checkpoint is SKIPPED and the caller says so out loud —
41
+ * a restore point that silently is not there is worse than none at all.
42
+ */
43
+ const MAX_CHECKPOINT_FILES = 20_000;
44
+ const MAX_CHECKPOINT_BYTES = 512 * 1024 * 1024;
45
+ /** A single file bigger than this is left out of the checkpoint (with a notice). */
46
+ const MAX_CHECKPOINT_FILE_BYTES = 64 * 1024 * 1024;
47
+ /** Retention: nothing older than this survives, whatever the ordinal. */
48
+ export const CHECKPOINT_MAX_AGE_MS = 14 * 24 * 3_600_000;
49
+ /** Retention: the newest N per session. */
50
+ export const CHECKPOINT_MAX_PER_SESSION = 200;
51
+ /**
52
+ * How long the points of a session nobody mentions are kept anyway.
53
+ *
54
+ * The list of live sessions arrives capped, so absence from it is weak
55
+ * evidence. A day of silence is strong evidence — and until then the cost of
56
+ * being wrong is disk, while the cost of being wrong the other way is the only
57
+ * copy of somebody's uncommitted work.
58
+ */
59
+ export const ORPHAN_GRACE_MS = 24 * 3_600_000;
60
+ /**
61
+ * Where this repository's checkpoints live.
62
+ *
63
+ * Keyed by the repo's common git dir, so a session worktree and the project
64
+ * folder it belongs to share one store — which is what makes a DIRECT session
65
+ * and a BRANCH session on the same project interchangeable here.
66
+ */
67
+ async function storeFor(worktreePath) {
68
+ const commonDir = await gitIn(worktreePath, 'rev-parse', '--path-format=absolute', '--git-common-dir');
69
+ const real = await realpath(commonDir);
70
+ const key = createHash('sha256').update(real).digest('hex').slice(0, 32);
71
+ return {
72
+ store: path.join(checkpointsDir(), `${key}.git`),
73
+ objectDir: path.join(real, 'objects'),
74
+ };
75
+ }
76
+ async function realpath(p) {
77
+ try {
78
+ return await fs.promises.realpath(p);
79
+ }
80
+ catch {
81
+ return p;
82
+ }
83
+ }
84
+ async function gitIn(cwd, ...args) {
85
+ const { stdout } = await execFileAsync('git', [...GIT_GLOBAL_ARGS, ...args], {
86
+ cwd,
87
+ timeout: GIT_TIMEOUT_MS,
88
+ maxBuffer: 32 * 1024 * 1024,
89
+ // The project's own environment must not leak in: a GIT_INDEX_FILE or
90
+ // GIT_DIR inherited from a parent process would silently retarget every
91
+ // command below at the wrong repository.
92
+ env: cleanEnv(),
93
+ });
94
+ return stdout.replace(/\n$/, '');
95
+ }
96
+ /**
97
+ * Run git against the checkpoint store with the project as its working tree.
98
+ *
99
+ * `GIT_INDEX_FILE` is inside the runner's state directory and never inside the
100
+ * worktree: an index file left in the project would be blobbed by the NEXT
101
+ * checkpoint, which is both a leak and an infinite growth loop.
102
+ */
103
+ async function gitStore(store, worktreePath, indexFile, ...args) {
104
+ const { stdout } = await execFileAsync('git', [...GIT_GLOBAL_ARGS, ...args], {
105
+ cwd: worktreePath,
106
+ timeout: GIT_TIMEOUT_MS,
107
+ maxBuffer: 32 * 1024 * 1024,
108
+ env: {
109
+ ...cleanEnv(),
110
+ GIT_DIR: store,
111
+ GIT_WORK_TREE: worktreePath,
112
+ GIT_INDEX_FILE: indexFile,
113
+ GIT_AUTHOR_NAME: 'DevBridge',
114
+ GIT_AUTHOR_EMAIL: 'runner@devbridge.local',
115
+ GIT_COMMITTER_NAME: 'DevBridge',
116
+ GIT_COMMITTER_EMAIL: 'runner@devbridge.local',
117
+ },
118
+ });
119
+ return stdout.replace(/\n$/, '');
120
+ }
121
+ function cleanEnv() {
122
+ const env = { ...process.env };
123
+ delete env['GIT_DIR'];
124
+ delete env['GIT_WORK_TREE'];
125
+ delete env['GIT_INDEX_FILE'];
126
+ delete env['GIT_OBJECT_DIRECTORY'];
127
+ delete env['GIT_ALTERNATE_OBJECT_DIRECTORIES'];
128
+ return env;
129
+ }
130
+ async function ensureStore(worktreePath) {
131
+ const { store, objectDir } = await storeFor(worktreePath);
132
+ if (!fs.existsSync(store)) {
133
+ fs.mkdirSync(path.dirname(store), { recursive: true, mode: 0o700 });
134
+ await execFileAsync('git', ['init', '--quiet', '--bare', store], {
135
+ timeout: GIT_TIMEOUT_MS,
136
+ env: cleanEnv(),
137
+ });
138
+ fs.chmodSync(store, 0o700);
139
+ }
140
+ // Alternates let the store READ the project's objects (so `read-tree HEAD`
141
+ // resolves) while every object it WRITES stays on its own side. The direction
142
+ // is one-way: the project's git cannot see anything in here — asserted in the
143
+ // test suite, because that one-way-ness is the whole security argument.
144
+ const alternates = path.join(store, 'objects', 'info', 'alternates');
145
+ const wanted = `${objectDir}\n`;
146
+ if (!fs.existsSync(alternates) || fs.readFileSync(alternates, 'utf8') !== wanted) {
147
+ fs.mkdirSync(path.dirname(alternates), { recursive: true, mode: 0o700 });
148
+ fs.writeFileSync(alternates, wanted, { mode: 0o600 });
149
+ }
150
+ return store;
151
+ }
152
+ function indexFileFor(sessionId) {
153
+ return path.join(checkpointsDir(), 'index', `${sessionId}.idx`);
154
+ }
155
+ /**
156
+ * A private index file for ONE operation.
157
+ *
158
+ * Not one per session: a checkpoint taken while a preview is being built would
159
+ * otherwise share an index with it, and the loser of that race writes a tree
160
+ * describing a state that never existed. `git` gives no locking here — the
161
+ * file IS the state — so the isolation has to come from the name.
162
+ */
163
+ let indexCounter = 0;
164
+ function tempIndexFile(sessionId, tag) {
165
+ indexCounter += 1;
166
+ return path.join(checkpointsDir(), 'index', `${sessionId}.${tag}.${process.pid}.${indexCounter}.idx`);
167
+ }
168
+ const REF_PREFIX = 'refs/devbridge/checkpoints/';
169
+ function refFor(sessionId, ordinal) {
170
+ return `${REF_PREFIX}${sessionId}/${ordinal}`;
171
+ }
172
+ /**
173
+ * Run git against a store alone — no working tree involved.
174
+ *
175
+ * Ref bookkeeping and garbage collection need no checkout, and demanding one
176
+ * would make housekeeping impossible for exactly the repositories that need it
177
+ * most: the ones whose sessions are already gone.
178
+ */
179
+ async function gitRefs(store, ...args) {
180
+ const { stdout } = await execFileAsync('git', [...GIT_GLOBAL_ARGS, '--git-dir', store, ...args], {
181
+ timeout: GIT_TIMEOUT_MS,
182
+ maxBuffer: 32 * 1024 * 1024,
183
+ env: cleanEnv(),
184
+ });
185
+ return stdout.replace(/\n$/, '');
186
+ }
187
+ /** Checkpoint metadata read straight from a store, with no worktree. */
188
+ async function readRecord(store, sessionId, ordinal) {
189
+ try {
190
+ const commit = await gitRefs(store, 'rev-parse', `${refFor(sessionId, ordinal)}^{commit}`);
191
+ const meta = decodeMeta(await gitRefs(store, 'log', '-1', '--format=%B', commit));
192
+ return meta ? { ordinal, commit, ...meta } : null;
193
+ }
194
+ catch {
195
+ return null;
196
+ }
197
+ }
198
+ /** NUL-separated git output → array, with the trailing empty field dropped. */
199
+ function splitZ(raw) {
200
+ return raw.split('\0').filter((entry) => entry.length > 0);
201
+ }
202
+ /**
203
+ * Everything in the worktree that differs from HEAD right now, minus secrets.
204
+ *
205
+ * `-uall` matters: without it git collapses an untracked directory into a
206
+ * single `dir/` entry, and a checkpoint built from that list would silently
207
+ * miss every file inside it.
208
+ */
209
+ async function changedPaths(worktreePath) {
210
+ const raw = await gitIn(worktreePath, 'status', '--porcelain=v1', '-uall', '-z');
211
+ const paths = [];
212
+ const secrets = [];
213
+ // Porcelain v1 with -z: `XY <path>` records, and a rename adds a second
214
+ // NUL-separated field. Both sides of a rename are ordinary paths for us.
215
+ const fields = splitZ(raw);
216
+ for (let i = 0; i < fields.length; i += 1) {
217
+ const entry = fields[i];
218
+ if (!entry || entry.length < 4)
219
+ continue;
220
+ const status = entry.slice(0, 2);
221
+ const file = entry.slice(3);
222
+ if (status[0] === 'R' || status[0] === 'C') {
223
+ // `R new\0old` — the following field is the old path.
224
+ const old = fields[i + 1];
225
+ i += 1;
226
+ if (old)
227
+ push(old);
228
+ }
229
+ push(file);
230
+ }
231
+ function push(file) {
232
+ if (!file)
233
+ return;
234
+ const absolute = path.join(worktreePath, file);
235
+ if (isSecretPath(absolute)) {
236
+ secrets.push(file);
237
+ return;
238
+ }
239
+ paths.push(file);
240
+ }
241
+ return { paths: [...new Set(paths)], secrets: [...new Set(secrets)] };
242
+ }
243
+ async function runBatched(store, worktreePath, indexFile, args, paths) {
244
+ for (let i = 0; i < paths.length; i += PATH_BATCH) {
245
+ await gitStore(store, worktreePath, indexFile, ...args, '--', ...paths.slice(i, i + PATH_BATCH));
246
+ }
247
+ }
248
+ /**
249
+ * Build an index describing the worktree, under ONE set of rules.
250
+ *
251
+ * Both the checkpoint and the "where are we now" tree go through here, and
252
+ * that is the whole point. When the two were built by separate code with
253
+ * slightly different filters, a file excluded from one and present in the
254
+ * other appeared in the diff as "created after the checkpoint" — and a rewind
255
+ * offered to delete a file that had been there all along.
256
+ *
257
+ * The second rule is subtler and cost a live data-loss bug: a path we do not
258
+ * cover must be **removed from the index**, not merely left at its HEAD
259
+ * version. `read-tree --reset -u` writes every index entry to disk, so a
260
+ * tracked `.env` sitting at its HEAD version silently overwrote the user's
261
+ * local edits — while the preview, built from a diff in which both sides were
262
+ * identical, said nothing would happen to it. An entry that does not exist
263
+ * cannot be written.
264
+ */
265
+ async function buildIndex(store, worktreePath, indexFile) {
266
+ fs.mkdirSync(path.dirname(indexFile), { recursive: true, mode: 0o700 });
267
+ fs.rmSync(indexFile, { force: true });
268
+ const headSha = await gitIn(worktreePath, 'rev-parse', 'HEAD');
269
+ await gitStore(store, worktreePath, indexFile, 'read-tree', headSha);
270
+ const { paths, secrets } = await changedPaths(worktreePath);
271
+ const excluded = new Set(secrets);
272
+ const included = [];
273
+ let byteCount = 0;
274
+ for (const file of paths) {
275
+ let size = 0;
276
+ try {
277
+ const stat = fs.lstatSync(path.join(worktreePath, file));
278
+ size = stat.isFile() ? stat.size : 0;
279
+ }
280
+ catch {
281
+ // Deleted since `status` ran — `update-index --remove` handles it.
282
+ }
283
+ if (size > MAX_CHECKPOINT_FILE_BYTES) {
284
+ excluded.add(file);
285
+ continue;
286
+ }
287
+ byteCount += size;
288
+ included.push(file);
289
+ }
290
+ // Everything HEAD tracks that we may not touch — not just the paths that
291
+ // happen to have changed. An unmodified tracked secret would otherwise stay
292
+ // in the index and be rewritten from HEAD on every restore.
293
+ for (const tracked of splitZ(await gitStore(store, worktreePath, indexFile, 'ls-files', '-z'))) {
294
+ if (isSecretPath(path.join(worktreePath, tracked)))
295
+ excluded.add(tracked);
296
+ }
297
+ await runBatched(store, worktreePath, indexFile, ['update-index', '--add', '--remove'], included);
298
+ const drop = [...excluded];
299
+ if (drop.length > 0) {
300
+ await runBatched(store, worktreePath, indexFile, ['update-index', '--force-remove'], drop);
301
+ }
302
+ // `read-tree <commit>` writes index entries with ZEROED stat data, and
303
+ // `read-tree --reset -u` rewrites every entry whose stat does not match the
304
+ // file on disk — which, with zeroed stat, is all of them. Without this
305
+ // refresh a rewind of one file re-wrote the whole repository: mtime churn on
306
+ // every tracked path, i.e. a full rebuild for every watcher on the machine.
307
+ //
308
+ // Exit status is deliberately ignored: `--refresh` reports "needs update"
309
+ // for genuinely modified paths by failing, and those are exactly the paths a
310
+ // rewind is supposed to touch.
311
+ try {
312
+ await gitStore(store, worktreePath, indexFile, 'update-index', '-q', '--refresh');
313
+ }
314
+ catch {
315
+ // Some entries differ from the worktree — expected, and the point.
316
+ }
317
+ return {
318
+ headSha,
319
+ included,
320
+ excluded: drop,
321
+ byteCount,
322
+ tooLarge: included.length > MAX_CHECKPOINT_FILES || byteCount > MAX_CHECKPOINT_BYTES,
323
+ };
324
+ }
325
+ async function nextOrdinal(store, worktreePath, sessionId) {
326
+ const raw = await gitStore(store, worktreePath, indexFileFor(sessionId), 'for-each-ref', '--format=%(refname)', `refs/devbridge/checkpoints/${sessionId}/`);
327
+ let max = 0;
328
+ for (const line of raw.split('\n')) {
329
+ const n = Number.parseInt(line.slice(line.lastIndexOf('/') + 1), 10);
330
+ if (Number.isFinite(n) && n > max)
331
+ max = n;
332
+ }
333
+ return max + 1;
334
+ }
335
+ /**
336
+ * Metadata rides in the commit MESSAGE, not in a side file.
337
+ *
338
+ * One object, one fsync, one thing to garbage-collect — and a checkpoint whose
339
+ * ref survives can never be missing its own metadata.
340
+ */
341
+ function encodeMeta(meta) {
342
+ return `devbridge-checkpoint\n\n${JSON.stringify(meta)}\n`;
343
+ }
344
+ function decodeMeta(message) {
345
+ const start = message.indexOf('{');
346
+ if (start < 0)
347
+ return null;
348
+ try {
349
+ const parsed = JSON.parse(message.slice(start));
350
+ const kind = parsed['kind'];
351
+ return {
352
+ kind: kind === 'SAFETY' || kind === 'MANUAL' ? kind : 'TURN',
353
+ headSha: typeof parsed['headSha'] === 'string' ? parsed['headSha'] : '',
354
+ stagedPaths: Array.isArray(parsed['stagedPaths'])
355
+ ? parsed['stagedPaths'].filter((p) => typeof p === 'string')
356
+ : [],
357
+ createdAt: typeof parsed['createdAt'] === 'number' ? parsed['createdAt'] : 0,
358
+ fileCount: typeof parsed['fileCount'] === 'number' ? parsed['fileCount'] : 0,
359
+ byteCount: typeof parsed['byteCount'] === 'number' ? parsed['byteCount'] : 0,
360
+ ...(typeof parsed['agentAnchor'] === 'string' ? { agentAnchor: parsed['agentAnchor'] } : {}),
361
+ ...(typeof parsed['agentSession'] === 'string'
362
+ ? { agentSession: parsed['agentSession'] }
363
+ : {}),
364
+ ...(typeof parsed['messageSeq'] === 'number' ? { messageSeq: parsed['messageSeq'] } : {}),
365
+ };
366
+ }
367
+ catch {
368
+ return null;
369
+ }
370
+ }
371
+ /**
372
+ * Take a restore point for this worktree.
373
+ *
374
+ * Never throws for an ordinary failure: a checkpoint that could not be taken
375
+ * must not stop the message it was taken for from reaching the agent.
376
+ */
377
+ export async function createCheckpoint(input) {
378
+ const { worktreePath, sessionId, kind } = input;
379
+ try {
380
+ const store = await ensureStore(worktreePath);
381
+ const indexFile = tempIndexFile(sessionId, 'create');
382
+ const built = await buildIndex(store, worktreePath, indexFile);
383
+ if (built.tooLarge) {
384
+ fs.rmSync(indexFile, { force: true });
385
+ return { created: false, reason: 'too-large' };
386
+ }
387
+ const { headSha, included, excluded: skippedFiles, byteCount } = built;
388
+ const tree = await gitStore(store, worktreePath, indexFile, 'write-tree');
389
+ const stagedPaths = splitZ(await gitIn(worktreePath, 'diff', '--cached', '--name-only', '-z')).filter((file) => !isSecretPath(path.join(worktreePath, file)));
390
+ const meta = {
391
+ kind,
392
+ headSha,
393
+ stagedPaths,
394
+ createdAt: Date.now(),
395
+ fileCount: included.length,
396
+ byteCount,
397
+ ...(input.agentAnchor ? { agentAnchor: input.agentAnchor } : {}),
398
+ ...(input.agentSession ? { agentSession: input.agentSession } : {}),
399
+ ...(input.messageSeq === undefined ? {} : { messageSeq: input.messageSeq }),
400
+ };
401
+ const commit = await gitStore(store, worktreePath, indexFile, 'commit-tree', tree, '-m', encodeMeta(meta));
402
+ const ordinal = await nextOrdinal(store, worktreePath, sessionId);
403
+ await gitStore(store, worktreePath, indexFile, 'update-ref', refFor(sessionId, ordinal), commit);
404
+ fs.rmSync(indexFile, { force: true });
405
+ return {
406
+ created: true,
407
+ record: { ordinal, commit, ...meta },
408
+ ...(input.messageSeq === undefined ? {} : { messageSeq: input.messageSeq }),
409
+ skippedFiles,
410
+ };
411
+ }
412
+ catch (error) {
413
+ const detail = String(error instanceof Error ? error.message : error).slice(0, 300);
414
+ if (/not a git repository|ambiguous argument 'HEAD'|unknown revision/i.test(detail)) {
415
+ return { created: false, reason: 'not-a-repo', detail };
416
+ }
417
+ log.warn('checkpoints: could not create a restore point', { sessionId, error: detail });
418
+ return { created: false, reason: 'failed', detail };
419
+ }
420
+ }
421
+ async function readCheckpoint(store, worktreePath, sessionId, ordinal) {
422
+ try {
423
+ const commit = await gitStore(store, worktreePath, indexFileFor(sessionId), 'rev-parse', `${refFor(sessionId, ordinal)}^{commit}`);
424
+ const message = await gitStore(store, worktreePath, indexFileFor(sessionId), 'log', '-1', '--format=%B', commit);
425
+ const meta = decodeMeta(message);
426
+ if (!meta)
427
+ return null;
428
+ return { ordinal, commit, ...meta };
429
+ }
430
+ catch {
431
+ return null;
432
+ }
433
+ }
434
+ export async function listCheckpoints(worktreePath, sessionId) {
435
+ try {
436
+ const store = await ensureStore(worktreePath);
437
+ const raw = await gitStore(store, worktreePath, indexFileFor(sessionId), 'for-each-ref', '--format=%(refname)', `refs/devbridge/checkpoints/${sessionId}/`);
438
+ const out = [];
439
+ for (const line of raw.split('\n')) {
440
+ if (!line.trim())
441
+ continue;
442
+ const ordinal = Number.parseInt(line.slice(line.lastIndexOf('/') + 1), 10);
443
+ if (!Number.isFinite(ordinal))
444
+ continue;
445
+ const record = await readCheckpoint(store, worktreePath, sessionId, ordinal);
446
+ if (record)
447
+ out.push(record);
448
+ }
449
+ return out.sort((a, b) => a.ordinal - b.ordinal);
450
+ }
451
+ catch {
452
+ return [];
453
+ }
454
+ }
455
+ /**
456
+ * Where the worktree stands right now, under the SAME rules a checkpoint uses.
457
+ *
458
+ * Identical filters are not a tidiness point: a path covered by one side and
459
+ * not the other shows up in the diff as a creation or a deletion that never
460
+ * happened, and the rewind acts on it.
461
+ */
462
+ async function currentTree(store, worktreePath, indexFile) {
463
+ const built = await buildIndex(store, worktreePath, indexFile);
464
+ return {
465
+ tree: await gitStore(store, worktreePath, indexFile, 'write-tree'),
466
+ headSha: built.headSha,
467
+ };
468
+ }
469
+ const MAX_PREVIEW_ENTRIES = 5_000;
470
+ /** What a rewind to this checkpoint would do, without doing any of it. */
471
+ export async function previewRewind(input) {
472
+ const { worktreePath, sessionId, ordinal } = input;
473
+ const store = await ensureStore(worktreePath);
474
+ const indexFile = tempIndexFile(sessionId, 'preview');
475
+ const record = await readCheckpoint(store, worktreePath, sessionId, ordinal);
476
+ const { tree, headSha } = await currentTree(store, worktreePath, indexFile);
477
+ if (!record) {
478
+ return {
479
+ restore: [],
480
+ delete: [],
481
+ recreate: [],
482
+ headSha,
483
+ checkpointHeadSha: '',
484
+ treeOid: tree,
485
+ blockedReason: 'checkpoint-lost',
486
+ };
487
+ }
488
+ const raw = await gitStore(store, worktreePath, indexFile, 'diff-tree', '-r', '--no-renames', '--name-status', '-z', `${record.commit}^{tree}`, tree);
489
+ fs.rmSync(indexFile, { force: true });
490
+ const restore = [];
491
+ const remove = [];
492
+ const recreate = [];
493
+ const fields = splitZ(raw);
494
+ for (let i = 0; i + 1 < fields.length; i += 2) {
495
+ const status = fields[i] ?? '';
496
+ const file = fields[i + 1] ?? '';
497
+ // Direction is checkpoint → now: `A` means the file appeared after the
498
+ // checkpoint (a rewind deletes it), `D` means it was there and is gone
499
+ // (a rewind brings it back), everything else is a content change.
500
+ if (status.startsWith('A'))
501
+ remove.push(file);
502
+ else if (status.startsWith('D'))
503
+ recreate.push(file);
504
+ else
505
+ restore.push(file);
506
+ }
507
+ // A list too long to SHOW is a rewind we must not run. Truncating it and
508
+ // going ahead anyway would delete files nobody was shown — the one thing the
509
+ // confirmation exists to prevent — so the size of the change is itself a
510
+ // reason to refuse.
511
+ const total = restore.length + remove.length + recreate.length;
512
+ let blockedReason = total > MAX_PREVIEW_ENTRIES ? 'too-many-changes' : undefined;
513
+ const commitsSince = [];
514
+ if (!blockedReason && record.headSha && record.headSha !== headSha) {
515
+ blockedReason = 'head-moved';
516
+ try {
517
+ const listed = await gitIn(worktreePath, 'log', '--format=%h%x00%s', '--max-count=20', `${record.headSha}..${headSha}`);
518
+ for (const line of listed.split('\n')) {
519
+ if (!line.trim())
520
+ continue;
521
+ const [sha, subject] = line.split('\0');
522
+ commitsSince.push({ sha: sha ?? '', subject: (subject ?? '').slice(0, 200) });
523
+ }
524
+ }
525
+ catch {
526
+ // The old HEAD is unreachable (history was rewritten) — the refusal
527
+ // stands on its own; the list is a courtesy.
528
+ }
529
+ }
530
+ if (!blockedReason && (await mergeInProgress(worktreePath))) {
531
+ blockedReason = 'merge-in-progress';
532
+ }
533
+ return {
534
+ restore: restore.slice(0, MAX_PREVIEW_ENTRIES),
535
+ delete: remove.slice(0, MAX_PREVIEW_ENTRIES),
536
+ recreate: recreate.slice(0, MAX_PREVIEW_ENTRIES),
537
+ headSha,
538
+ checkpointHeadSha: record.headSha,
539
+ treeOid: tree,
540
+ ...(blockedReason ? { blockedReason } : {}),
541
+ ...(commitsSince.length ? { commitsSince } : {}),
542
+ ...(total > MAX_PREVIEW_ENTRIES ? { truncated: true, totalChanges: total } : {}),
543
+ };
544
+ }
545
+ async function mergeInProgress(worktreePath) {
546
+ try {
547
+ const gitDir = await gitIn(worktreePath, 'rev-parse', '--path-format=absolute', '--git-dir');
548
+ return (fs.existsSync(path.join(gitDir, 'MERGE_HEAD')) ||
549
+ fs.existsSync(path.join(gitDir, 'REBASE_HEAD')) ||
550
+ fs.existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD')));
551
+ }
552
+ catch {
553
+ return false;
554
+ }
555
+ }
556
+ /**
557
+ * Put the working tree back to a checkpoint.
558
+ *
559
+ * The SAFETY point is taken FIRST and its ref is on disk before a single byte
560
+ * of the worktree changes — kill the process between the two steps and the way
561
+ * back still exists. `read-tree --reset -u` restores content but does NOT
562
+ * delete files created after the checkpoint (verified on live git), so the
563
+ * deletions are done explicitly from a list the caller echoed back. There is no
564
+ * `git clean` anywhere in this file, on purpose: it deletes by rule rather than
565
+ * by list, and a rule is exactly what nobody confirmed.
566
+ */
567
+ export async function applyRewind(input) {
568
+ const { worktreePath, sessionId, ordinal, confirmDeletes, expectedTreeOid } = input;
569
+ const store = await ensureStore(worktreePath);
570
+ const record = await readCheckpoint(store, worktreePath, sessionId, ordinal);
571
+ if (!record)
572
+ throw new Error('This restore point is no longer available');
573
+ const preview = await previewRewind({ worktreePath, sessionId, ordinal });
574
+ if (preview.blockedReason) {
575
+ throw new Error(rewindBlockMessage(preview.blockedReason));
576
+ }
577
+ const MOVED = 'The working tree changed while you were looking at it — open the preview again';
578
+ // The whole state, not just the deletions (QA-120 B1). `read-tree --reset -u`
579
+ // writes the RESTORE list as well, and that list is recomputed HERE — so a
580
+ // file saved from an editor while the dialog sat open used to be overwritten
581
+ // from the checkpoint without appearing on any list the person read.
582
+ if (expectedTreeOid && expectedTreeOid !== preview.treeOid)
583
+ throw new Error(MOVED);
584
+ const expected = [...preview.delete].sort();
585
+ const echoed = [...confirmDeletes].sort();
586
+ if (expected.length !== echoed.length || expected.some((p, i) => p !== echoed[i])) {
587
+ throw new Error(MOVED);
588
+ }
589
+ const safetyResult = await createCheckpoint({ worktreePath, sessionId, kind: 'SAFETY' });
590
+ if (!safetyResult.created) {
591
+ throw new Error(safetyResult.reason === 'too-large'
592
+ ? 'The working tree is too large to take a safety point — the rewind was not started'
593
+ : 'Could not take a safety point before the rewind — nothing was changed');
594
+ }
595
+ const indexFile = tempIndexFile(sessionId, 'rewind');
596
+ // Seed the index with the CURRENT state so `read-tree --reset -u` only
597
+ // touches files that actually differ. Against an empty index git rewrites
598
+ // every file in the repository, and an mtime bump on a whole tree is a full
599
+ // rebuild for every watcher on the machine.
600
+ await currentTree(store, worktreePath, indexFile);
601
+ await gitStore(store, worktreePath, indexFile, 'read-tree', '--reset', '-u', `${record.commit}^{tree}`);
602
+ fs.rmSync(indexFile, { force: true });
603
+ // `read-tree --reset -u` removes the files that are in the seeded index and
604
+ // not in the checkpoint — which is the same set the user just confirmed,
605
+ // because both come from the same tree diff. This pass is the belt to that
606
+ // brace: it names each path explicitly, re-checks it against the worktree
607
+ // root, and reports what is actually gone. Nothing here deletes by rule, and
608
+ // there is no `git clean` anywhere in this file.
609
+ await deletePaths(worktreePath, preview.delete);
610
+ const deleted = preview.delete.filter((rel) => !fs.existsSync(path.join(worktreePath, rel))).length;
611
+ await reconcileIndex(worktreePath, preview, record.stagedPaths);
612
+ return {
613
+ restored: preview.restore.length,
614
+ deleted,
615
+ recreated: preview.recreate.length,
616
+ safety: safetyResult.record,
617
+ rewoundToKind: record.kind,
618
+ };
619
+ }
620
+ export function rewindBlockMessage(reason) {
621
+ switch (reason) {
622
+ case 'head-moved':
623
+ return 'A commit was made after this restore point — rewind the commit from the Changes panel first';
624
+ case 'merge-in-progress':
625
+ return 'This worktree is in the middle of a merge — finish or abort it first';
626
+ case 'checkpoint-lost':
627
+ return 'This restore point is no longer available';
628
+ case 'too-many-changes':
629
+ return `More than ${MAX_PREVIEW_ENTRIES} files differ from this restore point — too many to list, and a rewind must never delete a file nobody was shown`;
630
+ }
631
+ }
632
+ /**
633
+ * Delete exactly the paths the user confirmed.
634
+ *
635
+ * Every one is re-checked against the worktree root here as well as in the API:
636
+ * this function is the last thing standing between a list and `unlink`, and a
637
+ * symlink that leaves the tree must not be followed out of it.
638
+ */
639
+ async function deletePaths(worktreePath, paths) {
640
+ const root = await realpath(worktreePath);
641
+ let deleted = 0;
642
+ for (const rel of paths) {
643
+ if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..'))
644
+ continue;
645
+ const target = path.resolve(worktreePath, rel);
646
+ const relative = path.relative(worktreePath, target);
647
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
648
+ continue;
649
+ if (relative.split(path.sep).includes('.git'))
650
+ continue;
651
+ // The PARENT is resolved, not the target: resolving the target would follow
652
+ // the very symlink we are deciding about.
653
+ let parentReal;
654
+ try {
655
+ parentReal = await fs.promises.realpath(path.dirname(target));
656
+ }
657
+ catch {
658
+ continue;
659
+ }
660
+ const parentRel = path.relative(root, parentReal);
661
+ if (parentRel.startsWith('..') || path.isAbsolute(parentRel))
662
+ continue;
663
+ try {
664
+ const stat = await fs.promises.lstat(target);
665
+ // Directories are never in the list — git names files — and a directory
666
+ // here would mean the list is not what we think it is.
667
+ if (stat.isDirectory())
668
+ continue;
669
+ await fs.promises.unlink(target);
670
+ deleted += 1;
671
+ }
672
+ catch {
673
+ // Already gone: the outcome the caller asked for.
674
+ }
675
+ }
676
+ return deleted;
677
+ }
678
+ /**
679
+ * Put the project's own index back the way it was at the checkpoint, for the
680
+ * paths this rewind touched — and only those.
681
+ *
682
+ * The rewind itself never touches the project index (it runs against an index
683
+ * of the runner's), so without this a file that was staged-as-added and is now
684
+ * deleted from disk would show up as `AD` forever. Paths outside the rewind are
685
+ * left strictly alone: the user's other staged work is none of our business.
686
+ */
687
+ async function reconcileIndex(worktreePath, preview, stagedAtCheckpoint) {
688
+ const touched = [...new Set([...preview.restore, ...preview.delete, ...preview.recreate])];
689
+ if (touched.length === 0)
690
+ return;
691
+ const staged = new Set(stagedAtCheckpoint);
692
+ try {
693
+ for (let i = 0; i < touched.length; i += PATH_BATCH) {
694
+ await gitIn(worktreePath, 'reset', '--quiet', '--', ...touched.slice(i, i + PATH_BATCH));
695
+ }
696
+ const restage = touched.filter((file) => staged.has(file) && !preview.delete.includes(file));
697
+ for (let i = 0; i < restage.length; i += PATH_BATCH) {
698
+ await gitIn(worktreePath, 'add', '--', ...restage.slice(i, i + PATH_BATCH));
699
+ }
700
+ }
701
+ catch (error) {
702
+ // The files are already back; a staging area we could not reproduce is a
703
+ // cosmetic loss, not a reason to report the rewind as failed.
704
+ log.warn('checkpoints: could not restore the staging area', { error: String(error) });
705
+ }
706
+ }
707
+ /** Drop every restore point of one session (session deleted or purged). */
708
+ export async function dropCheckpoints(worktreePath, sessionId) {
709
+ try {
710
+ const store = await ensureStore(worktreePath);
711
+ await deleteRefs(store, worktreePath, sessionId);
712
+ fs.rmSync(indexFileFor(sessionId), { force: true });
713
+ }
714
+ catch (error) {
715
+ log.warn('checkpoints: could not drop restore points', { sessionId, error: String(error) });
716
+ }
717
+ }
718
+ async function deleteRefs(store, worktreePath, sessionId) {
719
+ const raw = await gitStore(store, worktreePath, indexFileFor(sessionId), 'for-each-ref', '--format=%(refname)', `refs/devbridge/checkpoints/${sessionId}/`);
720
+ for (const ref of raw.split('\n')) {
721
+ if (!ref.trim())
722
+ continue;
723
+ await gitStore(store, worktreePath, indexFileFor(sessionId), 'update-ref', '-d', ref);
724
+ }
725
+ }
726
+ /**
727
+ * Garbage collection.
728
+ *
729
+ * Two jobs, and the second one is the reason this exists: deleting a session
730
+ * while its dev server is switched off leaves refs — a full copy of a working
731
+ * tree — with no row anywhere to point at them. The reconciliation on `hello`
732
+ * is what eventually removes those, so this takes a list of the sessions that
733
+ * still exist rather than a list of the ones that do not.
734
+ */
735
+ export async function pruneCheckpoints(input) {
736
+ const { liveSessionIds } = input;
737
+ const now = input.now ?? Date.now();
738
+ const droppedSessions = [];
739
+ let droppedRefs = 0;
740
+ const root = checkpointsDir();
741
+ if (!fs.existsSync(root))
742
+ return { droppedSessions, droppedRefs };
743
+ // Every store, not just the one belonging to some worktree we happen to hold
744
+ // a path for. A session deleted while its dev server was switched off leaves
745
+ // refs behind with no row anywhere pointing at them, and the repository it
746
+ // came from may have no live session left to find it through.
747
+ for (const entry of fs.readdirSync(root)) {
748
+ if (!entry.endsWith('.git'))
749
+ continue;
750
+ const store = path.join(root, entry);
751
+ try {
752
+ const raw = await gitRefs(store, 'for-each-ref', '--format=%(refname)', REF_PREFIX);
753
+ const bySession = new Map();
754
+ for (const ref of raw.split('\n')) {
755
+ if (!ref.trim())
756
+ continue;
757
+ const rest = ref.slice(REF_PREFIX.length);
758
+ const slash = rest.lastIndexOf('/');
759
+ if (slash <= 0)
760
+ continue;
761
+ const sessionId = rest.slice(0, slash);
762
+ const ordinal = Number.parseInt(rest.slice(slash + 1), 10);
763
+ if (!Number.isFinite(ordinal))
764
+ continue;
765
+ const list = bySession.get(sessionId) ?? [];
766
+ list.push(ordinal);
767
+ bySession.set(sessionId, list);
768
+ }
769
+ let droppedHere = 0;
770
+ for (const [sessionId, ordinals] of bySession) {
771
+ // "Not in the list" is NOT the same as "does not exist". The list the
772
+ // API sends on `hello` is capped — REVIEW sessions are trimmed to the
773
+ // ten most recent — so treating every absent id as an orphan would
774
+ // delete the restore points of a session that is merely quiet. Age is
775
+ // what makes the difference safe: a session deleted while its server
776
+ // was off stops being mentioned forever, so its points age out; a live
777
+ // one that fell off a truncated list is mentioned again within minutes.
778
+ const unlisted = !liveSessionIds.has(sessionId);
779
+ ordinals.sort((a, b) => b - a);
780
+ const keep = new Set(ordinals.slice(0, CHECKPOINT_MAX_PER_SESSION));
781
+ let survivors = 0;
782
+ for (const ordinal of ordinals) {
783
+ let drop = !keep.has(ordinal);
784
+ if (!drop) {
785
+ const record = await readRecord(store, sessionId, ordinal);
786
+ const age = record ? now - record.createdAt : Number.POSITIVE_INFINITY;
787
+ drop = !record || age > CHECKPOINT_MAX_AGE_MS || (unlisted && age > ORPHAN_GRACE_MS);
788
+ }
789
+ if (!drop) {
790
+ survivors += 1;
791
+ continue;
792
+ }
793
+ await gitRefs(store, 'update-ref', '-d', refFor(sessionId, ordinal));
794
+ droppedHere += 1;
795
+ }
796
+ if (survivors === 0) {
797
+ droppedSessions.push(sessionId);
798
+ fs.rmSync(indexFileFor(sessionId), { force: true });
799
+ }
800
+ }
801
+ droppedRefs += droppedHere;
802
+ if (droppedHere > 0) {
803
+ // Dropping a ref only unlinks it; the trees and blobs it named stay on
804
+ // disk until they are collected. Skipping this would make "retention"
805
+ // mean nothing at all for the thing that actually takes the space.
806
+ await gitRefs(store, 'reflog', 'expire', '--expire=now', '--all');
807
+ await gitRefs(store, 'gc', '--prune=now', '--quiet');
808
+ }
809
+ }
810
+ catch (error) {
811
+ log.warn('checkpoints: prune failed for a store', { store: entry, error: String(error) });
812
+ }
813
+ }
814
+ return { droppedSessions, droppedRefs };
815
+ }
816
+ //# sourceMappingURL=checkpoints.js.map