cursor-guard 4.9.9 → 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 (35) hide show
  1. package/README.md +697 -697
  2. package/README.zh-CN.md +696 -696
  3. package/ROADMAP.md +1775 -1720
  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 +70 -69
  8. package/references/dashboard/public/app.js +2079 -1832
  9. package/references/dashboard/public/style.css +1660 -1573
  10. package/references/dashboard/server.js +197 -4
  11. package/references/lib/core/backups.js +509 -492
  12. package/references/lib/core/core.test.js +1761 -1616
  13. package/references/lib/core/snapshot.js +441 -369
  14. package/references/mcp/mcp.test.js +381 -362
  15. package/references/mcp/server.js +404 -347
  16. package/references/vscode-extension/dist/{cursor-guard-ide-4.9.9.vsix → cursor-guard-ide-4.9.15.vsix} +0 -0
  17. package/references/vscode-extension/dist/dashboard/public/app.js +2079 -1832
  18. package/references/vscode-extension/dist/dashboard/public/style.css +1660 -1573
  19. package/references/vscode-extension/dist/dashboard/server.js +197 -4
  20. package/references/vscode-extension/dist/extension.js +780 -704
  21. package/references/vscode-extension/dist/guard-version.json +1 -1
  22. package/references/vscode-extension/dist/lib/auto-setup.js +201 -192
  23. package/references/vscode-extension/dist/lib/core/backups.js +509 -492
  24. package/references/vscode-extension/dist/lib/core/snapshot.js +441 -369
  25. package/references/vscode-extension/dist/lib/poller.js +161 -21
  26. package/references/vscode-extension/dist/lib/sidebar-webview.js +22 -0
  27. package/references/vscode-extension/dist/mcp/server.js +152 -35
  28. package/references/vscode-extension/dist/package.json +7 -1
  29. package/references/vscode-extension/dist/skill/ROADMAP.md +1775 -1720
  30. package/references/vscode-extension/dist/skill/SKILL.md +631 -629
  31. package/references/vscode-extension/extension.js +780 -704
  32. package/references/vscode-extension/lib/auto-setup.js +201 -192
  33. package/references/vscode-extension/lib/poller.js +161 -21
  34. package/references/vscode-extension/lib/sidebar-webview.js +22 -0
  35. package/references/vscode-extension/package.json +146 -140
