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.
- package/README.md +697 -697
- package/README.zh-CN.md +696 -696
- package/ROADMAP.md +1775 -1720
- package/SKILL.md +631 -629
- package/docs/RELEASE.md +197 -196
- package/docs/SNAPSHOT-BOOKMARK.md +47 -0
- package/package.json +70 -69
- package/references/dashboard/public/app.js +2079 -1832
- package/references/dashboard/public/style.css +1660 -1573
- package/references/dashboard/server.js +197 -4
- package/references/lib/core/backups.js +509 -492
- package/references/lib/core/core.test.js +1761 -1616
- package/references/lib/core/snapshot.js +441 -369
- package/references/mcp/mcp.test.js +381 -362
- package/references/mcp/server.js +404 -347
- package/references/vscode-extension/dist/{cursor-guard-ide-4.9.9.vsix → cursor-guard-ide-4.9.15.vsix} +0 -0
- package/references/vscode-extension/dist/dashboard/public/app.js +2079 -1832
- package/references/vscode-extension/dist/dashboard/public/style.css +1660 -1573
- package/references/vscode-extension/dist/dashboard/server.js +197 -4
- package/references/vscode-extension/dist/extension.js +780 -704
- package/references/vscode-extension/dist/guard-version.json +1 -1
- package/references/vscode-extension/dist/lib/auto-setup.js +201 -192
- package/references/vscode-extension/dist/lib/core/backups.js +509 -492
- package/references/vscode-extension/dist/lib/core/snapshot.js +441 -369
- package/references/vscode-extension/dist/lib/poller.js +161 -21
- package/references/vscode-extension/dist/lib/sidebar-webview.js +22 -0
- package/references/vscode-extension/dist/mcp/server.js +152 -35
- package/references/vscode-extension/dist/package.json +7 -1
- package/references/vscode-extension/dist/skill/ROADMAP.md +1775 -1720
- package/references/vscode-extension/dist/skill/SKILL.md +631 -629
- package/references/vscode-extension/extension.js +780 -704
- package/references/vscode-extension/lib/auto-setup.js +201 -192
- package/references/vscode-extension/lib/poller.js +161 -21
- package/references/vscode-extension/lib/sidebar-webview.js +22 -0
- 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
function
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
.
|
|
298
|
-
.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 };
|