cursor-guard 4.9.12 → 4.9.15

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.
Files changed (30) hide show
  1. package/README.md +697 -697
  2. package/README.zh-CN.md +696 -696
  3. package/ROADMAP.md +1775 -1758
  4. package/SKILL.md +631 -629
  5. package/docs/RELEASE.md +197 -196
  6. package/docs/SNAPSHOT-BOOKMARK.md +47 -0
  7. package/package.json +2 -1
  8. package/references/dashboard/public/app.js +2079 -2050
  9. package/references/dashboard/public/style.css +1660 -1628
  10. package/references/lib/core/backups.js +509 -507
  11. package/references/lib/core/core.test.js +39 -1
  12. package/references/lib/core/snapshot.js +441 -416
  13. package/references/mcp/mcp.test.js +381 -362
  14. package/references/mcp/server.js +404 -347
  15. package/references/vscode-extension/{cursor-guard-ide-4.9.12.vsix → dist/cursor-guard-ide-4.9.15.vsix} +0 -0
  16. package/references/vscode-extension/dist/dashboard/public/app.js +2079 -2050
  17. package/references/vscode-extension/dist/dashboard/public/style.css +1660 -1628
  18. package/references/vscode-extension/dist/extension.js +780 -704
  19. package/references/vscode-extension/dist/guard-version.json +1 -1
  20. package/references/vscode-extension/dist/lib/auto-setup.js +201 -192
  21. package/references/vscode-extension/dist/lib/core/backups.js +509 -507
  22. package/references/vscode-extension/dist/lib/core/snapshot.js +441 -416
  23. package/references/vscode-extension/dist/mcp/server.js +78 -12
  24. package/references/vscode-extension/dist/package.json +7 -1
  25. package/references/vscode-extension/dist/skill/ROADMAP.md +1775 -1758
  26. package/references/vscode-extension/dist/skill/SKILL.md +631 -629
  27. package/references/vscode-extension/extension.js +780 -704
  28. package/references/vscode-extension/lib/auto-setup.js +201 -192
  29. package/references/vscode-extension/package.json +7 -1
  30. package/references/vscode-extension/dist/cursor-guard-ide-4.9.12.vsix +0 -0
