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.
- package/README.md +36 -23
- package/README.zh-CN.md +36 -23
- package/ROADMAP.md +309 -103
- package/SKILL.md +20 -6
- package/package.json +2 -1
- package/references/bin/cursor-guard-init.js +120 -0
- package/references/config-reference.md +40 -0
- package/references/config-reference.zh-CN.md +40 -0
- package/references/cursor-guard.example.json +6 -0
- package/references/cursor-guard.schema.json +30 -0
- package/references/lib/auto-backup.js +66 -8
- package/references/lib/core/anomaly.js +217 -0
- package/references/lib/core/backups.js +9 -9
- package/references/lib/core/core.test.js +600 -0
- package/references/lib/core/dashboard.js +208 -0
- package/references/lib/core/doctor-fix.js +1 -1
- package/references/lib/core/doctor.js +26 -8
- package/references/lib/core/restore.js +78 -23
- package/references/lib/core/snapshot.js +45 -19
- package/references/lib/core/status.js +4 -4
- package/references/lib/utils.js +72 -5
- package/references/mcp/mcp.test.js +98 -3
- package/references/mcp/server.js +74 -12
- package/references/quickstart.zh-CN.md +23 -1
|
@@ -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[
|
|
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}
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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}
|
|
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
|
|
92
|
-
const
|
|
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-${
|
|
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 (
|
|
173
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
265
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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}
|
|
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
|
|
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:
|
|
139
|
+
commitCount: countOutput ? countOutput.split('\n').filter(Boolean).length : 0,
|
|
140
140
|
};
|
|
141
141
|
}
|
|
142
142
|
|