@@ -1,369 +1,441 @@
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, filterFiles, matchesAny,
8
- } = require('../utils');
9
-
10
- // ── Helpers ─────────────────────────────────────────────────────
11
-
12
- function formatTimestamp(d) {
13
- const pad = n => String(n).padStart(2, '0');
14
- return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
15
- }
16
-
17
- function listIndexFiles(cwd, env) {
18
- try {
19
- const out = execFileSync('git', ['ls-files', '--cached'], {
20
- cwd, env, stdio: 'pipe', encoding: 'utf-8',
21
- }).trim();
22
- return out ? out.split('\n').filter(Boolean) : [];
23
- } catch { return []; }
24
- }
25
-
26
- function pruneIndexFiles(cwd, env, shouldRemove) {
27
- for (const f of listIndexFiles(cwd, env)) {
28
- if (!shouldRemove(f)) continue;
29
- try {
30
- execFileSync('git', ['rm', '--cached', '--ignore-unmatch', '-q', '--', f], {
31
- cwd, env, stdio: 'pipe',
32
- });
33
- } catch { /* ignore */ }
34
- }
35
- }
36
-
37
- function removeSecretsFromIndex(secretsPatterns, cwd, env) {
38
- const files = listIndexFiles(cwd, env);
39
-
40
- const excluded = [];
41
- for (const f of files) {
42
- const leaf = path.basename(f);
43
- if (matchesAny(secretsPatterns, f) || matchesAny(secretsPatterns, leaf)) {
44
- try {
45
- execFileSync('git', ['rm', '--cached', '--ignore-unmatch', '-q', '--', f], {
46
- cwd, env, stdio: 'pipe',
47
- });
48
- } catch { /* ignore */ }
49
- excluded.push(f);
50
- }
51
- }
52
- return excluded;
53
- }
54
-
55
- // ── Commit message builder ──────────────────────────────────────
56
-
57
- function buildCommitMessage(ts, opts) {
58
- if (opts.message && !opts.context) return opts.message;
59
-
60
- const ctx = opts.context || {};
61
- const countTag = ctx.changedFileCount ? ` (${ctx.changedFileCount} files)` : '';
62
- const subject = opts.message || `guard: auto-backup ${ts}${countTag}`;
63
-
64
- const trailers = [];
65
- if (ctx.changedFileCount != null) trailers.push(`Files-Changed: ${ctx.changedFileCount}`);
66
- if (ctx.summary) trailers.push(`Summary: ${ctx.summary}`);
67
- if (ctx.trigger) trailers.push(`Trigger: ${ctx.trigger}`);
68
- if (ctx.intent) trailers.push(`Intent: ${ctx.intent}`);
69
- if (ctx.agent) trailers.push(`Agent: ${ctx.agent}`);
70
- if (ctx.session) trailers.push(`Session: ${ctx.session}`);
71
-
72
- if (trailers.length === 0) return subject;
73
- return subject + '\n\n' + trailers.join('\n');
74
- }
75
-
76
- // ── Git snapshot ────────────────────────────────────────────────
77
-
78
- /**
79
- * Create a git snapshot commit on a dedicated ref using plumbing commands.
80
- * Does not touch the user's index or branch.
81
- *
82
- * @param {string} projectDir
83
- * @param {object} cfg - Loaded config
84
- * @param {object} [opts]
85
- * @param {string} [opts.branchRef='refs/guard/auto-backup']
86
- * @param {string} [opts.message] - Commit message (auto-generated if omitted)
87
- * @param {object} [opts.context] - Backup context metadata
88
- * @param {string} [opts.context.trigger] - 'auto' | 'manual' | 'pre-restore'
89
- * @param {number} [opts.context.changedFileCount] - Number of changed files
90
- * @param {string} [opts.context.summary] - Short change summary (e.g. "Modified 3: a.js, b.js; Added 1: c.js")
91
- * @param {string} [opts.context.intent] - Why this snapshot was created (e.g. "refactoring auth middleware")
92
- * @param {string} [opts.context.agent] - AI model identifier (e.g. "claude-4-opus")
93
- * @param {string} [opts.context.session] - Conversation/session ID
94
- * @param {boolean} [opts.allowEmptyTree] - If true, still create a commit when the snapshot tree equals the previous ref (empty / bookmark commit). Auto-backup should omit this; explicit manual snapshots should set it.
95
- * @param {boolean} [opts.fullWorkspaceSnapshot] - If true, ignore `cfg.protect` when building the snapshot tree (still apply `ignore` / secrets). Use for IDE/MCP "snapshot everything" so edits outside protect patterns are not invisible to the snapshot.
96
- * @returns {{ status: 'created'|'skipped'|'error', commitHash?: string, shortHash?: string, fileCount?: number, reason?: string, error?: string, secretsExcluded?: string[] }}
97
- */
98
- function createGitSnapshot(projectDir, cfg, opts = {}) {
99
- const branchRef = opts.branchRef || 'refs/guard/auto-backup';
100
- const cwd = projectDir;
101
- const gDir = getGitDir(projectDir);
102
- if (!gDir) return { status: 'error', error: 'not a git repository' };
103
-
104
- const narrowProtect = cfg.protect.length > 0 && !opts.fullWorkspaceSnapshot;
105
-
106
- const guardIndex = path.join(gDir, 'cursor-guard-index');
107
- const guardIndexLock = guardIndex + '.lock';
108
- const env = { ...process.env, GIT_INDEX_FILE: guardIndex };
109
-
110
- try { fs.unlinkSync(guardIndex); } catch { /* doesn't exist */ }
111
- try { fs.unlinkSync(guardIndexLock); } catch { /* doesn't exist */ }
112
-
113
- try {
114
- const parentHash = git(['rev-parse', '--verify', branchRef], { cwd, allowFail: true });
115
-
116
- if (narrowProtect) {
117
- // protect uses strict matching (full path only, no basename fallback)
118
- // so *.js only matches root-level js files, not nested ones
119
- execFileSync('git', ['add', '-A'], { cwd, env, stdio: 'pipe' });
120
- pruneIndexFiles(cwd, env, f => !matchesAny(cfg.protect, f, { strict: true }));
121
- } else {
122
- if (parentHash) {
123
- execFileSync('git', ['read-tree', branchRef], { cwd, env, stdio: 'pipe' });
124
- }
125
- execFileSync('git', ['add', '-A'], { cwd, env, stdio: 'pipe' });
126
- }
127
-
128
- // Keep ignore semantics aligned with filterFiles()/matchesAny(), including
129
- // basename-only patterns like "settings.json" for nested files.
130
- pruneIndexFiles(cwd, env, f => matchesAny(cfg.ignore, f));
131
-
132
- const secretsExcluded = removeSecretsFromIndex(cfg.secrets_patterns, cwd, env);
133
-
134
- const newTree = execFileSync('git', ['write-tree'], { cwd, env, stdio: 'pipe', encoding: 'utf-8' }).trim();
135
- const parentTree = parentHash
136
- ? git(['rev-parse', `${branchRef}^{tree}`], { cwd, allowFail: true })
137
- : null;
138
-
139
- if (newTree === parentTree && !opts.allowEmptyTree) {
140
- return { status: 'skipped', reason: 'tree unchanged' };
141
- }
142
-
143
- // Build incremental summary from actual tree diff (not working-dir status)
144
- let changedCount;
145
- let incrementalSummary;
146
- let changedFiles;
147
- if (parentTree) {
148
- const diffOut = git(['diff-tree', '--no-commit-id', '--name-status', '-r', parentTree, newTree], { cwd, allowFail: true });
149
- if (diffOut) {
150
- const diffLines = diffOut.split('\n').filter(Boolean);
151
- const groups = { M: [], A: [], D: [], R: [] };
152
- for (const line of diffLines) {
153
- const tab = line.indexOf('\t');
154
- if (tab < 0) continue;
155
- const code = line.substring(0, tab).trim();
156
- const filePart = line.substring(tab + 1);
157
- const key = code.startsWith('R') ? 'R'
158
- : code === 'D' ? 'D'
159
- : code === 'A' ? 'A'
160
- : 'M';
161
- const fileName = filePart.split('\t').pop();
162
- if (matchesAny(cfg.ignore, fileName) || matchesAny(cfg.ignore, path.basename(fileName))) continue;
163
- if (narrowProtect && !matchesAny(cfg.protect, fileName, { strict: true })) continue;
164
- groups[key].push(fileName);
165
- }
166
- changedCount = Object.values(groups).reduce((sum, arr) => sum + arr.length, 0);
167
-
168
- const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', parentTree, newTree], { cwd, allowFail: true });
169
- const stats = {};
170
- if (numstatOut) {
171
- for (const line of numstatOut.split('\n').filter(Boolean)) {
172
- const [add, del, ...nameParts] = line.split('\t');
173
- const fname = nameParts.join('\t');
174
- stats[fname] = { added: add === '-' ? 0 : parseInt(add, 10), deleted: del === '-' ? 0 : parseInt(del, 10) };
175
- }
176
- }
177
-
178
- // Build structured changedFiles array
179
- changedFiles = [];
180
- const ACTION_MAP = { M: 'modified', A: 'added', D: 'deleted', R: 'renamed' };
181
- for (const [key, arr] of Object.entries(groups)) {
182
- for (const f of arr) {
183
- const s = stats[f] || { added: 0, deleted: 0 };
184
- changedFiles.push({ path: f, action: ACTION_MAP[key], added: s.added, deleted: s.deleted });
185
- }
186
- }
187
- changedFiles.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
188
-
189
- function fmtFiles(arr) {
190
- return arr.slice(0, 5).map(f => {
191
- const s = stats[f];
192
- return s ? `${f} (+${s.added} -${s.deleted})` : f;
193
- }).join(', ');
194
- }
195
-
196
- const parts = [];
197
- if (groups.M.length) parts.push(`Modified ${groups.M.length}: ${fmtFiles(groups.M)}${groups.M.length > 5 ? ', ...' : ''}`);
198
- if (groups.A.length) parts.push(`Added ${groups.A.length}: ${fmtFiles(groups.A)}${groups.A.length > 5 ? ', ...' : ''}`);
199
- if (groups.D.length) parts.push(`Deleted ${groups.D.length}: ${fmtFiles(groups.D)}${groups.D.length > 5 ? ', ...' : ''}`);
200
- if (groups.R.length) parts.push(`Renamed ${groups.R.length}: ${fmtFiles(groups.R)}${groups.R.length > 5 ? ', ...' : ''}`);
201
- if (parts.length) incrementalSummary = parts.join('; ');
202
- }
203
- } else {
204
- const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf899d15f3b60ea6a';
205
- const lsInitial = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
206
- if (lsInitial) {
207
- const files = lsInitial.split('\n').filter(Boolean)
208
- .filter(f => !matchesAny(cfg.ignore, f) && !matchesAny(cfg.ignore, path.basename(f)))
209
- .filter(f => !narrowProtect || matchesAny(cfg.protect, f, { strict: true }));
210
- changedCount = files.length;
211
- const sample = files.slice(0, 5).join(', ');
212
-
213
- const numstatInit = git(['diff-tree', '--no-commit-id', '--numstat', '-r', EMPTY_TREE, newTree], { cwd, allowFail: true });
214
- const stats = {};
215
- if (numstatInit) {
216
- for (const line of numstatInit.split('\n').filter(Boolean)) {
217
- const [add, del, ...nameParts] = line.split('\t');
218
- const fname = nameParts.join('\t');
219
- stats[fname] = { added: add === '-' ? 0 : parseInt(add, 10), deleted: del === '-' ? 0 : parseInt(del, 10) };
220
- }
221
- }
222
-
223
- changedFiles = files.map(f => {
224
- const s = stats[f] || { added: 0, deleted: 0 };
225
- return { path: f, action: 'added', added: s.added, deleted: s.deleted };
226
- });
227
-
228
- function fmtFilesInit(arr) {
229
- return arr.slice(0, 5).map(f => {
230
- const s = stats[f];
231
- return s ? `${f} (+${s.added} -${s.deleted})` : f;
232
- }).join(', ');
233
- }
234
- incrementalSummary = `Added ${files.length}: ${fmtFilesInit(files)}${files.length > 5 ? ', ...' : ''}`;
235
- }
236
- }
237
-
238
- // Override context summary with the accurate incremental one
239
- if (incrementalSummary && opts.context) {
240
- opts.context.summary = incrementalSummary;
241
- } else if (incrementalSummary && !opts.context) {
242
- opts.context = { summary: incrementalSummary };
243
- }
244
- if (changedCount != null && opts.context) {
245
- opts.context.changedFileCount = changedCount;
246
- }
247
-
248
- const ts = formatTimestamp(new Date());
249
- const msg = buildCommitMessage(ts, opts);
250
- const commitArgs = parentHash
251
- ? ['commit-tree', newTree, '-p', parentHash, '-m', msg]
252
- : ['commit-tree', newTree, '-m', msg];
253
- const commitHash = execFileSync('git', commitArgs, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
254
-
255
- if (!commitHash) {
256
- return { status: 'error', error: 'commit-tree returned empty hash' };
257
- }
258
-
259
- git(['update-ref', branchRef, commitHash], { cwd });
260
-
261
- const lsOut = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
262
- const fileCount = lsOut ? lsOut.split('\n').filter(Boolean).length : 0;
263
-
264
- return {
265
- status: 'created',
266
- commitHash,
267
- shortHash: commitHash.substring(0, 7),
268
- fileCount,
269
- changedCount,
270
- changedFiles,
271
- incrementalSummary,
272
- secretsExcluded: secretsExcluded.length > 0 ? secretsExcluded : undefined,
273
- };
274
- } catch (e) {
275
- return { status: 'error', error: e.message };
276
- } finally {
277
- try { fs.unlinkSync(guardIndex); } catch { /* ignore */ }
278
- try { fs.unlinkSync(guardIndexLock); } catch { /* ignore */ }
279
- }
280
- }
281
-
282
- // ── Shadow copy ─────────────────────────────────────────────────
283
-
284
- /**
285
- * Create a shadow (file) copy of the project.
286
- *
287
- * @param {string} projectDir
288
- * @param {object} cfg - Loaded config
289
- * @param {object} [opts]
290
- * @param {string} [opts.backupDir] - Override backup directory (default: projectDir/.cursor-guard-backup)
291
- * @returns {{ status: 'created'|'empty'|'error', timestamp?: string, fileCount?: number, snapshotDir?: string, error?: string }}
292
- */
293
- function findPreviousSnapshot(backupDir) {
294
- try {
295
- const entries = fs.readdirSync(backupDir)
296
- .filter(e => /^\d{8}_\d{6}/.test(e))
297
- .sort()
298
- .reverse();
299
- for (const e of entries) {
300
- const full = path.join(backupDir, e);
301
- if (fs.statSync(full).isDirectory()) return full;
302
- }
303
- } catch { /* no previous snapshots */ }
304
- return null;
305
- }
306
-
307
- function createShadowCopy(projectDir, cfg, opts = {}) {
308
- const backupDir = opts.backupDir || path.join(projectDir, '.cursor-guard-backup');
309
- let ts = formatTimestamp(new Date());
310
- let snapDir = path.join(backupDir, ts);
311
-
312
- try {
313
- if (fs.existsSync(snapDir)) {
314
- const baseTs = ts;
315
- let seq = new Date().getMilliseconds();
316
- for (let i = 0; i < 1000 && fs.existsSync(snapDir); i++, seq++) {
317
- ts = `${baseTs}_${String(seq % 1000).padStart(3, '0')}`;
318
- snapDir = path.join(backupDir, ts);
319
- }
320
- }
321
- const prevSnapDir = findPreviousSnapshot(backupDir);
322
-
323
- fs.mkdirSync(snapDir, { recursive: true });
324
-
325
- const allFiles = walkDir(projectDir, projectDir);
326
- const files = filterFiles(allFiles, cfg);
327
-
328
- let copied = 0;
329
- let linked = 0;
330
- for (const f of files) {
331
- const dest = path.join(snapDir, f.rel);
332
- fs.mkdirSync(path.dirname(dest), { recursive: true });
333
- try {
334
- let didLink = false;
335
- if (prevSnapDir) {
336
- const prevFile = path.join(prevSnapDir, f.rel);
337
- try {
338
- const srcStat = fs.statSync(f.full);
339
- const prevStat = fs.statSync(prevFile);
340
- if (srcStat.size === prevStat.size && Math.abs(srcStat.mtimeMs - prevStat.mtimeMs) < 1) {
341
- fs.linkSync(prevFile, dest);
342
- didLink = true;
343
- linked++;
344
- }
345
- } catch { /* prev file missing or stat error — fall through to copy */ }
346
- }
347
- if (!didLink) {
348
- fs.copyFileSync(f.full, dest);
349
- try {
350
- const srcStat = fs.statSync(f.full);
351
- fs.utimesSync(dest, srcStat.atime, srcStat.mtime);
352
- } catch { /* non-critical: mtime preservation failed */ }
353
- }
354
- copied++;
355
- } catch { /* skip unreadable */ }
356
- }
357
-
358
- if (copied === 0) {
359
- fs.rmSync(snapDir, { recursive: true, force: true });
360
- return { status: 'empty', timestamp: ts };
361
- }
362
-
363
- return { status: 'created', timestamp: ts, fileCount: copied, linkedCount: linked, snapshotDir: snapDir };
364
- } catch (e) {
365
- return { status: 'error', error: e.message };
366
- }
367
- }
368
-
369
- module.exports = { createGitSnapshot, createShadowCopy, formatTimestamp, removeSecretsFromIndex };
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, filterFiles, matchesAny,
8
+ } = require('../utils');
9
+
10
+ // ── Helpers ─────────────────────────────────────────────────────
11
+
12
+ function formatTimestamp(d) {
13
+ const pad = n => String(n).padStart(2, '0');
14
+ return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
15
+ }
16
+
17
+ const REF_GUARD_AUTO_BACKUP = 'refs/guard/auto-backup';
18
+ const REF_GUARD_SNAPSHOT = 'refs/guard/snapshot';
19
+
20
+ /**
21
+ * Parent commit for the next Guard Git snapshot (first parent of `commit-tree`).
22
+ *
23
+ * For **refs/guard/auto-backup** and **refs/guard/snapshot** only: pick whichever tip is
24
+ * **newer in commit time** between the two refs. That matches the human reading: "changes
25
+ * since the **last** Guard backup, automatic or manual" — one shared baseline for +/- and file counts.
26
+ *
27
+ * For any **other** `branchRef` (e.g. tests using refs/guard/test-*): chain that ref only.
28
+ */
29
+ function resolveGuardParentHash(cwd, branchRef) {
30
+ if (branchRef !== REF_GUARD_AUTO_BACKUP && branchRef !== REF_GUARD_SNAPSHOT) {
31
+ return git(['rev-parse', '--verify', branchRef], { cwd, allowFail: true });
32
+ }
33
+ const autoH = git(['rev-parse', '--verify', REF_GUARD_AUTO_BACKUP], { cwd, allowFail: true });
34
+ const snapH = git(['rev-parse', '--verify', REF_GUARD_SNAPSHOT], { cwd, allowFail: true });
35
+ if (!autoH && !snapH) return null;
36
+ if (!autoH) return snapH;
37
+ if (!snapH) return autoH;
38
+ const commitUnix = h => {
39
+ const s = git(['log', '-1', '--format=%ct', h], { cwd, allowFail: true });
40
+ return s ? parseInt(String(s).trim(), 10) : 0;
41
+ };
42
+ const tAuto = commitUnix(autoH);
43
+ const tSnap = commitUnix(snapH);
44
+ return tSnap > tAuto ? snapH : autoH;
45
+ }
46
+
47
+ function listIndexFiles(cwd, env) {
48
+ try {
49
+ const out = execFileSync('git', ['ls-files', '--cached'], {
50
+ cwd, env, stdio: 'pipe', encoding: 'utf-8',
51
+ }).trim();
52
+ return out ? out.split('\n').filter(Boolean) : [];
53
+ } catch { return []; }
54
+ }
55
+
56
+ function pruneIndexFiles(cwd, env, shouldRemove) {
57
+ for (const f of listIndexFiles(cwd, env)) {
58
+ if (!shouldRemove(f)) continue;
59
+ try {
60
+ execFileSync('git', ['rm', '--cached', '--ignore-unmatch', '-q', '--', f], {
61
+ cwd, env, stdio: 'pipe',
62
+ });
63
+ } catch { /* ignore */ }
64
+ }
65
+ }
66
+
67
+ function removeSecretsFromIndex(secretsPatterns, cwd, env) {
68
+ const files = listIndexFiles(cwd, env);
69
+
70
+ const excluded = [];
71
+ for (const f of files) {
72
+ const leaf = path.basename(f);
73
+ if (matchesAny(secretsPatterns, f) || matchesAny(secretsPatterns, leaf)) {
74
+ try {
75
+ execFileSync('git', ['rm', '--cached', '--ignore-unmatch', '-q', '--', f], {
76
+ cwd, env, stdio: 'pipe',
77
+ });
78
+ } catch { /* ignore */ }
79
+ excluded.push(f);
80
+ }
81
+ }
82
+ return excluded;
83
+ }
84
+
85
+ // ── Commit message builder ──────────────────────────────────────
86
+
87
+ /** Single-line trailer value (no CR/LF; capped length). */
88
+ function trailerScalar(val, maxLen = 500) {
89
+ if (val == null) return '';
90
+ return String(val)
91
+ .replace(/\r\n/g, ' ')
92
+ .replace(/\r/g, ' ')
93
+ .replace(/\n/g, ' ')
94
+ .trim()
95
+ .slice(0, maxLen);
96
+ }
97
+
98
+ function buildCommitMessage(ts, opts) {
99
+ if (opts.message && !opts.context) return opts.message;
100
+
101
+ const ctx = opts.context || {};
102
+ const countTag = ctx.changedFileCount ? ` (${ctx.changedFileCount} files)` : '';
103
+ const subject = opts.message || `guard: auto-backup ${ts}${countTag}`;
104
+
105
+ const trailers = [];
106
+ if (ctx.changedFileCount != null) trailers.push(`Files-Changed: ${ctx.changedFileCount}`);
107
+ if (ctx.summary) trailers.push(`Summary: ${trailerScalar(ctx.summary, 2000)}`);
108
+ if (ctx.trigger) trailers.push(`Trigger: ${trailerScalar(ctx.trigger)}`);
109
+ if (ctx.intent) trailers.push(`Intent: ${trailerScalar(ctx.intent)}`);
110
+ if (ctx.agent) trailers.push(`Agent: ${trailerScalar(ctx.agent)}`);
111
+ if (ctx.session) trailers.push(`Session: ${trailerScalar(ctx.session)}`);
112
+ if (ctx.guardEvent) trailers.push(`Guard-Event: ${trailerScalar(ctx.guardEvent)}`);
113
+
114
+ if (trailers.length === 0) return subject;
115
+ return subject + '\n\n' + trailers.join('\n');
116
+ }
117
+
118
+ // ── Git snapshot ────────────────────────────────────────────────
119
+
120
+ /**
121
+ * Create a git snapshot commit on a dedicated ref using plumbing commands.
122
+ * Does not touch the user's index or branch.
123
+ *
124
+ * @param {string} projectDir
125
+ * @param {object} cfg - Loaded config
126
+ * @param {object} [opts]
127
+ * @param {string} [opts.branchRef='refs/guard/auto-backup']
128
+ * @param {string} [opts.message] - Commit message (auto-generated if omitted)
129
+ * @param {object} [opts.context] - Backup context metadata
130
+ * @param {string} [opts.context.trigger] - 'auto' | 'manual' | 'pre-restore'
131
+ * @param {number} [opts.context.changedFileCount] - Number of changed files
132
+ * @param {string} [opts.context.summary] - Short change summary (e.g. "Modified 3: a.js, b.js; Added 1: c.js")
133
+ * @param {string} [opts.context.intent] - Why this snapshot was created (e.g. "refactoring auth middleware")
134
+ * @param {string} [opts.context.agent] - AI model identifier (e.g. "claude-4-opus")
135
+ * @param {string} [opts.context.session] - Conversation/session ID
136
+ * @param {string} [opts.context.guardEvent] - Short MCP/audit event id (written as Guard-Event trailer)
137
+ * @param {boolean} [opts.allowEmptyTree] - If true, still create a commit when the snapshot tree equals the previous ref (empty / bookmark commit). Auto-backup should omit this; explicit manual snapshots should set it.
138
+ * @param {boolean} [opts.fullWorkspaceSnapshot] - If true, ignore `cfg.protect` when building the snapshot tree (still apply `ignore` / secrets). Use for IDE/MCP "snapshot everything" so edits outside protect patterns are not invisible to the snapshot.
139
+ * @returns {{ status: 'created'|'skipped'|'error', commitHash?: string, shortHash?: string, fileCount?: number, reason?: string, error?: string, secretsExcluded?: string[], bookmark?: boolean }}
140
+ * @remarks For refs/guard/auto-backup and refs/guard/snapshot, the first parent is always the
141
+ * newer of those two tips (by commit time), so incremental stats mean "since last Guard backup" in the human sense.
142
+ */
143
+ function createGitSnapshot(projectDir, cfg, opts = {}) {
144
+ const branchRef = opts.branchRef || 'refs/guard/auto-backup';
145
+ const cwd = projectDir;
146
+ const gDir = getGitDir(projectDir);
147
+ if (!gDir) return { status: 'error', error: 'not a git repository' };
148
+
149
+ const narrowProtect = cfg.protect.length > 0 && !opts.fullWorkspaceSnapshot;
150
+
151
+ const guardIndex = path.join(gDir, 'cursor-guard-index');
152
+ const guardIndexLock = guardIndex + '.lock';
153
+ const env = { ...process.env, GIT_INDEX_FILE: guardIndex };
154
+
155
+ try { fs.unlinkSync(guardIndex); } catch { /* doesn't exist */ }
156
+ try { fs.unlinkSync(guardIndexLock); } catch { /* doesn't exist */ }
157
+
158
+ try {
159
+ const parentHash = resolveGuardParentHash(cwd, branchRef);
160
+
161
+ if (narrowProtect) {
162
+ // protect uses strict matching (full path only, no basename fallback)
163
+ // so *.js only matches root-level js files, not nested ones
164
+ execFileSync('git', ['add', '-A'], { cwd, env, stdio: 'pipe' });
165
+ pruneIndexFiles(cwd, env, f => !matchesAny(cfg.protect, f, { strict: true }));
166
+ } else {
167
+ if (parentHash) {
168
+ execFileSync('git', ['read-tree', parentHash], { cwd, env, stdio: 'pipe' });
169
+ }
170
+ execFileSync('git', ['add', '-A'], { cwd, env, stdio: 'pipe' });
171
+ }
172
+
173
+ // Keep ignore semantics aligned with filterFiles()/matchesAny(), including
174
+ // basename-only patterns like "settings.json" for nested files.
175
+ pruneIndexFiles(cwd, env, f => matchesAny(cfg.ignore, f));
176
+
177
+ const secretsExcluded = removeSecretsFromIndex(cfg.secrets_patterns, cwd, env);
178
+
179
+ const newTree = execFileSync('git', ['write-tree'], { cwd, env, stdio: 'pipe', encoding: 'utf-8' }).trim();
180
+ const parentTree = parentHash
181
+ ? git(['rev-parse', `${parentHash}^{tree}`], { cwd, allowFail: true })
182
+ : null;
183
+
184
+ if (newTree === parentTree && !opts.allowEmptyTree) {
185
+ return { status: 'skipped', reason: 'tree unchanged' };
186
+ }
187
+
188
+ /** Manual snapshot (allowEmptyTree): same tree as parent → still create a Git commit so intent/time appear on the timeline. */
189
+ const isBookmarkCommit = !!(opts.allowEmptyTree && parentTree && newTree === parentTree);
190
+
191
+ // Build incremental summary from actual tree diff (not working-dir status)
192
+ let changedCount;
193
+ let incrementalSummary;
194
+ let changedFiles;
195
+ if (parentTree) {
196
+ const diffOut = git(['diff-tree', '--no-commit-id', '--name-status', '-r', parentTree, newTree], { cwd, allowFail: true });
197
+ if (diffOut) {
198
+ const diffLines = diffOut.split('\n').filter(Boolean);
199
+ const groups = { M: [], A: [], D: [], R: [] };
200
+ for (const line of diffLines) {
201
+ const tab = line.indexOf('\t');
202
+ if (tab < 0) continue;
203
+ const code = line.substring(0, tab).trim();
204
+ const filePart = line.substring(tab + 1);
205
+ const key = code.startsWith('R') ? 'R'
206
+ : code === 'D' ? 'D'
207
+ : code === 'A' ? 'A'
208
+ : 'M';
209
+ const fileName = filePart.split('\t').pop();
210
+ if (matchesAny(cfg.ignore, fileName) || matchesAny(cfg.ignore, path.basename(fileName))) continue;
211
+ if (narrowProtect && !matchesAny(cfg.protect, fileName, { strict: true })) continue;
212
+ groups[key].push(fileName);
213
+ }
214
+ changedCount = Object.values(groups).reduce((sum, arr) => sum + arr.length, 0);
215
+
216
+ const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', parentTree, newTree], { cwd, allowFail: true });
217
+ const stats = {};
218
+ if (numstatOut) {
219
+ for (const line of numstatOut.split('\n').filter(Boolean)) {
220
+ const [add, del, ...nameParts] = line.split('\t');
221
+ const fname = nameParts.join('\t');
222
+ stats[fname] = { added: add === '-' ? 0 : parseInt(add, 10), deleted: del === '-' ? 0 : parseInt(del, 10) };
223
+ }
224
+ }
225
+
226
+ // Build structured changedFiles array
227
+ changedFiles = [];
228
+ const ACTION_MAP = { M: 'modified', A: 'added', D: 'deleted', R: 'renamed' };
229
+ for (const [key, arr] of Object.entries(groups)) {
230
+ for (const f of arr) {
231
+ const s = stats[f] || { added: 0, deleted: 0 };
232
+ changedFiles.push({ path: f, action: ACTION_MAP[key], added: s.added, deleted: s.deleted });
233
+ }
234
+ }
235
+ changedFiles.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
236
+
237
+ function fmtFiles(arr) {
238
+ return arr.slice(0, 5).map(f => {
239
+ const s = stats[f];
240
+ return s ? `${f} (+${s.added} -${s.deleted})` : f;
241
+ }).join(', ');
242
+ }
243
+
244
+ const parts = [];
245
+ if (groups.M.length) parts.push(`Modified ${groups.M.length}: ${fmtFiles(groups.M)}${groups.M.length > 5 ? ', ...' : ''}`);
246
+ if (groups.A.length) parts.push(`Added ${groups.A.length}: ${fmtFiles(groups.A)}${groups.A.length > 5 ? ', ...' : ''}`);
247
+ if (groups.D.length) parts.push(`Deleted ${groups.D.length}: ${fmtFiles(groups.D)}${groups.D.length > 5 ? ', ...' : ''}`);
248
+ if (groups.R.length) parts.push(`Renamed ${groups.R.length}: ${fmtFiles(groups.R)}${groups.R.length > 5 ? ', ...' : ''}`);
249
+ if (parts.length) incrementalSummary = parts.join('; ');
250
+ }
251
+ } else {
252
+ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf899d15f3b60ea6a';
253
+ const lsInitial = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
254
+ if (lsInitial) {
255
+ const files = lsInitial.split('\n').filter(Boolean)
256
+ .filter(f => !matchesAny(cfg.ignore, f) && !matchesAny(cfg.ignore, path.basename(f)))
257
+ .filter(f => !narrowProtect || matchesAny(cfg.protect, f, { strict: true }));
258
+ changedCount = files.length;
259
+ const sample = files.slice(0, 5).join(', ');
260
+
261
+ const numstatInit = git(['diff-tree', '--no-commit-id', '--numstat', '-r', EMPTY_TREE, newTree], { cwd, allowFail: true });
262
+ const stats = {};
263
+ if (numstatInit) {
264
+ for (const line of numstatInit.split('\n').filter(Boolean)) {
265
+ const [add, del, ...nameParts] = line.split('\t');
266
+ const fname = nameParts.join('\t');
267
+ stats[fname] = { added: add === '-' ? 0 : parseInt(add, 10), deleted: del === '-' ? 0 : parseInt(del, 10) };
268
+ }
269
+ }
270
+
271
+ changedFiles = files.map(f => {
272
+ const s = stats[f] || { added: 0, deleted: 0 };
273
+ return { path: f, action: 'added', added: s.added, deleted: s.deleted };
274
+ });
275
+
276
+ function fmtFilesInit(arr) {
277
+ return arr.slice(0, 5).map(f => {
278
+ const s = stats[f];
279
+ return s ? `${f} (+${s.added} -${s.deleted})` : f;
280
+ }).join(', ');
281
+ }
282
+ incrementalSummary = `Added ${files.length}: ${fmtFilesInit(files)}${files.length > 5 ? ', ...' : ''}`;
283
+ }
284
+ }
285
+
286
+ // Override context summary with the accurate incremental one
287
+ if (incrementalSummary && opts.context) {
288
+ opts.context.summary = incrementalSummary;
289
+ } else if (incrementalSummary && !opts.context) {
290
+ opts.context = { summary: incrementalSummary };
291
+ }
292
+ if (changedCount != null && opts.context) {
293
+ opts.context.changedFileCount = changedCount;
294
+ }
295
+
296
+ if (isBookmarkCommit && opts.context) {
297
+ const s = opts.context.summary;
298
+ if (s == null || String(s).trim() === '') {
299
+ opts.context.summary = 'No file changes since last Guard baseline (bookmark).';
300
+ }
301
+ if (opts.context.changedFileCount == null) opts.context.changedFileCount = 0;
302
+ }
303
+
304
+ const ts = formatTimestamp(new Date());
305
+ let msg = buildCommitMessage(ts, opts);
306
+
307
+ const autoTip = git(['rev-parse', '--verify', REF_GUARD_AUTO_BACKUP], { cwd, allowFail: true });
308
+ const snapTip = git(['rev-parse', '--verify', REF_GUARD_SNAPSHOT], { cwd, allowFail: true });
309
+ const autoTipTrim = autoTip ? String(autoTip).trim() : '';
310
+ const snapTipTrim = snapTip ? String(snapTip).trim() : '';
311
+ let diffBaseLabel = 'initial';
312
+ if (parentHash) {
313
+ if (parentHash === autoTipTrim) diffBaseLabel = 'auto-backup';
314
+ else if (parentHash === snapTipTrim) diffBaseLabel = 'snapshot';
315
+ else diffBaseLabel = 'other';
316
+ }
317
+ const scopeTrailer = narrowProtect ? 'narrow' : 'full';
318
+ const guardBlock = `Guard-Diff-Base: ${diffBaseLabel}\nGuard-Scope: ${scopeTrailer}${isBookmarkCommit ? '\nGuard-Bookmark: true' : ''}`;
319
+ msg = msg.includes('\n\n') ? `${msg}\n${guardBlock}` : `${msg}\n\n${guardBlock}`;
320
+
321
+ const commitArgs = parentHash
322
+ ? ['commit-tree', newTree, '-p', parentHash, '-m', msg]
323
+ : ['commit-tree', newTree, '-m', msg];
324
+ const commitHash = execFileSync('git', commitArgs, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
325
+
326
+ if (!commitHash) {
327
+ return { status: 'error', error: 'commit-tree returned empty hash' };
328
+ }
329
+
330
+ git(['update-ref', branchRef, commitHash], { cwd });
331
+
332
+ const lsOut = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
333
+ const fileCount = lsOut ? lsOut.split('\n').filter(Boolean).length : 0;
334
+
335
+ return {
336
+ status: 'created',
337
+ commitHash,
338
+ shortHash: commitHash.substring(0, 7),
339
+ fileCount,
340
+ changedCount,
341
+ changedFiles,
342
+ incrementalSummary,
343
+ secretsExcluded: secretsExcluded.length > 0 ? secretsExcluded : undefined,
344
+ ...(isBookmarkCommit ? { bookmark: true } : {}),
345
+ };
346
+ } catch (e) {
347
+ return { status: 'error', error: e.message };
348
+ } finally {
349
+ try { fs.unlinkSync(guardIndex); } catch { /* ignore */ }
350
+ try { fs.unlinkSync(guardIndexLock); } catch { /* ignore */ }
351
+ }
352
+ }
353
+
354
+ // ── Shadow copy ─────────────────────────────────────────────────
355
+
356
+ /**
357
+ * Create a shadow (file) copy of the project.
358
+ *
359
+ * @param {string} projectDir
360
+ * @param {object} cfg - Loaded config
361
+ * @param {object} [opts]
362
+ * @param {string} [opts.backupDir] - Override backup directory (default: projectDir/.cursor-guard-backup)
363
+ * @returns {{ status: 'created'|'empty'|'error', timestamp?: string, fileCount?: number, snapshotDir?: string, error?: string }}
364
+ */
365
+ function findPreviousSnapshot(backupDir) {
366
+ try {
367
+ const entries = fs.readdirSync(backupDir)
368
+ .filter(e => /^\d{8}_\d{6}/.test(e))
369
+ .sort()
370
+ .reverse();
371
+ for (const e of entries) {
372
+ const full = path.join(backupDir, e);
373
+ if (fs.statSync(full).isDirectory()) return full;
374
+ }
375
+ } catch { /* no previous snapshots */ }
376
+ return null;
377
+ }
378
+
379
+ function createShadowCopy(projectDir, cfg, opts = {}) {
380
+ const backupDir = opts.backupDir || path.join(projectDir, '.cursor-guard-backup');
381
+ let ts = formatTimestamp(new Date());
382
+ let snapDir = path.join(backupDir, ts);
383
+
384
+ try {
385
+ if (fs.existsSync(snapDir)) {
386
+ const baseTs = ts;
387
+ let seq = new Date().getMilliseconds();
388
+ for (let i = 0; i < 1000 && fs.existsSync(snapDir); i++, seq++) {
389
+ ts = `${baseTs}_${String(seq % 1000).padStart(3, '0')}`;
390
+ snapDir = path.join(backupDir, ts);
391
+ }
392
+ }
393
+ const prevSnapDir = findPreviousSnapshot(backupDir);
394
+
395
+ fs.mkdirSync(snapDir, { recursive: true });
396
+
397
+ const allFiles = walkDir(projectDir, projectDir);
398
+ const files = filterFiles(allFiles, cfg);
399
+
400
+ let copied = 0;
401
+ let linked = 0;
402
+ for (const f of files) {
403
+ const dest = path.join(snapDir, f.rel);
404
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
405
+ try {
406
+ let didLink = false;
407
+ if (prevSnapDir) {
408
+ const prevFile = path.join(prevSnapDir, f.rel);
409
+ try {
410
+ const srcStat = fs.statSync(f.full);
411
+ const prevStat = fs.statSync(prevFile);
412
+ if (srcStat.size === prevStat.size && Math.abs(srcStat.mtimeMs - prevStat.mtimeMs) < 1) {
413
+ fs.linkSync(prevFile, dest);
414
+ didLink = true;
415
+ linked++;
416
+ }
417
+ } catch { /* prev file missing or stat error — fall through to copy */ }
418
+ }
419
+ if (!didLink) {
420
+ fs.copyFileSync(f.full, dest);
421
+ try {
422
+ const srcStat = fs.statSync(f.full);
423
+ fs.utimesSync(dest, srcStat.atime, srcStat.mtime);
424
+ } catch { /* non-critical: mtime preservation failed */ }
425
+ }
426
+ copied++;
427
+ } catch { /* skip unreadable */ }
428
+ }
429
+
430
+ if (copied === 0) {
431
+ fs.rmSync(snapDir, { recursive: true, force: true });
432
+ return { status: 'empty', timestamp: ts };
433
+ }
434
+
435
+ return { status: 'created', timestamp: ts, fileCount: copied, linkedCount: linked, snapshotDir: snapDir };
436
+ } catch (e) {
437
+ return { status: 'error', error: e.message };
438
+ }
439
+ }
440
+
441
+ module.exports = { createGitSnapshot, createShadowCopy, formatTimestamp, removeSecretsFromIndex, trailerScalar };