@@ -1,507 +1,509 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const { execFileSync } = require('child_process');
6
- const {
7
- git, isGitRepo, gitDir: getGitDir, walkDir, diskFreeGB,
8
- } = require('../utils');
9
-
10
- // ── Helpers ──────────────────────────────────────────────────────
11
-
12
- function parseShadowTimestamp(name) {
13
- const m = name.match(/^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})(?:_(\d{3}))?$/);
14
- if (!m) return null;
15
- const ms = m[7] ? `.${m[7]}` : '';
16
- return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${ms}`);
17
- }
18
-
19
- function parseBeforeExpression(before) {
20
- if (!before) return null;
21
- const iso = Date.parse(before);
22
- if (!isNaN(iso)) return new Date(iso);
23
- const agoMatch = before.match(/^(\d+)\s*(second|minute|hour|day|week|month)s?\s*ago$/i);
24
- if (agoMatch) {
25
- const n = parseInt(agoMatch[1], 10);
26
- const unit = agoMatch[2].toLowerCase();
27
- const ms = { second: 1000, minute: 60000, hour: 3600000, day: 86400000, week: 604800000, month: 2592000000 }[unit] || 0;
28
- return new Date(Date.now() - n * ms);
29
- }
30
- return null;
31
- }
32
-
33
- function entryToMs(entry) {
34
- if (!entry.timestamp) return 0;
35
- const iso = Date.parse(entry.timestamp);
36
- if (!isNaN(iso)) return iso;
37
- const tsName = typeof entry.timestamp === 'string' && entry.timestamp.startsWith('pre-restore-')
38
- ? entry.timestamp.slice('pre-restore-'.length)
39
- : entry.timestamp;
40
- const d = parseShadowTimestamp(tsName);
41
- return d ? d.getTime() : 0;
42
- }
43
-
44
- const TRAILER_MAP = {
45
- 'Files-Changed': { key: 'filesChanged', parse: v => parseInt(v, 10) },
46
- 'Summary': { key: 'summary' },
47
- 'Trigger': { key: 'trigger' },
48
- 'Intent': { key: 'intent' },
49
- 'Agent': { key: 'agent' },
50
- 'Session': { key: 'session' },
51
- 'From': { key: 'from' },
52
- 'Restore-To': { key: 'restoreTo' },
53
- 'File': { key: 'restoreFile' },
54
- 'Guard-Diff-Base': { key: 'guardDiffBase' },
55
- 'Guard-Scope': { key: 'guardScope' },
56
- };
57
-
58
- function parseCommitTrailers(body) {
59
- if (!body) return {};
60
- const result = {};
61
- const pattern = new RegExp(`^(${Object.keys(TRAILER_MAP).join('|')}):\\s*(.+)$`);
62
- for (const line of body.split('\n')) {
63
- const m = line.match(pattern);
64
- if (m) {
65
- const def = TRAILER_MAP[m[1]];
66
- const raw = m[2].replace(/\r/g, '');
67
- const val = def.parse ? def.parse(raw) : raw.trim();
68
- result[def.key] = typeof val === 'string' ? val.trim() : val;
69
- }
70
- }
71
- return result;
72
- }
73
-
74
- // ── List backups ────────────────────────────────────────────────
75
-
76
- /**
77
- * List available backup/restore points from all sources.
78
- * Returns a globally time-sorted list (newest first), truncated to `limit`.
79
- *
80
- * @param {string} projectDir
81
- * @param {object} [opts]
82
- * @param {string} [opts.file] - Filter to commits touching this relative path
83
- * @param {string} [opts.before] - Time boundary (e.g. '10 minutes ago', ISO string)
84
- * @param {number} [opts.limit=20] - Max total results
85
- * @returns {{ sources: Array<{type: string, ref?: string, commitHash?: string, shortHash?: string, timestamp?: string, message?: string, path?: string, filesChanged?: number, summary?: string, trigger?: string}> }}
86
- */
87
- function listBackups(projectDir, opts = {}) {
88
- const limit = opts.limit || 20;
89
- const sources = [];
90
-
91
- if (opts.file) {
92
- const normalized = path.normalize(opts.file).replace(/\\/g, '/');
93
- if (path.isAbsolute(normalized) || normalized.startsWith('..')) {
94
- return { sources: [], error: 'file path must be relative and within project directory' };
95
- }
96
- }
97
-
98
- const repo = isGitRepo(projectDir);
99
- const beforeDate = parseBeforeExpression(opts.before);
100
-
101
- // Git sources
102
- if (repo) {
103
- // Auto-backup commits (git --before handles native filtering)
104
- const autoRef = 'refs/guard/auto-backup';
105
- const autoExists = git(['rev-parse', '--verify', autoRef], { cwd: projectDir, allowFail: true });
106
- if (autoExists) {
107
- const logArgs = ['log', autoRef, '--format=%H\x1f%aI\x1f%B\x1e', `-${limit}`, '--grep=^guard:'];
108
- if (opts.before) logArgs.push(`--before=${opts.before}`);
109
- if (opts.file) logArgs.push('--', opts.file);
110
- const out = git(logArgs, { cwd: projectDir, allowFail: true });
111
- if (out) {
112
- for (const record of out.split('\x1e').filter(r => r.trim())) {
113
- const parts = record.split('\x1f');
114
- if (parts.length < 3) continue;
115
- const hash = parts[0].trim();
116
- const timestamp = parts[1];
117
- const body = parts[2];
118
- const subject = body.split('\n')[0];
119
- const trailers = parseCommitTrailers(body);
120
- sources.push({
121
- type: 'git-auto-backup',
122
- ref: autoRef,
123
- commitHash: hash,
124
- shortHash: hash.substring(0, 7),
125
- timestamp,
126
- message: subject,
127
- ...trailers,
128
- });
129
- }
130
- }
131
- }
132
-
133
- // Pre-restore snapshots
134
- const preRestoreRefs = git(
135
- ['for-each-ref', 'refs/guard/pre-restore/', '--format=%(refname) %(objectname) %(*objectname) %(creatordate:iso-strict)', '--sort=-creatordate'],
136
- { cwd: projectDir, allowFail: true }
137
- );
138
- if (preRestoreRefs) {
139
- for (const line of preRestoreRefs.split('\n').filter(Boolean)) {
140
- const parts = line.split(' ');
141
- const ref = parts[0];
142
- const hash = parts[1];
143
- const timestamp = parts[3] || parts[2];
144
- if (beforeDate && timestamp) {
145
- const ms = Date.parse(timestamp);
146
- if (!isNaN(ms) && ms > beforeDate.getTime()) continue;
147
- }
148
- const entry = {
149
- type: 'git-pre-restore',
150
- ref,
151
- commitHash: hash,
152
- shortHash: hash.substring(0, 7),
153
- timestamp,
154
- };
155
- const prBody = git(['log', '-1', '--format=%B', hash], { cwd: projectDir, allowFail: true });
156
- if (prBody) {
157
- const prSubject = prBody.split('\n')[0];
158
- if (prSubject) entry.message = prSubject;
159
- Object.assign(entry, parseCommitTrailers(prBody));
160
- }
161
- sources.push(entry);
162
- }
163
- }
164
-
165
- // Manual / IDE snapshot ref (full history on dedicated ref; no subject grep so test/custom messages still list)
166
- const snapRef = 'refs/guard/snapshot';
167
- const snapshotExists = git(['rev-parse', '--verify', snapRef], { cwd: projectDir, allowFail: true });
168
- if (snapshotExists) {
169
- const snapLogArgs = ['log', snapRef, '--format=%H\x1f%aI\x1f%B\x1e', `-${limit}`];
170
- if (opts.before) snapLogArgs.push(`--before=${opts.before}`);
171
- if (opts.file) snapLogArgs.push('--', opts.file);
172
- const snapOut = git(snapLogArgs, { cwd: projectDir, allowFail: true });
173
- if (snapOut) {
174
- for (const record of snapOut.split('\x1e').filter(r => r.trim())) {
175
- const parts = record.split('\x1f');
176
- if (parts.length < 3) continue;
177
- const hash = parts[0].trim();
178
- const timestamp = parts[1];
179
- const body = parts[2];
180
- const subject = body.split('\n')[0];
181
- const trailers = parseCommitTrailers(body);
182
- if (beforeDate && timestamp) {
183
- const ms = Date.parse(timestamp);
184
- if (!isNaN(ms) && ms > beforeDate.getTime()) continue;
185
- }
186
- sources.push({
187
- type: 'git-snapshot',
188
- ref: snapRef,
189
- commitHash: hash,
190
- shortHash: hash.substring(0, 7),
191
- timestamp,
192
- message: subject || undefined,
193
- ...trailers,
194
- });
195
- }
196
- }
197
- }
198
- }
199
-
200
- // Shadow copy directories
201
- const backupDir = path.join(projectDir, '.cursor-guard-backup');
202
- if (fs.existsSync(backupDir)) {
203
- try {
204
- const dirs = fs.readdirSync(backupDir, { withFileTypes: true })
205
- .filter(d => d.isDirectory())
206
- .map(d => d.name)
207
- .sort()
208
- .reverse();
209
-
210
- for (const name of dirs) {
211
- const isPreRestore = name.startsWith('pre-restore-');
212
- const isTimestamp = /^\d{8}_\d{6}(_\d{3})?$/.test(name);
213
- if (!isTimestamp && !isPreRestore) continue;
214
-
215
- if (beforeDate) {
216
- const tsName = isPreRestore ? name.slice('pre-restore-'.length) : name;
217
- const snapDate = parseShadowTimestamp(tsName);
218
- if (snapDate && snapDate.getTime() > beforeDate.getTime()) continue;
219
- }
220
-
221
- const dirPath = path.join(backupDir, name);
222
-
223
- if (opts.file && !fs.existsSync(path.join(dirPath, opts.file))) continue;
224
-
225
- sources.push({
226
- type: isPreRestore ? 'shadow-pre-restore' : 'shadow',
227
- timestamp: name,
228
- path: dirPath,
229
- });
230
- }
231
- } catch { /* ignore */ }
232
- }
233
-
234
- // Unified time sort (newest first) across all sources, then truncate
235
- sources.sort((a, b) => entryToMs(b) - entryToMs(a));
236
-
237
- return { sources: sources.slice(0, limit) };
238
- }
239
-
240
- // ── Shadow retention ────────────────────────────────────────────
241
-
242
- /**
243
- * Clean old shadow copy snapshots based on retention config.
244
- *
245
- * @param {string} backupDir - Path to .cursor-guard-backup/
246
- * @param {object} cfg - Loaded config
247
- * @returns {{ removed: number, mode: string, diskFreeGB?: number, diskWarning?: string }}
248
- */
249
- function cleanShadowRetention(backupDir, cfg) {
250
- const { mode, days, max_count, max_size_mb } = cfg.retention;
251
- let dirs;
252
- try {
253
- dirs = fs.readdirSync(backupDir, { withFileTypes: true })
254
- .filter(d => d.isDirectory() && /^\d{8}_\d{6}(_\d{3})?$/.test(d.name))
255
- .map(d => d.name)
256
- .sort()
257
- .reverse();
258
- } catch { return { removed: 0, mode }; }
259
- if (!dirs || dirs.length === 0) return { removed: 0, mode };
260
-
261
- let removed = 0;
262
-
263
- if (mode === 'days') {
264
- const cutoff = Date.now() - days * 86400000;
265
- for (const name of dirs) {
266
- const dt = parseShadowTimestamp(name);
267
- if (!dt) continue;
268
- if (dt.getTime() < cutoff) {
269
- fs.rmSync(path.join(backupDir, name), { recursive: true, force: true });
270
- removed++;
271
- }
272
- }
273
- } else if (mode === 'count') {
274
- if (dirs.length > max_count) {
275
- for (const name of dirs.slice(max_count)) {
276
- fs.rmSync(path.join(backupDir, name), { recursive: true, force: true });
277
- removed++;
278
- }
279
- }
280
- } else if (mode === 'size') {
281
- let totalBytes = 0;
282
- try {
283
- const allFiles = walkDir(backupDir, backupDir);
284
- for (const f of allFiles) {
285
- try { totalBytes += fs.statSync(f.full).size; } catch { /* skip */ }
286
- }
287
- } catch { /* ignore */ }
288
- const oldestFirst = [...dirs].reverse();
289
- for (const name of oldestFirst) {
290
- if (totalBytes / (1024 * 1024) <= max_size_mb) break;
291
- const dirPath = path.join(backupDir, name);
292
- let dirSize = 0;
293
- try {
294
- const files = walkDir(dirPath, dirPath);
295
- for (const f of files) {
296
- try { dirSize += fs.statSync(f.full).size; } catch { /* skip */ }
297
- }
298
- } catch { /* ignore */ }
299
- fs.rmSync(dirPath, { recursive: true, force: true });
300
- totalBytes -= dirSize;
301
- removed++;
302
- }
303
- }
304
-
305
- const result = { removed, mode };
306
-
307
- const freeGB = diskFreeGB(backupDir);
308
- if (freeGB !== null) {
309
- result.diskFreeGB = parseFloat(freeGB.toFixed(1));
310
- if (freeGB < 1) result.diskWarning = 'critically low';
311
- else if (freeGB < 5) result.diskWarning = 'low';
312
- }
313
-
314
- return result;
315
- }
316
-
317
- // ── Git retention ───────────────────────────────────────────────
318
-
319
- /**
320
- * Clean old git auto-backup commits by rebuilding the branch as an orphan chain.
321
- *
322
- * @param {string} branchRef
323
- * @param {string} gitDirPath
324
- * @param {object} cfg - Loaded config
325
- * @param {string} cwd - Project directory
326
- * @returns {{ kept: number, pruned: number, mode: string, rebuilt: boolean, skipped?: boolean, reason?: string }}
327
- */
328
- function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
329
- const { mode, days, max_count } = cfg.git_retention;
330
- if (!cfg.git_retention.enabled) {
331
- return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'retention disabled' };
332
- }
333
-
334
- const RS = '\x1e', US = '\x1f';
335
- const out = git(['log', branchRef, `--format=%H${US}%aI${US}%cI${US}%s${US}%B${RS}`], { cwd, allowFail: true });
336
- if (!out) {
337
- return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'no commits on ref' };
338
- }
339
-
340
- const records = out.split(RS).filter(r => r.trim());
341
- const guardCommits = [];
342
- for (const record of records) {
343
- const fields = record.split(US);
344
- if (fields.length < 5) continue;
345
- const hash = fields[0].trim();
346
- const authorDate = fields[1].trim();
347
- const committerDate = fields[2].trim();
348
- const subject = fields[3].trim();
349
- const fullBody = fields[4].trim();
350
- if (subject.startsWith('guard: auto-backup') || subject.startsWith('guard: snapshot')) {
351
- guardCommits.push({ hash, authorDate, committerDate, subject, fullBody });
352
- }
353
- }
354
-
355
- const total = guardCommits.length;
356
- if (total === 0) {
357
- return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'no guard commits found' };
358
- }
359
-
360
- let keepCount = total;
361
- if (mode === 'count') {
362
- keepCount = Math.min(total, max_count);
363
- } else if (mode === 'days') {
364
- const cutoff = Date.now() - days * 86400000;
365
- keepCount = 0;
366
- for (const c of guardCommits) {
367
- if (new Date(c.authorDate).getTime() >= cutoff) keepCount++;
368
- else break;
369
- }
370
- keepCount = Math.max(keepCount, 10);
371
- }
372
-
373
- if (keepCount >= total) {
374
- return { kept: total, pruned: 0, mode, rebuilt: false };
375
- }
376
-
377
- const toKeep = guardCommits.slice(0, keepCount).reverse();
378
-
379
- function commitTreeWithDate(args, commit) {
380
- const env = {
381
- ...process.env,
382
- GIT_AUTHOR_DATE: commit.authorDate,
383
- GIT_COMMITTER_DATE: commit.committerDate,
384
- };
385
- try {
386
- return execFileSync('git', args, { cwd, env, stdio: 'pipe', encoding: 'utf-8' }).trim() || null;
387
- } catch { return null; }
388
- }
389
-
390
- const rootTree = git(['rev-parse', `${toKeep[0].hash}^{tree}`], { cwd, allowFail: true });
391
- if (!rootTree) {
392
- return { kept: total, pruned: 0, mode, rebuilt: false, reason: 'could not resolve root tree' };
393
- }
394
- const msgOf = (c) => c.fullBody || c.subject;
395
- let prevHash = commitTreeWithDate(['commit-tree', rootTree, '-m', msgOf(toKeep[0])], toKeep[0]);
396
- if (!prevHash) {
397
- return { kept: total, pruned: 0, mode, rebuilt: false, reason: 'commit-tree failed for root' };
398
- }
399
-
400
- for (let i = 1; i < toKeep.length; i++) {
401
- const tree = git(['rev-parse', `${toKeep[i].hash}^{tree}`], { cwd, allowFail: true });
402
- if (!tree) {
403
- return { kept: total, pruned: 0, mode, rebuilt: false, reason: `could not resolve tree for commit ${i}` };
404
- }
405
- prevHash = commitTreeWithDate(['commit-tree', tree, '-p', prevHash, '-m', msgOf(toKeep[i])], toKeep[i]);
406
- if (!prevHash) {
407
- return { kept: total, pruned: 0, mode, rebuilt: false, reason: `commit-tree failed at index ${i}` };
408
- }
409
- }
410
-
411
- git(['update-ref', branchRef, prevHash], { cwd, allowFail: true });
412
-
413
- return { kept: keepCount, pruned: total - keepCount, mode, rebuilt: true };
414
- }
415
-
416
- // ── Get backup file details ─────────────────────────────────────
417
-
418
- /** Git's canonical empty tree (used as diff base for root/orphan commits). */
419
- const GIT_EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
420
-
421
- /** Normalize paths so numstat / name-status keys match (Windows vs /, quotes). */
422
- function _normalizeBackupPath(p) {
423
- if (!p) return p;
424
- let s = String(p).replace(/\\/g, '/');
425
- if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
426
- s = s.slice(1, -1).replace(/\\"/g, '"');
427
- }
428
- return s;
429
- }
430
-
431
- /** Parse one line of `git diff --numstat` (same semantics as CLI; binary => `- -`). */
432
- function _parseGitDiffNumstatLine(add, del) {
433
- if (add === '-' || del === '-') {
434
- return { added: 0, deleted: 0, binary: true };
435
- }
436
- const a = parseInt(add, 10);
437
- const d = parseInt(del, 10);
438
- return {
439
- added: Number.isNaN(a) ? 0 : a,
440
- deleted: Number.isNaN(d) ? 0 : d,
441
- binary: false,
442
- };
443
- }
444
-
445
- /**
446
- * Get structured file-level changes for a specific git backup commit.
447
- * Uses **only** `git diff --numstat` + `git diff --name-status` (same as terminal),
448
- * so +/- counts match `git diff parent..commit` 100%. Root/orphan commits diff
449
- * against the standard empty tree.
450
- *
451
- * @param {string} projectDir
452
- * @param {string} commitHash - Full or short commit hash
453
- * @returns {{ files: Array<{path: string, action: string, added: number, deleted: number}>, error?: string }}
454
- */
455
- function getBackupFiles(projectDir, commitHash) {
456
- if (!isGitRepo(projectDir)) {
457
- return { files: [], error: 'not a git repository' };
458
- }
459
-
460
- const resolved = git(['rev-parse', '--verify', commitHash], { cwd: projectDir, allowFail: true });
461
- if (!resolved) {
462
- return { files: [], error: `cannot resolve commit: ${commitHash}` };
463
- }
464
-
465
- const parentCommit = git(['rev-parse', '--verify', `${resolved}^`], { cwd: projectDir, allowFail: true });
466
- const parent = parentCommit || GIT_EMPTY_TREE_SHA;
467
-
468
- const numstatOut = git(['diff', '--numstat', parent, resolved], { cwd: projectDir, allowFail: true });
469
- const nameStatusOut = git(['diff', '--name-status', parent, resolved], { cwd: projectDir, allowFail: true });
470
-
471
- const stats = {};
472
- if (numstatOut) {
473
- for (const line of numstatOut.split('\n').filter(Boolean)) {
474
- const [add, del, ...nameParts] = line.split('\t');
475
- const fname = _normalizeBackupPath(nameParts.join('\t'));
476
- stats[fname] = _parseGitDiffNumstatLine(add, del);
477
- }
478
- }
479
-
480
- const ACTION_MAP = { M: 'modified', A: 'added', D: 'deleted' };
481
- const files = [];
482
- if (nameStatusOut) {
483
- for (const line of nameStatusOut.split('\n').filter(Boolean)) {
484
- const tab = line.indexOf('\t');
485
- if (tab < 0) continue;
486
- const code = line.substring(0, tab).trim();
487
- const filePart = line.substring(tab + 1);
488
- let action = ACTION_MAP[code];
489
- if (code.startsWith('R')) action = 'renamed';
490
- else if (code.startsWith('C')) action = 'copied';
491
- else if (!action) action = 'modified';
492
-
493
- const fileName = filePart.split('\t').pop();
494
- const norm = _normalizeBackupPath(fileName);
495
- let s = stats[norm];
496
- if (!s && fileName !== norm) s = stats[fileName];
497
- if (!s) s = { added: 0, deleted: 0, binary: false };
498
-
499
- files.push({ path: fileName, action, added: s.added, deleted: s.deleted });
500
- }
501
- }
502
-
503
- files.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
504
- return { files };
505
- }
506
-
507
- module.exports = { listBackups, getBackupFiles, cleanShadowRetention, cleanGitRetention, parseShadowTimestamp };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+ const {
7
+ git, isGitRepo, gitDir: getGitDir, walkDir, diskFreeGB,
8
+ } = require('../utils');
9
+
10
+ // ── Helpers ──────────────────────────────────────────────────────
11
+
12
+ function parseShadowTimestamp(name) {
13
+ const m = name.match(/^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})(?:_(\d{3}))?$/);
14
+ if (!m) return null;
15
+ const ms = m[7] ? `.${m[7]}` : '';
16
+ return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${ms}`);
17
+ }
18
+
19
+ function parseBeforeExpression(before) {
20
+ if (!before) return null;
21
+ const iso = Date.parse(before);
22
+ if (!isNaN(iso)) return new Date(iso);
23
+ const agoMatch = before.match(/^(\d+)\s*(second|minute|hour|day|week|month)s?\s*ago$/i);
24
+ if (agoMatch) {
25
+ const n = parseInt(agoMatch[1], 10);
26
+ const unit = agoMatch[2].toLowerCase();
27
+ const ms = { second: 1000, minute: 60000, hour: 3600000, day: 86400000, week: 604800000, month: 2592000000 }[unit] || 0;
28
+ return new Date(Date.now() - n * ms);
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function entryToMs(entry) {
34
+ if (!entry.timestamp) return 0;
35
+ const iso = Date.parse(entry.timestamp);
36
+ if (!isNaN(iso)) return iso;
37
+ const tsName = typeof entry.timestamp === 'string' && entry.timestamp.startsWith('pre-restore-')
38
+ ? entry.timestamp.slice('pre-restore-'.length)
39
+ : entry.timestamp;
40
+ const d = parseShadowTimestamp(tsName);
41
+ return d ? d.getTime() : 0;
42
+ }
43
+
44
+ const TRAILER_MAP = {
45
+ 'Files-Changed': { key: 'filesChanged', parse: v => parseInt(v, 10) },
46
+ 'Summary': { key: 'summary' },
47
+ 'Trigger': { key: 'trigger' },
48
+ 'Intent': { key: 'intent' },
49
+ 'Agent': { key: 'agent' },
50
+ 'Session': { key: 'session' },
51
+ 'Guard-Event': { key: 'guardEvent' },
52
+ 'From': { key: 'from' },
53
+ 'Restore-To': { key: 'restoreTo' },
54
+ 'File': { key: 'restoreFile' },
55
+ 'Guard-Diff-Base': { key: 'guardDiffBase' },
56
+ 'Guard-Scope': { key: 'guardScope' },
57
+ 'Guard-Bookmark': { key: 'guardBookmark', parse: v => String(v).trim().toLowerCase() === 'true' },
58
+ };
59
+
60
+ function parseCommitTrailers(body) {
61
+ if (!body) return {};
62
+ const result = {};
63
+ const pattern = new RegExp(`^(${Object.keys(TRAILER_MAP).join('|')}):\\s*(.+)$`);
64
+ for (const line of body.split('\n')) {
65
+ const m = line.match(pattern);
66
+ if (m) {
67
+ const def = TRAILER_MAP[m[1]];
68
+ const raw = m[2].replace(/\r/g, '');
69
+ const val = def.parse ? def.parse(raw) : raw.trim();
70
+ result[def.key] = typeof val === 'string' ? val.trim() : val;
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+
76
+ // ── List backups ────────────────────────────────────────────────
77
+
78
+ /**
79
+ * List available backup/restore points from all sources.
80
+ * Returns a globally time-sorted list (newest first), truncated to `limit`.
81
+ *
82
+ * @param {string} projectDir
83
+ * @param {object} [opts]
84
+ * @param {string} [opts.file] - Filter to commits touching this relative path
85
+ * @param {string} [opts.before] - Time boundary (e.g. '10 minutes ago', ISO string)
86
+ * @param {number} [opts.limit=20] - Max total results
87
+ * @returns {{ sources: Array<{type: string, ref?: string, commitHash?: string, shortHash?: string, timestamp?: string, message?: string, path?: string, filesChanged?: number, summary?: string, trigger?: string}> }}
88
+ */
89
+ function listBackups(projectDir, opts = {}) {
90
+ const limit = opts.limit || 20;
91
+ const sources = [];
92
+
93
+ if (opts.file) {
94
+ const normalized = path.normalize(opts.file).replace(/\\/g, '/');
95
+ if (path.isAbsolute(normalized) || normalized.startsWith('..')) {
96
+ return { sources: [], error: 'file path must be relative and within project directory' };
97
+ }
98
+ }
99
+
100
+ const repo = isGitRepo(projectDir);
101
+ const beforeDate = parseBeforeExpression(opts.before);
102
+
103
+ // Git sources
104
+ if (repo) {
105
+ // Auto-backup commits (git --before handles native filtering)
106
+ const autoRef = 'refs/guard/auto-backup';
107
+ const autoExists = git(['rev-parse', '--verify', autoRef], { cwd: projectDir, allowFail: true });
108
+ if (autoExists) {
109
+ const logArgs = ['log', autoRef, '--format=%H\x1f%aI\x1f%B\x1e', `-${limit}`, '--grep=^guard:'];
110
+ if (opts.before) logArgs.push(`--before=${opts.before}`);
111
+ if (opts.file) logArgs.push('--', opts.file);
112
+ const out = git(logArgs, { cwd: projectDir, allowFail: true });
113
+ if (out) {
114
+ for (const record of out.split('\x1e').filter(r => r.trim())) {
115
+ const parts = record.split('\x1f');
116
+ if (parts.length < 3) continue;
117
+ const hash = parts[0].trim();
118
+ const timestamp = parts[1];
119
+ const body = parts[2];
120
+ const subject = body.split('\n')[0];
121
+ const trailers = parseCommitTrailers(body);
122
+ sources.push({
123
+ type: 'git-auto-backup',
124
+ ref: autoRef,
125
+ commitHash: hash,
126
+ shortHash: hash.substring(0, 7),
127
+ timestamp,
128
+ message: subject,
129
+ ...trailers,
130
+ });
131
+ }
132
+ }
133
+ }
134
+
135
+ // Pre-restore snapshots
136
+ const preRestoreRefs = git(
137
+ ['for-each-ref', 'refs/guard/pre-restore/', '--format=%(refname) %(objectname) %(*objectname) %(creatordate:iso-strict)', '--sort=-creatordate'],
138
+ { cwd: projectDir, allowFail: true }
139
+ );
140
+ if (preRestoreRefs) {
141
+ for (const line of preRestoreRefs.split('\n').filter(Boolean)) {
142
+ const parts = line.split(' ');
143
+ const ref = parts[0];
144
+ const hash = parts[1];
145
+ const timestamp = parts[3] || parts[2];
146
+ if (beforeDate && timestamp) {
147
+ const ms = Date.parse(timestamp);
148
+ if (!isNaN(ms) && ms > beforeDate.getTime()) continue;
149
+ }
150
+ const entry = {
151
+ type: 'git-pre-restore',
152
+ ref,
153
+ commitHash: hash,
154
+ shortHash: hash.substring(0, 7),
155
+ timestamp,
156
+ };
157
+ const prBody = git(['log', '-1', '--format=%B', hash], { cwd: projectDir, allowFail: true });
158
+ if (prBody) {
159
+ const prSubject = prBody.split('\n')[0];
160
+ if (prSubject) entry.message = prSubject;
161
+ Object.assign(entry, parseCommitTrailers(prBody));
162
+ }
163
+ sources.push(entry);
164
+ }
165
+ }
166
+
167
+ // Manual / IDE snapshot ref (full history on dedicated ref; no subject grep so test/custom messages still list)
168
+ const snapRef = 'refs/guard/snapshot';
169
+ const snapshotExists = git(['rev-parse', '--verify', snapRef], { cwd: projectDir, allowFail: true });
170
+ if (snapshotExists) {
171
+ const snapLogArgs = ['log', snapRef, '--format=%H\x1f%aI\x1f%B\x1e', `-${limit}`];
172
+ if (opts.before) snapLogArgs.push(`--before=${opts.before}`);
173
+ if (opts.file) snapLogArgs.push('--', opts.file);
174
+ const snapOut = git(snapLogArgs, { cwd: projectDir, allowFail: true });
175
+ if (snapOut) {
176
+ for (const record of snapOut.split('\x1e').filter(r => r.trim())) {
177
+ const parts = record.split('\x1f');
178
+ if (parts.length < 3) continue;
179
+ const hash = parts[0].trim();
180
+ const timestamp = parts[1];
181
+ const body = parts[2];
182
+ const subject = body.split('\n')[0];
183
+ const trailers = parseCommitTrailers(body);
184
+ if (beforeDate && timestamp) {
185
+ const ms = Date.parse(timestamp);
186
+ if (!isNaN(ms) && ms > beforeDate.getTime()) continue;
187
+ }
188
+ sources.push({
189
+ type: 'git-snapshot',
190
+ ref: snapRef,
191
+ commitHash: hash,
192
+ shortHash: hash.substring(0, 7),
193
+ timestamp,
194
+ message: subject || undefined,
195
+ ...trailers,
196
+ });
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ // Shadow copy directories
203
+ const backupDir = path.join(projectDir, '.cursor-guard-backup');
204
+ if (fs.existsSync(backupDir)) {
205
+ try {
206
+ const dirs = fs.readdirSync(backupDir, { withFileTypes: true })
207
+ .filter(d => d.isDirectory())
208
+ .map(d => d.name)
209
+ .sort()
210
+ .reverse();
211
+
212
+ for (const name of dirs) {
213
+ const isPreRestore = name.startsWith('pre-restore-');
214
+ const isTimestamp = /^\d{8}_\d{6}(_\d{3})?$/.test(name);
215
+ if (!isTimestamp && !isPreRestore) continue;
216
+
217
+ if (beforeDate) {
218
+ const tsName = isPreRestore ? name.slice('pre-restore-'.length) : name;
219
+ const snapDate = parseShadowTimestamp(tsName);
220
+ if (snapDate && snapDate.getTime() > beforeDate.getTime()) continue;
221
+ }
222
+
223
+ const dirPath = path.join(backupDir, name);
224
+
225
+ if (opts.file && !fs.existsSync(path.join(dirPath, opts.file))) continue;
226
+
227
+ sources.push({
228
+ type: isPreRestore ? 'shadow-pre-restore' : 'shadow',
229
+ timestamp: name,
230
+ path: dirPath,
231
+ });
232
+ }
233
+ } catch { /* ignore */ }
234
+ }
235
+
236
+ // Unified time sort (newest first) across all sources, then truncate
237
+ sources.sort((a, b) => entryToMs(b) - entryToMs(a));
238
+
239
+ return { sources: sources.slice(0, limit) };
240
+ }
241
+
242
+ // ── Shadow retention ────────────────────────────────────────────
243
+
244
+ /**
245
+ * Clean old shadow copy snapshots based on retention config.
246
+ *
247
+ * @param {string} backupDir - Path to .cursor-guard-backup/
248
+ * @param {object} cfg - Loaded config
249
+ * @returns {{ removed: number, mode: string, diskFreeGB?: number, diskWarning?: string }}
250
+ */
251
+ function cleanShadowRetention(backupDir, cfg) {
252
+ const { mode, days, max_count, max_size_mb } = cfg.retention;
253
+ let dirs;
254
+ try {
255
+ dirs = fs.readdirSync(backupDir, { withFileTypes: true })
256
+ .filter(d => d.isDirectory() && /^\d{8}_\d{6}(_\d{3})?$/.test(d.name))
257
+ .map(d => d.name)
258
+ .sort()
259
+ .reverse();
260
+ } catch { return { removed: 0, mode }; }
261
+ if (!dirs || dirs.length === 0) return { removed: 0, mode };
262
+
263
+ let removed = 0;
264
+
265
+ if (mode === 'days') {
266
+ const cutoff = Date.now() - days * 86400000;
267
+ for (const name of dirs) {
268
+ const dt = parseShadowTimestamp(name);
269
+ if (!dt) continue;
270
+ if (dt.getTime() < cutoff) {
271
+ fs.rmSync(path.join(backupDir, name), { recursive: true, force: true });
272
+ removed++;
273
+ }
274
+ }
275
+ } else if (mode === 'count') {
276
+ if (dirs.length > max_count) {
277
+ for (const name of dirs.slice(max_count)) {
278
+ fs.rmSync(path.join(backupDir, name), { recursive: true, force: true });
279
+ removed++;
280
+ }
281
+ }
282
+ } else if (mode === 'size') {
283
+ let totalBytes = 0;
284
+ try {
285
+ const allFiles = walkDir(backupDir, backupDir);
286
+ for (const f of allFiles) {
287
+ try { totalBytes += fs.statSync(f.full).size; } catch { /* skip */ }
288
+ }
289
+ } catch { /* ignore */ }
290
+ const oldestFirst = [...dirs].reverse();
291
+ for (const name of oldestFirst) {
292
+ if (totalBytes / (1024 * 1024) <= max_size_mb) break;
293
+ const dirPath = path.join(backupDir, name);
294
+ let dirSize = 0;
295
+ try {
296
+ const files = walkDir(dirPath, dirPath);
297
+ for (const f of files) {
298
+ try { dirSize += fs.statSync(f.full).size; } catch { /* skip */ }
299
+ }
300
+ } catch { /* ignore */ }
301
+ fs.rmSync(dirPath, { recursive: true, force: true });
302
+ totalBytes -= dirSize;
303
+ removed++;
304
+ }
305
+ }
306
+
307
+ const result = { removed, mode };
308
+
309
+ const freeGB = diskFreeGB(backupDir);
310
+ if (freeGB !== null) {
311
+ result.diskFreeGB = parseFloat(freeGB.toFixed(1));
312
+ if (freeGB < 1) result.diskWarning = 'critically low';
313
+ else if (freeGB < 5) result.diskWarning = 'low';
314
+ }
315
+
316
+ return result;
317
+ }
318
+
319
+ // ── Git retention ───────────────────────────────────────────────
320
+
321
+ /**
322
+ * Clean old git auto-backup commits by rebuilding the branch as an orphan chain.
323
+ *
324
+ * @param {string} branchRef
325
+ * @param {string} gitDirPath
326
+ * @param {object} cfg - Loaded config
327
+ * @param {string} cwd - Project directory
328
+ * @returns {{ kept: number, pruned: number, mode: string, rebuilt: boolean, skipped?: boolean, reason?: string }}
329
+ */
330
+ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
331
+ const { mode, days, max_count } = cfg.git_retention;
332
+ if (!cfg.git_retention.enabled) {
333
+ return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'retention disabled' };
334
+ }
335
+
336
+ const RS = '\x1e', US = '\x1f';
337
+ const out = git(['log', branchRef, `--format=%H${US}%aI${US}%cI${US}%s${US}%B${RS}`], { cwd, allowFail: true });
338
+ if (!out) {
339
+ return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'no commits on ref' };
340
+ }
341
+
342
+ const records = out.split(RS).filter(r => r.trim());
343
+ const guardCommits = [];
344
+ for (const record of records) {
345
+ const fields = record.split(US);
346
+ if (fields.length < 5) continue;
347
+ const hash = fields[0].trim();
348
+ const authorDate = fields[1].trim();
349
+ const committerDate = fields[2].trim();
350
+ const subject = fields[3].trim();
351
+ const fullBody = fields[4].trim();
352
+ if (subject.startsWith('guard: auto-backup') || subject.startsWith('guard: snapshot')) {
353
+ guardCommits.push({ hash, authorDate, committerDate, subject, fullBody });
354
+ }
355
+ }
356
+
357
+ const total = guardCommits.length;
358
+ if (total === 0) {
359
+ return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'no guard commits found' };
360
+ }
361
+
362
+ let keepCount = total;
363
+ if (mode === 'count') {
364
+ keepCount = Math.min(total, max_count);
365
+ } else if (mode === 'days') {
366
+ const cutoff = Date.now() - days * 86400000;
367
+ keepCount = 0;
368
+ for (const c of guardCommits) {
369
+ if (new Date(c.authorDate).getTime() >= cutoff) keepCount++;
370
+ else break;
371
+ }
372
+ keepCount = Math.max(keepCount, 10);
373
+ }
374
+
375
+ if (keepCount >= total) {
376
+ return { kept: total, pruned: 0, mode, rebuilt: false };
377
+ }
378
+
379
+ const toKeep = guardCommits.slice(0, keepCount).reverse();
380
+
381
+ function commitTreeWithDate(args, commit) {
382
+ const env = {
383
+ ...process.env,
384
+ GIT_AUTHOR_DATE: commit.authorDate,
385
+ GIT_COMMITTER_DATE: commit.committerDate,
386
+ };
387
+ try {
388
+ return execFileSync('git', args, { cwd, env, stdio: 'pipe', encoding: 'utf-8' }).trim() || null;
389
+ } catch { return null; }
390
+ }
391
+
392
+ const rootTree = git(['rev-parse', `${toKeep[0].hash}^{tree}`], { cwd, allowFail: true });
393
+ if (!rootTree) {
394
+ return { kept: total, pruned: 0, mode, rebuilt: false, reason: 'could not resolve root tree' };
395
+ }
396
+ const msgOf = (c) => c.fullBody || c.subject;
397
+ let prevHash = commitTreeWithDate(['commit-tree', rootTree, '-m', msgOf(toKeep[0])], toKeep[0]);
398
+ if (!prevHash) {
399
+ return { kept: total, pruned: 0, mode, rebuilt: false, reason: 'commit-tree failed for root' };
400
+ }
401
+
402
+ for (let i = 1; i < toKeep.length; i++) {
403
+ const tree = git(['rev-parse', `${toKeep[i].hash}^{tree}`], { cwd, allowFail: true });
404
+ if (!tree) {
405
+ return { kept: total, pruned: 0, mode, rebuilt: false, reason: `could not resolve tree for commit ${i}` };
406
+ }
407
+ prevHash = commitTreeWithDate(['commit-tree', tree, '-p', prevHash, '-m', msgOf(toKeep[i])], toKeep[i]);
408
+ if (!prevHash) {
409
+ return { kept: total, pruned: 0, mode, rebuilt: false, reason: `commit-tree failed at index ${i}` };
410
+ }
411
+ }
412
+
413
+ git(['update-ref', branchRef, prevHash], { cwd, allowFail: true });
414
+
415
+ return { kept: keepCount, pruned: total - keepCount, mode, rebuilt: true };
416
+ }
417
+
418
+ // ── Get backup file details ─────────────────────────────────────
419
+
420
+ /** Git's canonical empty tree (used as diff base for root/orphan commits). */
421
+ const GIT_EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
422
+
423
+ /** Normalize paths so numstat / name-status keys match (Windows vs /, quotes). */
424
+ function _normalizeBackupPath(p) {
425
+ if (!p) return p;
426
+ let s = String(p).replace(/\\/g, '/');
427
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
428
+ s = s.slice(1, -1).replace(/\\"/g, '"');
429
+ }
430
+ return s;
431
+ }
432
+
433
+ /** Parse one line of `git diff --numstat` (same semantics as CLI; binary => `- -`). */
434
+ function _parseGitDiffNumstatLine(add, del) {
435
+ if (add === '-' || del === '-') {
436
+ return { added: 0, deleted: 0, binary: true };
437
+ }
438
+ const a = parseInt(add, 10);
439
+ const d = parseInt(del, 10);
440
+ return {
441
+ added: Number.isNaN(a) ? 0 : a,
442
+ deleted: Number.isNaN(d) ? 0 : d,
443
+ binary: false,
444
+ };
445
+ }
446
+
447
+ /**
448
+ * Get structured file-level changes for a specific git backup commit.
449
+ * Uses **only** `git diff --numstat` + `git diff --name-status` (same as terminal),
450
+ * so +/- counts match `git diff parent..commit` 100%. Root/orphan commits diff
451
+ * against the standard empty tree.
452
+ *
453
+ * @param {string} projectDir
454
+ * @param {string} commitHash - Full or short commit hash
455
+ * @returns {{ files: Array<{path: string, action: string, added: number, deleted: number}>, error?: string }}
456
+ */
457
+ function getBackupFiles(projectDir, commitHash) {
458
+ if (!isGitRepo(projectDir)) {
459
+ return { files: [], error: 'not a git repository' };
460
+ }
461
+
462
+ const resolved = git(['rev-parse', '--verify', commitHash], { cwd: projectDir, allowFail: true });
463
+ if (!resolved) {
464
+ return { files: [], error: `cannot resolve commit: ${commitHash}` };
465
+ }
466
+
467
+ const parentCommit = git(['rev-parse', '--verify', `${resolved}^`], { cwd: projectDir, allowFail: true });
468
+ const parent = parentCommit || GIT_EMPTY_TREE_SHA;
469
+
470
+ const numstatOut = git(['diff', '--numstat', parent, resolved], { cwd: projectDir, allowFail: true });
471
+ const nameStatusOut = git(['diff', '--name-status', parent, resolved], { cwd: projectDir, allowFail: true });
472
+
473
+ const stats = {};
474
+ if (numstatOut) {
475
+ for (const line of numstatOut.split('\n').filter(Boolean)) {
476
+ const [add, del, ...nameParts] = line.split('\t');
477
+ const fname = _normalizeBackupPath(nameParts.join('\t'));
478
+ stats[fname] = _parseGitDiffNumstatLine(add, del);
479
+ }
480
+ }
481
+
482
+ const ACTION_MAP = { M: 'modified', A: 'added', D: 'deleted' };
483
+ const files = [];
484
+ if (nameStatusOut) {
485
+ for (const line of nameStatusOut.split('\n').filter(Boolean)) {
486
+ const tab = line.indexOf('\t');
487
+ if (tab < 0) continue;
488
+ const code = line.substring(0, tab).trim();
489
+ const filePart = line.substring(tab + 1);
490
+ let action = ACTION_MAP[code];
491
+ if (code.startsWith('R')) action = 'renamed';
492
+ else if (code.startsWith('C')) action = 'copied';
493
+ else if (!action) action = 'modified';
494
+
495
+ const fileName = filePart.split('\t').pop();
496
+ const norm = _normalizeBackupPath(fileName);
497
+ let s = stats[norm];
498
+ if (!s && fileName !== norm) s = stats[fileName];
499
+ if (!s) s = { added: 0, deleted: 0, binary: false };
500
+
501
+ files.push({ path: fileName, action, added: s.added, deleted: s.deleted });
502
+ }
503
+ }
504
+
505
+ files.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
506
+ return { files };
507
+ }
508
+
509
+ module.exports = { listBackups, getBackupFiles, cleanShadowRetention, cleanGitRetention, parseShadowTimestamp };