cursor-guard 3.4.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const {
6
+ loadConfig, gitAvailable, git, isGitRepo, gitDir: getGitDir,
7
+ diskFreeGB, walkDir, filterFiles,
8
+ } = require('../utils');
9
+ const { getBackupStatus } = require('./status');
10
+ const { loadActiveAlert } = require('./anomaly');
11
+ const { parseShadowTimestamp } = require('./backups');
12
+
13
+ // ── Helpers ─────────────────────────────────────────────────────
14
+
15
+ function dirSizeBytes(dirPath) {
16
+ let total = 0;
17
+ const stack = [dirPath];
18
+ while (stack.length > 0) {
19
+ const current = stack.pop();
20
+ let entries;
21
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); }
22
+ catch { continue; }
23
+ for (const entry of entries) {
24
+ const full = path.join(current, entry.name);
25
+ if (entry.isDirectory()) {
26
+ stack.push(full);
27
+ } else if (entry.isFile()) {
28
+ try { total += fs.statSync(full).size; } catch { /* skip */ }
29
+ }
30
+ }
31
+ }
32
+ return total;
33
+ }
34
+
35
+ function formatBytes(bytes) {
36
+ if (bytes < 1024) return `${bytes}B`;
37
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
38
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
39
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
40
+ }
41
+
42
+ function relativeTime(isoTimestamp) {
43
+ if (!isoTimestamp) return null;
44
+ const ms = Date.now() - new Date(isoTimestamp).getTime();
45
+ if (ms < 0) return 'just now';
46
+ const sec = Math.floor(ms / 1000);
47
+ if (sec < 60) return `${sec}s ago`;
48
+ const min = Math.floor(sec / 60);
49
+ if (min < 60) return `${min}m ago`;
50
+ const hr = Math.floor(min / 60);
51
+ if (hr < 24) return `${hr}h ago`;
52
+ const d = Math.floor(hr / 24);
53
+ return `${d}d ago`;
54
+ }
55
+
56
+ // ── Dashboard ───────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Build a comprehensive backup health dashboard.
60
+ *
61
+ * @param {string} projectDir
62
+ * @returns {{
63
+ * strategy: string,
64
+ * lastBackup: { git?: { timestamp: string, relativeTime: string }, shadow?: { timestamp: string, relativeTime: string } },
65
+ * counts: { git: { commits: number }, shadow: { snapshots: number } },
66
+ * diskUsage: { git: { bytes: number, display: string }, shadow: { bytes: number, display: string } },
67
+ * protectionScope: { protect: string[], ignore: string[], fileCount: number },
68
+ * health: { status: string, issues: string[] },
69
+ * alerts: { active: boolean, latest?: object },
70
+ * watcher: object,
71
+ * disk: object,
72
+ * }}
73
+ */
74
+ function getDashboard(projectDir) {
75
+ const status = getBackupStatus(projectDir);
76
+ const { cfg } = loadConfig(projectDir);
77
+ const hasGit = gitAvailable();
78
+ const repo = hasGit && isGitRepo(projectDir);
79
+ const gDir = repo ? getGitDir(projectDir) : null;
80
+ const backupDir = path.join(projectDir, '.cursor-guard-backup');
81
+
82
+ // ── Strategy ────────────────────────────────────────────────
83
+ const strategy = cfg.backup_strategy;
84
+
85
+ // ── Last backup with relative time ──────────────────────────
86
+ const lastBackup = {};
87
+ if (status.lastBackup.git) {
88
+ lastBackup.git = {
89
+ timestamp: status.lastBackup.git.timestamp,
90
+ relativeTime: relativeTime(status.lastBackup.git.timestamp),
91
+ shortHash: status.lastBackup.git.shortHash,
92
+ };
93
+ }
94
+ if (status.lastBackup.shadow) {
95
+ const ts = status.lastBackup.shadow.timestamp;
96
+ const parsed = parseShadowTimestamp(ts);
97
+ const isoTs = parsed ? parsed.toISOString() : ts;
98
+ lastBackup.shadow = {
99
+ timestamp: ts,
100
+ relativeTime: relativeTime(isoTs),
101
+ };
102
+ }
103
+
104
+ // ── Counts ──────────────────────────────────────────────────
105
+ const counts = {
106
+ git: { commits: status.refs.autoBackup ? status.refs.autoBackup.commitCount : 0 },
107
+ shadow: { snapshots: 0 },
108
+ };
109
+
110
+ if (fs.existsSync(backupDir)) {
111
+ try {
112
+ counts.shadow.snapshots = fs.readdirSync(backupDir, { withFileTypes: true })
113
+ .filter(d => d.isDirectory() && /^\d{8}_\d{6}(_\d{3})?$/.test(d.name))
114
+ .length;
115
+ } catch { /* ignore */ }
116
+ }
117
+
118
+ // ── Disk usage breakdown ────────────────────────────────────
119
+ const diskUsage = {
120
+ git: { bytes: 0, display: '0B' },
121
+ shadow: { bytes: 0, display: '0B' },
122
+ };
123
+
124
+ if (repo && gDir) {
125
+ const objectsDir = path.join(gDir, 'objects');
126
+ if (fs.existsSync(objectsDir)) {
127
+ diskUsage.git.bytes = dirSizeBytes(objectsDir);
128
+ diskUsage.git.display = formatBytes(diskUsage.git.bytes);
129
+ }
130
+ }
131
+
132
+ if (fs.existsSync(backupDir)) {
133
+ diskUsage.shadow.bytes = dirSizeBytes(backupDir);
134
+ diskUsage.shadow.display = formatBytes(diskUsage.shadow.bytes);
135
+ }
136
+
137
+ // ── Protection scope ────────────────────────────────────────
138
+ const protectionScope = {
139
+ protect: cfg.protect.length > 0 ? cfg.protect : ['**'],
140
+ ignore: cfg.ignore,
141
+ fileCount: 0,
142
+ };
143
+
144
+ try {
145
+ const allFiles = walkDir(projectDir, projectDir);
146
+ protectionScope.fileCount = filterFiles(allFiles, cfg).length;
147
+ } catch { /* ignore */ }
148
+
149
+ // ── Health assessment ───────────────────────────────────────
150
+ const issues = [];
151
+
152
+ if (!status.watcher.running) {
153
+ if (status.watcher.stale) {
154
+ issues.push('Watcher has a stale lock file (process not running)');
155
+ } else {
156
+ issues.push('Auto-backup watcher is not running');
157
+ }
158
+ }
159
+
160
+ if (strategy === 'git' || strategy === 'both') {
161
+ if (!repo) issues.push('Strategy requires Git but directory is not a git repo');
162
+ else if (!status.refs.autoBackup) issues.push('No auto-backup ref found — watcher may not have run yet');
163
+ }
164
+
165
+ if (status.disk.warning === 'critically low') {
166
+ issues.push(`Disk space critically low (${status.disk.freeGB} GB free)`);
167
+ } else if (status.disk.warning === 'low') {
168
+ issues.push(`Disk space low (${status.disk.freeGB} GB free)`);
169
+ }
170
+
171
+ if (status.lastBackup.git) {
172
+ const lastTs = new Date(status.lastBackup.git.timestamp).getTime();
173
+ const staleMinutes = (Date.now() - lastTs) / 60000;
174
+ const staleThreshold = Math.min(cfg.auto_backup_interval_seconds * 5 / 60, 30);
175
+ if (staleMinutes > staleThreshold) {
176
+ issues.push(`Last git backup is stale (${relativeTime(status.lastBackup.git.timestamp)})`);
177
+ }
178
+ }
179
+
180
+ let healthStatus = 'healthy';
181
+ if (issues.length > 0) healthStatus = 'warning';
182
+ if (issues.some(i => i.includes('critically') || i.includes('requires Git'))) healthStatus = 'critical';
183
+
184
+ // ── Active alerts ───────────────────────────────────────────
185
+ const activeAlert = loadActiveAlert(projectDir);
186
+ const alerts = {
187
+ active: !!activeAlert,
188
+ latest: activeAlert || undefined,
189
+ };
190
+ if (activeAlert) {
191
+ if (healthStatus === 'healthy') healthStatus = 'warning';
192
+ issues.push(`Active alert: ${activeAlert.type} — ${activeAlert.fileCount} files in ${activeAlert.windowSeconds}s`);
193
+ }
194
+
195
+ return {
196
+ strategy,
197
+ lastBackup,
198
+ counts,
199
+ diskUsage,
200
+ protectionScope,
201
+ health: { status: healthStatus, issues },
202
+ alerts,
203
+ watcher: status.watcher,
204
+ disk: status.disk,
205
+ };
206
+ }
207
+
208
+ module.exports = { getDashboard, dirSizeBytes, formatBytes, relativeTime };
@@ -183,7 +183,7 @@ function runFixes(projectDir, opts = {}) {
183
183
  let stale = false;
184
184
  try {
185
185
  const content = fs.readFileSync(lockFile, 'utf-8').trim();
186
- const pidMatch = content.match(/pid[:\s]+(\d+)/i);
186
+ const pidMatch = content.match(/pid[=:\s]+(\d+)/i);
187
187
  if (pidMatch) {
188
188
  const pid = parseInt(pidMatch[1], 10);
189
189
  try { process.kill(pid, 0); } catch { stale = true; }
@@ -111,7 +111,7 @@ function runDiagnostics(projectDir) {
111
111
  let totalBytes = 0;
112
112
  try {
113
113
  const dirs = fs.readdirSync(backupDir, { withFileTypes: true })
114
- .filter(d => d.isDirectory() && (/^\d{8}_\d{6}$/.test(d.name) || d.name.startsWith('pre-restore-')));
114
+ .filter(d => d.isDirectory() && (/^\d{8}_\d{6}(_\d{3})?$/.test(d.name) || d.name.startsWith('pre-restore-')));
115
115
  snapCount = dirs.length;
116
116
  } catch { /* ignore */ }
117
117
  try {
@@ -217,17 +217,35 @@ function runDiagnostics(projectDir) {
217
217
 
218
218
  let mcpSdkAvailable = false;
219
219
  let mcpSdkVersion = null;
220
- try {
221
- const mcpPkgPath = require.resolve('@modelcontextprotocol/sdk/package.json');
222
- const mcpPkg = JSON.parse(fs.readFileSync(mcpPkgPath, 'utf-8'));
223
- mcpSdkAvailable = true;
224
- mcpSdkVersion = mcpPkg.version;
225
- } catch { /* not installed */ }
220
+ // Try resolving SDK from the skill package's own node_modules first,
221
+ // then fall back to the running process's require paths.
222
+ const skillRoot = path.resolve(__dirname, '../../..');
223
+ const sdkCandidates = [
224
+ path.join(skillRoot, 'node_modules', '@modelcontextprotocol', 'sdk', 'package.json'),
225
+ ];
226
+ for (const candidate of sdkCandidates) {
227
+ try {
228
+ if (fs.existsSync(candidate)) {
229
+ const mcpPkg = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
230
+ mcpSdkAvailable = true;
231
+ mcpSdkVersion = mcpPkg.version;
232
+ break;
233
+ }
234
+ } catch { /* ignore */ }
235
+ }
236
+ if (!mcpSdkAvailable) {
237
+ try {
238
+ const mcpPkgPath = require.resolve('@modelcontextprotocol/sdk/package.json');
239
+ const mcpPkg = JSON.parse(fs.readFileSync(mcpPkgPath, 'utf-8'));
240
+ mcpSdkAvailable = true;
241
+ mcpSdkVersion = mcpPkg.version;
242
+ } catch { /* not installed */ }
243
+ }
226
244
 
227
245
  if (mcpServerExists && mcpSdkAvailable) {
228
246
  check('MCP server', 'PASS', `server.js found, SDK ${mcpSdkVersion}`);
229
247
  } else if (mcpServerExists && !mcpSdkAvailable) {
230
- check('MCP server', 'WARN', 'server.js found but @modelcontextprotocol/sdk not installed — run npm install');
248
+ check('MCP server', 'WARN', 'server.js found but @modelcontextprotocol/sdk not installed — run: cd <skill-dir> && npm install');
231
249
  } else if (!mcpServerExists && mcpSdkAvailable) {
232
250
  check('MCP server', 'WARN', `SDK installed (${mcpSdkVersion}) but server.js not found at expected path`);
233
251
  } else {
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { execFileSync } = require('child_process');
6
6
  const {
7
- git, isGitRepo, gitDir: getGitDir, loadConfig,
7
+ git, isGitRepo, gitDir: getGitDir, loadConfig, unquoteGitPath,
8
8
  } = require('../utils');
9
9
  const { createGitSnapshot, formatTimestamp, removeSecretsFromIndex } = require('./snapshot');
10
10
 
@@ -18,7 +18,7 @@ function validateRelativePath(file) {
18
18
  return { valid: true, normalized };
19
19
  }
20
20
 
21
- const VALID_SHADOW_SOURCE = /^\d{8}_\d{6}$|^pre-restore-\d{8}_\d{6}$/;
21
+ const VALID_SHADOW_SOURCE = /^\d{8}_\d{6}(_\d{3})?$|^pre-restore-\d{8}_\d{6}(_\d{3})?$/;
22
22
 
23
23
  function validateShadowSource(source) {
24
24
  if (!VALID_SHADOW_SOURCE.test(source)) {
@@ -88,11 +88,20 @@ function restoreFile(projectDir, file, source, opts = {}) {
88
88
  const targetFile = path.join(projectDir, file);
89
89
  if (fs.existsSync(targetFile)) {
90
90
  try {
91
- const ts = formatTimestamp(new Date());
92
- const preRestoreDir = path.join(projectDir, '.cursor-guard-backup', `pre-restore-${ts}`);
91
+ const preNow = new Date();
92
+ const preBaseTs = formatTimestamp(preNow);
93
+ let preTs = preBaseTs;
94
+ let preRestoreDir = path.join(projectDir, '.cursor-guard-backup', `pre-restore-${preTs}`);
95
+ if (fs.existsSync(preRestoreDir)) {
96
+ let seq = preNow.getMilliseconds();
97
+ for (let i = 0; i < 1000 && fs.existsSync(preRestoreDir); i++, seq++) {
98
+ preTs = `${preBaseTs}_${String(seq % 1000).padStart(3, '0')}`;
99
+ preRestoreDir = path.join(projectDir, '.cursor-guard-backup', `pre-restore-${preTs}`);
100
+ }
101
+ }
93
102
  fs.mkdirSync(path.join(preRestoreDir, path.dirname(file)), { recursive: true });
94
103
  fs.copyFileSync(targetFile, path.join(preRestoreDir, file));
95
- result.preRestoreShadow = `pre-restore-${ts}`;
104
+ result.preRestoreShadow = `pre-restore-${preTs}`;
96
105
  } catch (e) {
97
106
  return { status: 'error', restoredFrom: source, error: `pre-restore shadow copy failed: ${e.message}` };
98
107
  }
@@ -164,24 +173,39 @@ function previewProjectRestore(projectDir, source) {
164
173
  return { status: 'error', error: `cannot resolve git source: ${source}` };
165
174
  }
166
175
 
176
+ const files = [];
177
+
167
178
  const diffOutput = git(
168
179
  ['diff', '--name-status', resolved],
169
180
  { cwd: projectDir, allowFail: true }
170
181
  );
171
182
 
172
- if (!diffOutput) {
173
- return { status: 'ok', files: [], totalChanged: 0 };
183
+ if (diffOutput) {
184
+ for (const line of diffOutput.split('\n').filter(Boolean)) {
185
+ const parts = line.split('\t');
186
+ const code = parts[0].trim();
187
+ if (code.startsWith('R') || code.startsWith('C')) {
188
+ const oldPath = unquoteGitPath(parts[1] || '');
189
+ const newPath = unquoteGitPath(parts[2] || '');
190
+ files.push({ path: newPath, oldPath, change: code.startsWith('R') ? 'renamed' : 'copied' });
191
+ } else {
192
+ const filePath = unquoteGitPath(parts[1] || '');
193
+ let change = 'modified';
194
+ if (code === 'A') change = 'added';
195
+ else if (code === 'D') change = 'deleted';
196
+ files.push({ path: filePath, change });
197
+ }
198
+ }
174
199
  }
175
200
 
176
- const files = [];
177
- for (const line of diffOutput.split('\n').filter(Boolean)) {
178
- const tab = line.indexOf('\t');
179
- const code = line.substring(0, tab).trim();
180
- const filePath = line.substring(tab + 1).trim();
181
- let change = 'modified';
182
- if (code === 'A') change = 'added';
183
- else if (code === 'D') change = 'deleted';
184
- files.push({ path: filePath, change });
201
+ const untrackedOutput = git(
202
+ ['ls-files', '--others', '--exclude-standard'],
203
+ { cwd: projectDir, allowFail: true }
204
+ );
205
+ if (untrackedOutput) {
206
+ for (const f of untrackedOutput.split('\n').filter(Boolean)) {
207
+ files.push({ path: unquoteGitPath(f), change: 'untracked' });
208
+ }
185
209
  }
186
210
 
187
211
  return { status: 'ok', files, totalChanged: files.length };
@@ -195,16 +219,18 @@ function previewProjectRestore(projectDir, source) {
195
219
  /**
196
220
  * Execute a full project restore to a given source commit.
197
221
  * Creates a pre-restore snapshot first (unless opted out), then
198
- * restores all changed files.
222
+ * restores all tracked files and optionally removes untracked files.
199
223
  *
200
224
  * @param {string} projectDir
201
225
  * @param {string} source - Commit hash or ref
202
226
  * @param {object} [opts]
203
227
  * @param {boolean} [opts.preserveCurrent=true]
204
- * @returns {{ status: 'restored'|'error', preRestoreRef?: string, preRestoreShortHash?: string, filesRestored: number, files?: Array<{path: string, change: string}>, error?: string }}
228
+ * @param {boolean} [opts.cleanUntracked=true] - Remove untracked non-ignored files after restore
229
+ * @returns {{ status: 'restored'|'error', preRestoreRef?: string, preRestoreShortHash?: string, filesRestored: number, untrackedCleaned?: number, files?: Array<{path: string, change: string}>, error?: string }}
205
230
  */
206
231
  function executeProjectRestore(projectDir, source, opts = {}) {
207
232
  const preserveCurrent = resolvePreserve(projectDir, opts);
233
+ const cleanUntracked = opts.cleanUntracked !== false;
208
234
 
209
235
  if (!isGitRepo(projectDir)) {
210
236
  return { status: 'error', filesRestored: 0, error: 'not a git repository' };
@@ -219,11 +245,14 @@ function executeProjectRestore(projectDir, source, opts = {}) {
219
245
  if (preview.status === 'error') {
220
246
  return { status: 'error', filesRestored: 0, error: preview.error };
221
247
  }
222
- if (preview.totalChanged === 0) {
248
+ const trackedFiles = preview.files.filter(f => f.change !== 'untracked');
249
+ const effectiveFiles = cleanUntracked ? preview.files : trackedFiles;
250
+
251
+ if (effectiveFiles.length === 0) {
223
252
  return { status: 'restored', filesRestored: 0, files: [], preRestoreRef: null };
224
253
  }
225
254
 
226
- const result = { filesRestored: 0, files: preview.files };
255
+ const result = { filesRestored: 0, files: effectiveFiles };
227
256
 
228
257
  if (preserveCurrent) {
229
258
  const snap = createPreRestoreSnapshot(projectDir, null);
@@ -239,8 +268,27 @@ function executeProjectRestore(projectDir, source, opts = {}) {
239
268
  execFileSync('git', ['restore', `--source=${resolved}`, '--', '.'], {
240
269
  cwd: projectDir, stdio: 'pipe',
241
270
  });
271
+
272
+ let untrackedCleaned = 0;
273
+ if (cleanUntracked) {
274
+ const untrackedOutput = git(
275
+ ['ls-files', '--others', '--exclude-standard'],
276
+ { cwd: projectDir, allowFail: true }
277
+ );
278
+ if (untrackedOutput) {
279
+ for (const raw of untrackedOutput.split('\n').filter(Boolean)) {
280
+ const f = unquoteGitPath(raw);
281
+ try {
282
+ fs.unlinkSync(path.join(projectDir, f));
283
+ untrackedCleaned++;
284
+ } catch { /* skip files that can't be removed */ }
285
+ }
286
+ }
287
+ }
288
+
242
289
  result.status = 'restored';
243
- result.filesRestored = preview.totalChanged;
290
+ result.filesRestored = trackedFiles.length;
291
+ result.untrackedCleaned = untrackedCleaned;
244
292
  return result;
245
293
  } catch (e) {
246
294
  return { status: 'error', filesRestored: 0, error: e.message };
@@ -261,8 +309,15 @@ function createPreRestoreSnapshot(projectDir, scope) {
261
309
  const gDir = getGitDir(projectDir);
262
310
  if (!gDir) return { status: 'error', error: 'not a git repository' };
263
311
 
264
- const ts = formatTimestamp(new Date());
265
- const ref = `refs/guard/pre-restore/${ts}`;
312
+ const now = new Date();
313
+ const baseTs = formatTimestamp(now);
314
+ let seq = now.getMilliseconds();
315
+ let ts, ref;
316
+ for (let i = 0; i < 1000; i++, seq++) {
317
+ ts = `${baseTs}_${String(seq % 1000).padStart(3, '0')}`;
318
+ ref = `refs/guard/pre-restore/${ts}`;
319
+ if (!git(['rev-parse', '--verify', ref], { cwd: projectDir, allowFail: true })) break;
320
+ }
266
321
  const guardIdx = path.join(gDir, 'guard-pre-restore-index');
267
322
  const env = { ...process.env, GIT_INDEX_FILE: guardIdx };
268
323
  const cwd = projectDir;
@@ -14,14 +14,28 @@ function formatTimestamp(d) {
14
14
  return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
15
15
  }
16
16
 
17
- function removeSecretsFromIndex(secretsPatterns, cwd, env) {
18
- let files;
17
+ function listIndexFiles(cwd, env) {
19
18
  try {
20
19
  const out = execFileSync('git', ['ls-files', '--cached'], {
21
20
  cwd, env, stdio: 'pipe', encoding: 'utf-8',
22
21
  }).trim();
23
- files = out ? out.split('\n').filter(Boolean) : [];
22
+ return out ? out.split('\n').filter(Boolean) : [];
24
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);
25
39
 
26
40
  const excluded = [];
27
41
  for (const f of files) {
@@ -64,21 +78,24 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
64
78
 
65
79
  try {
66
80
  const parentHash = git(['rev-parse', '--verify', branchRef], { cwd, allowFail: true });
67
- if (parentHash) {
68
- execFileSync('git', ['read-tree', branchRef], { cwd, env, stdio: 'pipe' });
69
- }
70
81
 
71
82
  if (cfg.protect.length > 0) {
72
- for (const p of cfg.protect) {
73
- execFileSync('git', ['add', '--', p], { cwd, env, stdio: 'pipe' });
74
- }
83
+ // Add everything then prune — 'git add -- <pattern>' treats bare names as
84
+ // root-relative pathspecs, but matchesAny() also checks basenames (e.g.
85
+ // "settings.json" matches "src/settings.json"). Pruning via matchesAny
86
+ // keeps the semantics consistent with filterFiles().
87
+ execFileSync('git', ['add', '-A'], { cwd, env, stdio: 'pipe' });
88
+ pruneIndexFiles(cwd, env, f => !matchesAny(cfg.protect, f));
75
89
  } else {
90
+ if (parentHash) {
91
+ execFileSync('git', ['read-tree', branchRef], { cwd, env, stdio: 'pipe' });
92
+ }
76
93
  execFileSync('git', ['add', '-A'], { cwd, env, stdio: 'pipe' });
77
94
  }
78
95
 
79
- for (const ig of cfg.ignore) {
80
- execFileSync('git', ['rm', '--cached', '--ignore-unmatch', '-rq', '--', ig], { cwd, env, stdio: 'pipe' });
81
- }
96
+ // Keep ignore semantics aligned with filterFiles()/matchesAny(), including
97
+ // basename-only patterns like "settings.json" for nested files.
98
+ pruneIndexFiles(cwd, env, f => matchesAny(cfg.ignore, f));
82
99
 
83
100
  const secretsExcluded = removeSecretsFromIndex(cfg.secrets_patterns, cwd, env);
84
101
 
@@ -104,13 +121,13 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
104
121
 
105
122
  git(['update-ref', branchRef, commitHash], { cwd });
106
123
 
107
- let fileCount = 0;
124
+ const lsOut = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
125
+ const fileCount = lsOut ? lsOut.split('\n').filter(Boolean).length : 0;
126
+
127
+ let changedCount;
108
128
  if (parentTree) {
109
129
  const diff = git(['diff-tree', '--no-commit-id', '--name-only', '-r', parentTree, newTree], { cwd, allowFail: true });
110
- fileCount = diff ? diff.split('\n').filter(Boolean).length : 0;
111
- } else {
112
- const all = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
113
- fileCount = all ? all.split('\n').filter(Boolean).length : 0;
130
+ changedCount = diff ? diff.split('\n').filter(Boolean).length : 0;
114
131
  }
115
132
 
116
133
  return {
@@ -118,6 +135,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
118
135
  commitHash,
119
136
  shortHash: commitHash.substring(0, 7),
120
137
  fileCount,
138
+ changedCount,
121
139
  secretsExcluded: secretsExcluded.length > 0 ? secretsExcluded : undefined,
122
140
  };
123
141
  } catch (e) {
@@ -140,10 +158,18 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
140
158
  */
141
159
  function createShadowCopy(projectDir, cfg, opts = {}) {
142
160
  const backupDir = opts.backupDir || path.join(projectDir, '.cursor-guard-backup');
143
- const ts = formatTimestamp(new Date());
144
- const snapDir = path.join(backupDir, ts);
161
+ let ts = formatTimestamp(new Date());
162
+ let snapDir = path.join(backupDir, ts);
145
163
 
146
164
  try {
165
+ if (fs.existsSync(snapDir)) {
166
+ const baseTs = ts;
167
+ let seq = new Date().getMilliseconds();
168
+ for (let i = 0; i < 1000 && fs.existsSync(snapDir); i++, seq++) {
169
+ ts = `${baseTs}_${String(seq % 1000).padStart(3, '0')}`;
170
+ snapDir = path.join(backupDir, ts);
171
+ }
172
+ }
147
173
  fs.mkdirSync(snapDir, { recursive: true });
148
174
 
149
175
  const allFiles = walkDir(projectDir, projectDir);
@@ -74,7 +74,7 @@ function getBackupStatus(projectDir) {
74
74
  const autoExists = git(['rev-parse', '--verify', autoRef], { cwd: projectDir, allowFail: true });
75
75
  if (autoExists) {
76
76
  const logLine = git(
77
- ['log', autoRef, '--format=%H %aI %s', '-1'],
77
+ ['log', autoRef, '--format=%H %aI %s', '-1', '--grep=^guard:'],
78
78
  { cwd: projectDir, allowFail: true }
79
79
  );
80
80
  if (logLine) {
@@ -97,7 +97,7 @@ function getBackupStatus(projectDir) {
97
97
  if (fs.existsSync(backupDir)) {
98
98
  try {
99
99
  const dirs = fs.readdirSync(backupDir, { withFileTypes: true })
100
- .filter(d => d.isDirectory() && /^\d{8}_\d{6}$/.test(d.name))
100
+ .filter(d => d.isDirectory() && /^\d{8}_\d{6}(_\d{3})?$/.test(d.name))
101
101
  .sort((a, b) => b.name.localeCompare(a.name));
102
102
 
103
103
  if (dirs.length > 0) {
@@ -133,10 +133,10 @@ function getBackupStatus(projectDir) {
133
133
  const autoRef = 'refs/guard/auto-backup';
134
134
  const autoHash = git(['rev-parse', '--verify', autoRef], { cwd: projectDir, allowFail: true });
135
135
  if (autoHash) {
136
- const count = git(['rev-list', '--count', autoRef], { cwd: projectDir, allowFail: true });
136
+ const countOutput = git(['log', autoRef, '--grep=^guard:', '--format=%H'], { cwd: projectDir, allowFail: true });
137
137
  refs.autoBackup = {
138
138
  hash: autoHash.substring(0, 7),
139
- commitCount: count ? parseInt(count, 10) : 0,
139
+ commitCount: countOutput ? countOutput.split('\n').filter(Boolean).length : 0,
140
140
  };
141
141
  }
142
142