cursor-guard 2.1.0 → 3.0.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,305 @@
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, loadConfig,
8
+ } = require('../utils');
9
+ const { createGitSnapshot, formatTimestamp, removeSecretsFromIndex } = require('./snapshot');
10
+
11
+ // ── Path safety ─────────────────────────────────────────────────
12
+
13
+ function validateRelativePath(file) {
14
+ const normalized = path.normalize(file).replace(/\\/g, '/');
15
+ if (path.isAbsolute(normalized) || normalized.startsWith('..')) {
16
+ return { valid: false, error: 'file path must be relative and within project directory' };
17
+ }
18
+ return { valid: true, normalized };
19
+ }
20
+
21
+ const VALID_SHADOW_SOURCE = /^\d{8}_\d{6}$|^pre-restore-\d{8}_\d{6}$/;
22
+
23
+ function validateShadowSource(source) {
24
+ if (!VALID_SHADOW_SOURCE.test(source)) {
25
+ return { valid: false };
26
+ }
27
+ return { valid: true };
28
+ }
29
+
30
+ /**
31
+ * Determine whether to create a pre-restore snapshot.
32
+ * Priority: explicit opts.preserveCurrent > config pre_restore_backup > default true.
33
+ */
34
+ function resolvePreserve(projectDir, opts) {
35
+ if (typeof opts.preserveCurrent === 'boolean') return opts.preserveCurrent;
36
+ const { cfg } = loadConfig(projectDir);
37
+ if (cfg.pre_restore_backup === 'never') return false;
38
+ return true;
39
+ }
40
+
41
+ // ── Restore file ────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Restore a single file from a backup source.
45
+ *
46
+ * @param {string} projectDir
47
+ * @param {string} file - Relative path to the file
48
+ * @param {string} source - Commit hash, ref name, or shadow timestamp
49
+ * @param {object} [opts]
50
+ * @param {boolean} [opts.preserveCurrent=true] - Snapshot current state before restoring
51
+ * @returns {{ status: 'restored'|'error', preRestoreRef?: string, preRestoreShortHash?: string, restoredFrom: string, sourceType?: 'git'|'shadow', error?: string }}
52
+ */
53
+ function restoreFile(projectDir, file, source, opts = {}) {
54
+ const pathCheck = validateRelativePath(file);
55
+ if (!pathCheck.valid) {
56
+ return { status: 'error', restoredFrom: source, error: pathCheck.error };
57
+ }
58
+
59
+ const preserveCurrent = resolvePreserve(projectDir, opts);
60
+ const repo = isGitRepo(projectDir);
61
+ const result = { restoredFrom: source };
62
+
63
+ // Determine source type — shadow source must be a valid timestamp directory name
64
+ const shadowCheck = validateShadowSource(source);
65
+ const shadowDir = shadowCheck.valid
66
+ ? path.join(projectDir, '.cursor-guard-backup', source, pathCheck.normalized)
67
+ : null;
68
+ const isShadowSource = shadowDir && fs.existsSync(shadowDir);
69
+
70
+ if (!isShadowSource && !repo) {
71
+ return { status: 'error', restoredFrom: source, error: 'not a git repo and source is not a shadow copy timestamp' };
72
+ }
73
+
74
+ // Pre-restore snapshot (git path)
75
+ if (preserveCurrent && repo) {
76
+ const preRestoreResult = createPreRestoreSnapshot(projectDir, file);
77
+ if (preRestoreResult.status === 'created') {
78
+ result.preRestoreRef = preRestoreResult.ref;
79
+ result.preRestoreShortHash = preRestoreResult.shortHash;
80
+ } else if (preRestoreResult.status === 'error') {
81
+ return { status: 'error', restoredFrom: source, error: `pre-restore snapshot failed: ${preRestoreResult.error}` };
82
+ }
83
+ // 'skipped' (no changes) is fine, proceed
84
+ }
85
+
86
+ // Pre-restore shadow copy (non-git path)
87
+ if (preserveCurrent && !repo) {
88
+ const targetFile = path.join(projectDir, file);
89
+ if (fs.existsSync(targetFile)) {
90
+ try {
91
+ const ts = formatTimestamp(new Date());
92
+ const preRestoreDir = path.join(projectDir, '.cursor-guard-backup', `pre-restore-${ts}`);
93
+ fs.mkdirSync(path.join(preRestoreDir, path.dirname(file)), { recursive: true });
94
+ fs.copyFileSync(targetFile, path.join(preRestoreDir, file));
95
+ result.preRestoreShadow = `pre-restore-${ts}`;
96
+ } catch (e) {
97
+ return { status: 'error', restoredFrom: source, error: `pre-restore shadow copy failed: ${e.message}` };
98
+ }
99
+ }
100
+ }
101
+
102
+ // Restore from shadow copy
103
+ if (isShadowSource) {
104
+ try {
105
+ const dest = path.join(projectDir, file);
106
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
107
+ fs.copyFileSync(shadowDir, dest);
108
+ result.status = 'restored';
109
+ result.sourceType = 'shadow';
110
+ return result;
111
+ } catch (e) {
112
+ return { status: 'error', restoredFrom: source, error: e.message };
113
+ }
114
+ }
115
+
116
+ // Restore from git
117
+ try {
118
+ // Verify the source ref/hash is valid
119
+ const resolved = git(['rev-parse', '--verify', source], { cwd: projectDir, allowFail: true });
120
+ if (!resolved) {
121
+ return { status: 'error', restoredFrom: source, error: `cannot resolve git source: ${source}` };
122
+ }
123
+
124
+ // Check that the file exists in the source
125
+ const fileExists = git(['cat-file', '-e', `${resolved}:${file}`], { cwd: projectDir, allowFail: true });
126
+ if (fileExists === null) {
127
+ // cat-file -e returns empty on success with allowFail, null on error
128
+ // Try ls-tree instead
129
+ const lsOut = git(['ls-tree', resolved, '--', file], { cwd: projectDir, allowFail: true });
130
+ if (!lsOut) {
131
+ return { status: 'error', restoredFrom: source, error: `file '${file}' not found in source ${source}` };
132
+ }
133
+ }
134
+
135
+ execFileSync('git', ['restore', `--source=${resolved}`, '--', file], {
136
+ cwd: projectDir, stdio: 'pipe',
137
+ });
138
+
139
+ result.status = 'restored';
140
+ result.sourceType = 'git';
141
+ return result;
142
+ } catch (e) {
143
+ return { status: 'error', restoredFrom: source, error: e.message };
144
+ }
145
+ }
146
+
147
+ // ── Restore project (preview only for V3.0) ─────────────────────
148
+
149
+ /**
150
+ * Preview which files would be affected by a full project restore.
151
+ *
152
+ * @param {string} projectDir
153
+ * @param {string} source - Commit hash or ref
154
+ * @returns {{ status: 'ok'|'error', files?: Array<{path: string, change: 'modified'|'added'|'deleted'}>, totalChanged?: number, error?: string }}
155
+ */
156
+ function previewProjectRestore(projectDir, source) {
157
+ if (!isGitRepo(projectDir)) {
158
+ return { status: 'error', error: 'not a git repository' };
159
+ }
160
+
161
+ try {
162
+ const resolved = git(['rev-parse', '--verify', source], { cwd: projectDir, allowFail: true });
163
+ if (!resolved) {
164
+ return { status: 'error', error: `cannot resolve git source: ${source}` };
165
+ }
166
+
167
+ const diffOutput = git(
168
+ ['diff', '--name-status', resolved],
169
+ { cwd: projectDir, allowFail: true }
170
+ );
171
+
172
+ if (!diffOutput) {
173
+ return { status: 'ok', files: [], totalChanged: 0 };
174
+ }
175
+
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 });
185
+ }
186
+
187
+ return { status: 'ok', files, totalChanged: files.length };
188
+ } catch (e) {
189
+ return { status: 'error', error: e.message };
190
+ }
191
+ }
192
+
193
+ // ── Execute project restore ─────────────────────────────────────
194
+
195
+ /**
196
+ * Execute a full project restore to a given source commit.
197
+ * Creates a pre-restore snapshot first (unless opted out), then
198
+ * restores all changed files.
199
+ *
200
+ * @param {string} projectDir
201
+ * @param {string} source - Commit hash or ref
202
+ * @param {object} [opts]
203
+ * @param {boolean} [opts.preserveCurrent=true]
204
+ * @returns {{ status: 'restored'|'error', preRestoreRef?: string, preRestoreShortHash?: string, filesRestored: number, files?: Array<{path: string, change: string}>, error?: string }}
205
+ */
206
+ function executeProjectRestore(projectDir, source, opts = {}) {
207
+ const preserveCurrent = resolvePreserve(projectDir, opts);
208
+
209
+ if (!isGitRepo(projectDir)) {
210
+ return { status: 'error', filesRestored: 0, error: 'not a git repository' };
211
+ }
212
+
213
+ const resolved = git(['rev-parse', '--verify', source], { cwd: projectDir, allowFail: true });
214
+ if (!resolved) {
215
+ return { status: 'error', filesRestored: 0, error: `cannot resolve git source: ${source}` };
216
+ }
217
+
218
+ const preview = previewProjectRestore(projectDir, source);
219
+ if (preview.status === 'error') {
220
+ return { status: 'error', filesRestored: 0, error: preview.error };
221
+ }
222
+ if (preview.totalChanged === 0) {
223
+ return { status: 'restored', filesRestored: 0, files: [], preRestoreRef: null };
224
+ }
225
+
226
+ const result = { filesRestored: 0, files: preview.files };
227
+
228
+ if (preserveCurrent) {
229
+ const snap = createPreRestoreSnapshot(projectDir, null);
230
+ if (snap.status === 'created') {
231
+ result.preRestoreRef = snap.ref;
232
+ result.preRestoreShortHash = snap.shortHash;
233
+ } else if (snap.status === 'error') {
234
+ return { status: 'error', filesRestored: 0, error: `pre-restore snapshot failed: ${snap.error}` };
235
+ }
236
+ }
237
+
238
+ try {
239
+ execFileSync('git', ['restore', `--source=${resolved}`, '--', '.'], {
240
+ cwd: projectDir, stdio: 'pipe',
241
+ });
242
+ result.status = 'restored';
243
+ result.filesRestored = preview.totalChanged;
244
+ return result;
245
+ } catch (e) {
246
+ return { status: 'error', filesRestored: 0, error: e.message };
247
+ }
248
+ }
249
+
250
+ // ── Pre-restore snapshot helper ─────────────────────────────────
251
+
252
+ /**
253
+ * Create a pre-restore snapshot on refs/guard/pre-restore/<timestamp>.
254
+ * Uses temp index so the user's staging area is never touched.
255
+ *
256
+ * @param {string} projectDir
257
+ * @param {string} [scope] - Specific file to check for changes, or null for all
258
+ * @returns {{ status: 'created'|'skipped'|'error', ref?: string, shortHash?: string, error?: string }}
259
+ */
260
+ function createPreRestoreSnapshot(projectDir, scope) {
261
+ const gDir = getGitDir(projectDir);
262
+ if (!gDir) return { status: 'error', error: 'not a git repository' };
263
+
264
+ const ts = formatTimestamp(new Date());
265
+ const ref = `refs/guard/pre-restore/${ts}`;
266
+ const guardIdx = path.join(gDir, 'guard-pre-restore-index');
267
+ const env = { ...process.env, GIT_INDEX_FILE: guardIdx };
268
+ const cwd = projectDir;
269
+
270
+ try { fs.unlinkSync(guardIdx); } catch { /* doesn't exist */ }
271
+
272
+ try {
273
+ const head = git(['rev-parse', 'HEAD'], { cwd, allowFail: true });
274
+ if (!head) return { status: 'skipped', reason: 'no HEAD commit' };
275
+
276
+ execFileSync('git', ['read-tree', 'HEAD'], { cwd, env, stdio: 'pipe' });
277
+ execFileSync('git', ['add', '-A'], { cwd, env, stdio: 'pipe' });
278
+
279
+ const { cfg } = loadConfig(projectDir);
280
+ removeSecretsFromIndex(cfg.secrets_patterns, cwd, env);
281
+
282
+ const tree = execFileSync('git', ['write-tree'], { cwd, env, stdio: 'pipe', encoding: 'utf-8' }).trim();
283
+ const headTree = git(['rev-parse', 'HEAD^{tree}'], { cwd, allowFail: true });
284
+
285
+ if (tree === headTree) {
286
+ return { status: 'skipped', reason: 'no changes to preserve' };
287
+ }
288
+
289
+ const commitHash = execFileSync('git', [
290
+ 'commit-tree', tree, '-p', head, '-m', `guard: pre-restore snapshot ${ts}`,
291
+ ], { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
292
+
293
+ if (!commitHash) return { status: 'error', error: 'commit-tree returned empty' };
294
+
295
+ git(['update-ref', ref, commitHash], { cwd });
296
+
297
+ return { status: 'created', ref, shortHash: commitHash.substring(0, 7) };
298
+ } catch (e) {
299
+ return { status: 'error', error: e.message };
300
+ } finally {
301
+ try { fs.unlinkSync(guardIdx); } catch { /* ignore */ }
302
+ }
303
+ }
304
+
305
+ module.exports = { restoreFile, previewProjectRestore, executeProjectRestore, createPreRestoreSnapshot, validateRelativePath, validateShadowSource };
@@ -0,0 +1,173 @@
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 removeSecretsFromIndex(secretsPatterns, cwd, env) {
18
+ let files;
19
+ try {
20
+ const out = execFileSync('git', ['ls-files', '--cached'], {
21
+ cwd, env, stdio: 'pipe', encoding: 'utf-8',
22
+ }).trim();
23
+ files = out ? out.split('\n').filter(Boolean) : [];
24
+ } catch { return []; }
25
+
26
+ const excluded = [];
27
+ for (const f of files) {
28
+ const leaf = path.basename(f);
29
+ if (matchesAny(secretsPatterns, f) || matchesAny(secretsPatterns, leaf)) {
30
+ try {
31
+ execFileSync('git', ['rm', '--cached', '--ignore-unmatch', '-q', '--', f], {
32
+ cwd, env, stdio: 'pipe',
33
+ });
34
+ } catch { /* ignore */ }
35
+ excluded.push(f);
36
+ }
37
+ }
38
+ return excluded;
39
+ }
40
+
41
+ // ── Git snapshot ────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Create a git snapshot commit on a dedicated ref using plumbing commands.
45
+ * Does not touch the user's index or branch.
46
+ *
47
+ * @param {string} projectDir
48
+ * @param {object} cfg - Loaded config
49
+ * @param {object} [opts]
50
+ * @param {string} [opts.branchRef='refs/guard/auto-backup']
51
+ * @param {string} [opts.message] - Commit message (auto-generated if omitted)
52
+ * @returns {{ status: 'created'|'skipped'|'error', commitHash?: string, shortHash?: string, fileCount?: number, reason?: string, error?: string, secretsExcluded?: string[] }}
53
+ */
54
+ function createGitSnapshot(projectDir, cfg, opts = {}) {
55
+ const branchRef = opts.branchRef || 'refs/guard/auto-backup';
56
+ const cwd = projectDir;
57
+ const gDir = getGitDir(projectDir);
58
+ if (!gDir) return { status: 'error', error: 'not a git repository' };
59
+
60
+ const guardIndex = path.join(gDir, 'cursor-guard-index');
61
+ const env = { ...process.env, GIT_INDEX_FILE: guardIndex };
62
+
63
+ try { fs.unlinkSync(guardIndex); } catch { /* doesn't exist */ }
64
+
65
+ try {
66
+ 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
+
71
+ if (cfg.protect.length > 0) {
72
+ for (const p of cfg.protect) {
73
+ execFileSync('git', ['add', '--', p], { cwd, env, stdio: 'pipe' });
74
+ }
75
+ } else {
76
+ execFileSync('git', ['add', '-A'], { cwd, env, stdio: 'pipe' });
77
+ }
78
+
79
+ for (const ig of cfg.ignore) {
80
+ execFileSync('git', ['rm', '--cached', '--ignore-unmatch', '-rq', '--', ig], { cwd, env, stdio: 'pipe' });
81
+ }
82
+
83
+ const secretsExcluded = removeSecretsFromIndex(cfg.secrets_patterns, cwd, env);
84
+
85
+ const newTree = execFileSync('git', ['write-tree'], { cwd, env, stdio: 'pipe', encoding: 'utf-8' }).trim();
86
+ const parentTree = parentHash
87
+ ? git(['rev-parse', `${branchRef}^{tree}`], { cwd, allowFail: true })
88
+ : null;
89
+
90
+ if (newTree === parentTree) {
91
+ return { status: 'skipped', reason: 'tree unchanged' };
92
+ }
93
+
94
+ const ts = formatTimestamp(new Date());
95
+ const msg = opts.message || `guard: auto-backup ${ts}`;
96
+ const commitArgs = parentHash
97
+ ? ['commit-tree', newTree, '-p', parentHash, '-m', msg]
98
+ : ['commit-tree', newTree, '-m', msg];
99
+ const commitHash = execFileSync('git', commitArgs, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
100
+
101
+ if (!commitHash) {
102
+ return { status: 'error', error: 'commit-tree returned empty hash' };
103
+ }
104
+
105
+ git(['update-ref', branchRef, commitHash], { cwd });
106
+
107
+ let fileCount = 0;
108
+ if (parentTree) {
109
+ 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;
114
+ }
115
+
116
+ return {
117
+ status: 'created',
118
+ commitHash,
119
+ shortHash: commitHash.substring(0, 7),
120
+ fileCount,
121
+ secretsExcluded: secretsExcluded.length > 0 ? secretsExcluded : undefined,
122
+ };
123
+ } catch (e) {
124
+ return { status: 'error', error: e.message };
125
+ } finally {
126
+ try { fs.unlinkSync(guardIndex); } catch { /* ignore */ }
127
+ }
128
+ }
129
+
130
+ // ── Shadow copy ─────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Create a shadow (file) copy of the project.
134
+ *
135
+ * @param {string} projectDir
136
+ * @param {object} cfg - Loaded config
137
+ * @param {object} [opts]
138
+ * @param {string} [opts.backupDir] - Override backup directory (default: projectDir/.cursor-guard-backup)
139
+ * @returns {{ status: 'created'|'empty'|'error', timestamp?: string, fileCount?: number, snapshotDir?: string, error?: string }}
140
+ */
141
+ function createShadowCopy(projectDir, cfg, opts = {}) {
142
+ const backupDir = opts.backupDir || path.join(projectDir, '.cursor-guard-backup');
143
+ const ts = formatTimestamp(new Date());
144
+ const snapDir = path.join(backupDir, ts);
145
+
146
+ try {
147
+ fs.mkdirSync(snapDir, { recursive: true });
148
+
149
+ const allFiles = walkDir(projectDir, projectDir);
150
+ const files = filterFiles(allFiles, cfg);
151
+
152
+ let copied = 0;
153
+ for (const f of files) {
154
+ const dest = path.join(snapDir, f.rel);
155
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
156
+ try {
157
+ fs.copyFileSync(f.full, dest);
158
+ copied++;
159
+ } catch { /* skip unreadable */ }
160
+ }
161
+
162
+ if (copied === 0) {
163
+ fs.rmSync(snapDir, { recursive: true, force: true });
164
+ return { status: 'empty', timestamp: ts };
165
+ }
166
+
167
+ return { status: 'created', timestamp: ts, fileCount: copied, snapshotDir: snapDir };
168
+ } catch (e) {
169
+ return { status: 'error', error: e.message };
170
+ }
171
+ }
172
+
173
+ module.exports = { createGitSnapshot, createShadowCopy, formatTimestamp, removeSecretsFromIndex };
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const {
6
+ loadConfig, gitAvailable, git, isGitRepo, gitDir: getGitDir, diskFreeGB,
7
+ } = require('../utils');
8
+
9
+ /**
10
+ * Gather comprehensive backup system status.
11
+ *
12
+ * @param {string} projectDir
13
+ * @returns {{
14
+ * watcher: { running: boolean, pid?: number, startedAt?: string, lockFile?: string, stale?: boolean },
15
+ * config: { loaded: boolean, strategy: string, interval: number, retention: object, gitRetention: object, error?: string },
16
+ * lastBackup: { git?: { ref: string, hash: string, shortHash: string, timestamp: string, message: string }, shadow?: { timestamp: string, path: string, fileCount: number } },
17
+ * refs: { snapshot?: string, autoBackup?: { hash: string, commitCount: number }, preRestoreCount: number },
18
+ * disk: { freeGB: number|null, warning?: string },
19
+ * }}
20
+ */
21
+ function getBackupStatus(projectDir) {
22
+ const hasGit = gitAvailable();
23
+ const repo = hasGit && isGitRepo(projectDir);
24
+ const gDir = repo ? getGitDir(projectDir) : null;
25
+ const backupDir = path.join(projectDir, '.cursor-guard-backup');
26
+
27
+ // ── Watcher status ────────────────────────────────────────────
28
+ const lockFile = gDir
29
+ ? path.join(gDir, 'cursor-guard.lock')
30
+ : path.join(backupDir, 'cursor-guard.lock');
31
+
32
+ const watcher = { running: false };
33
+
34
+ if (fs.existsSync(lockFile)) {
35
+ watcher.lockFile = lockFile;
36
+ try {
37
+ const content = fs.readFileSync(lockFile, 'utf-8').trim();
38
+ const pidMatch = content.match(/pid=(\d+)/);
39
+ const startedMatch = content.match(/started=(.+)/);
40
+
41
+ if (pidMatch) {
42
+ const pid = parseInt(pidMatch[1], 10);
43
+ watcher.pid = pid;
44
+ try {
45
+ process.kill(pid, 0);
46
+ watcher.running = true;
47
+ } catch {
48
+ watcher.running = false;
49
+ watcher.stale = true;
50
+ }
51
+ }
52
+ if (startedMatch) {
53
+ watcher.startedAt = startedMatch[1].trim();
54
+ }
55
+ } catch { /* unreadable lock */ }
56
+ }
57
+
58
+ // ── Config ────────────────────────────────────────────────────
59
+ const { cfg, loaded, error } = loadConfig(projectDir);
60
+ const config = {
61
+ loaded,
62
+ strategy: cfg.backup_strategy,
63
+ interval: cfg.auto_backup_interval_seconds || 60,
64
+ retention: cfg.retention,
65
+ gitRetention: cfg.git_retention,
66
+ };
67
+ if (error) config.error = error;
68
+
69
+ // ── Last backup ───────────────────────────────────────────────
70
+ const lastBackup = {};
71
+
72
+ if (repo) {
73
+ const autoRef = 'refs/guard/auto-backup';
74
+ const autoExists = git(['rev-parse', '--verify', autoRef], { cwd: projectDir, allowFail: true });
75
+ if (autoExists) {
76
+ const logLine = git(
77
+ ['log', autoRef, '--format=%H %aI %s', '-1'],
78
+ { cwd: projectDir, allowFail: true }
79
+ );
80
+ if (logLine) {
81
+ const firstSpace = logLine.indexOf(' ');
82
+ const secondSpace = logLine.indexOf(' ', firstSpace + 1);
83
+ const hash = logLine.substring(0, firstSpace);
84
+ const timestamp = logLine.substring(firstSpace + 1, secondSpace);
85
+ const message = logLine.substring(secondSpace + 1);
86
+ lastBackup.git = {
87
+ ref: autoRef,
88
+ hash,
89
+ shortHash: hash.substring(0, 7),
90
+ timestamp,
91
+ message,
92
+ };
93
+ }
94
+ }
95
+ }
96
+
97
+ if (fs.existsSync(backupDir)) {
98
+ try {
99
+ const dirs = fs.readdirSync(backupDir, { withFileTypes: true })
100
+ .filter(d => d.isDirectory() && /^\d{8}_\d{6}$/.test(d.name))
101
+ .sort((a, b) => b.name.localeCompare(a.name));
102
+
103
+ if (dirs.length > 0) {
104
+ const latest = dirs[0].name;
105
+ const latestPath = path.join(backupDir, latest);
106
+ let fileCount = 0;
107
+ try {
108
+ const countFiles = (dir) => {
109
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
110
+ if (entry.isDirectory()) countFiles(path.join(dir, entry.name));
111
+ else fileCount++;
112
+ }
113
+ };
114
+ countFiles(latestPath);
115
+ } catch { /* ignore */ }
116
+
117
+ lastBackup.shadow = {
118
+ timestamp: latest,
119
+ path: latestPath,
120
+ fileCount,
121
+ };
122
+ }
123
+ } catch { /* ignore */ }
124
+ }
125
+
126
+ // ── Guard refs ────────────────────────────────────────────────
127
+ const refs = { preRestoreCount: 0 };
128
+
129
+ if (repo) {
130
+ const snapshotHash = git(['rev-parse', '--verify', 'refs/guard/snapshot'], { cwd: projectDir, allowFail: true });
131
+ if (snapshotHash) refs.snapshot = snapshotHash.substring(0, 7);
132
+
133
+ const autoRef = 'refs/guard/auto-backup';
134
+ const autoHash = git(['rev-parse', '--verify', autoRef], { cwd: projectDir, allowFail: true });
135
+ if (autoHash) {
136
+ const count = git(['rev-list', '--count', autoRef], { cwd: projectDir, allowFail: true });
137
+ refs.autoBackup = {
138
+ hash: autoHash.substring(0, 7),
139
+ commitCount: count ? parseInt(count, 10) : 0,
140
+ };
141
+ }
142
+
143
+ const preRestoreRefs = git(
144
+ ['for-each-ref', 'refs/guard/pre-restore/', '--format=%(refname)'],
145
+ { cwd: projectDir, allowFail: true }
146
+ );
147
+ if (preRestoreRefs) {
148
+ refs.preRestoreCount = preRestoreRefs.split('\n').filter(Boolean).length;
149
+ }
150
+ }
151
+
152
+ // ── Disk ──────────────────────────────────────────────────────
153
+ const freeGB = diskFreeGB(projectDir);
154
+ const disk = { freeGB };
155
+ if (freeGB !== null) {
156
+ if (freeGB < 1) disk.warning = 'critically low';
157
+ else if (freeGB < 5) disk.warning = 'low';
158
+ }
159
+
160
+ return { watcher, config, lastBackup, refs, disk };
161
+ }
162
+
163
+ module.exports = { getBackupStatus };