cursor-guard 4.4.1 → 4.5.4

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.
@@ -349,6 +349,19 @@ main {
349
349
  max-width: 400px;
350
350
  line-height: 1.4;
351
351
  }
352
+ .summary-restore-ctx {
353
+ font-size: 12px;
354
+ color: var(--amber);
355
+ background: var(--amber-bg);
356
+ padding: 4px 10px;
357
+ border-radius: 4px;
358
+ border-left: 3px solid var(--amber);
359
+ overflow: hidden;
360
+ text-overflow: ellipsis;
361
+ white-space: nowrap;
362
+ max-width: 400px;
363
+ line-height: 1.4;
364
+ }
352
365
  .summary-message {
353
366
  font-size: 12px;
354
367
  color: var(--text-primary);
@@ -375,6 +388,25 @@ main {
375
388
  }
376
389
  .summary-detail-line:first-child { padding-top: 2px; }
377
390
 
391
+ .summary-collapsed {
392
+ display: none;
393
+ }
394
+ .summary-expanded .summary-collapsed {
395
+ display: block;
396
+ }
397
+ .summary-toggle-btn {
398
+ background: none;
399
+ border: none;
400
+ color: var(--blue);
401
+ font-size: 11px;
402
+ font-weight: 600;
403
+ cursor: pointer;
404
+ padding: 2px 0;
405
+ transition: color var(--transition);
406
+ }
407
+ .summary-toggle-btn:hover { color: var(--text-heading); }
408
+ .summary-expanded .summary-toggle-btn { display: none; }
409
+
378
410
  .summary-pre {
379
411
  font-size: 12px;
380
412
  line-height: 1.6;
@@ -386,6 +418,30 @@ main {
386
418
  border-left: 3px solid var(--blue);
387
419
  }
388
420
 
421
+ /* ── Summary inline file rows ────────────────────────────── */
422
+
423
+ .summary-file-row {
424
+ display: flex;
425
+ align-items: center;
426
+ gap: 6px;
427
+ padding: 1px 0;
428
+ font-size: 11px;
429
+ line-height: 1.4;
430
+ }
431
+ .summary-file-path {
432
+ flex: 1;
433
+ overflow: hidden;
434
+ text-overflow: ellipsis;
435
+ white-space: nowrap;
436
+ max-width: 220px;
437
+ font-size: 11px;
438
+ color: var(--text-primary);
439
+ }
440
+ .summary-file-more {
441
+ padding: 2px 0;
442
+ font-style: italic;
443
+ }
444
+
389
445
  /* ── Stats Row ────────────────────────────────────────────── */
390
446
 
391
447
  .stats-row {
@@ -717,6 +773,58 @@ main {
717
773
  line-height: 1.6;
718
774
  }
719
775
 
776
+ /* ── Drawer: Files Table ──────────────────────────────────── */
777
+
778
+ .drawer-files-container {
779
+ margin-top: 6px;
780
+ max-height: 340px;
781
+ overflow: auto;
782
+ border: 1px solid var(--border-subtle);
783
+ border-radius: var(--radius-sm);
784
+ }
785
+ .drawer-files-loading {
786
+ padding: 16px;
787
+ text-align: center;
788
+ }
789
+ .drawer-files-table {
790
+ width: 100%;
791
+ border-collapse: collapse;
792
+ font-size: 12px;
793
+ }
794
+ .drawer-files-table th {
795
+ text-align: left;
796
+ padding: 8px 12px;
797
+ font-size: 10px;
798
+ font-weight: 700;
799
+ text-transform: uppercase;
800
+ letter-spacing: .06em;
801
+ color: var(--text-tertiary);
802
+ border-bottom: 1px solid var(--border-subtle);
803
+ background: var(--bg-elevated);
804
+ position: sticky;
805
+ top: 0;
806
+ z-index: 1;
807
+ }
808
+ .drawer-sort-header { cursor: pointer; transition: color var(--transition); }
809
+ .drawer-sort-header:hover { color: var(--blue); }
810
+ .drawer-files-table td {
811
+ padding: 5px 12px;
812
+ border-bottom: 1px solid rgba(71,85,105,.15);
813
+ vertical-align: middle;
814
+ }
815
+ .drawer-file-path {
816
+ max-width: 280px;
817
+ overflow: hidden;
818
+ text-overflow: ellipsis;
819
+ white-space: nowrap;
820
+ color: var(--text-primary);
821
+ }
822
+ .drawer-file-changes {
823
+ white-space: nowrap;
824
+ font-variant-numeric: tabular-nums;
825
+ color: var(--text-secondary);
826
+ }
827
+
720
828
  /* ── Drawer: Doctor Checks ────────────────────────────────── */
721
829
 
722
830
  .check-item {
@@ -850,6 +958,236 @@ main {
850
958
  }
851
959
  .skeleton-table::after { width: 75%; opacity: .6; }
852
960
 
961
+ /* ── Alert Card Details ───────────────────────────────────── */
962
+
963
+ .alert-details {
964
+ margin-top: 10px;
965
+ display: flex;
966
+ flex-direction: column;
967
+ gap: 6px;
968
+ }
969
+ .alert-detail-row {
970
+ display: flex;
971
+ align-items: center;
972
+ gap: 8px;
973
+ font-size: 12px;
974
+ color: var(--text-secondary);
975
+ }
976
+ .alert-detail-label {
977
+ font-weight: 600;
978
+ color: var(--text-tertiary);
979
+ min-width: 72px;
980
+ font-size: 10px;
981
+ text-transform: uppercase;
982
+ letter-spacing: .06em;
983
+ }
984
+ .alert-countdown {
985
+ color: var(--yellow);
986
+ font-weight: 700;
987
+ font-variant-numeric: tabular-nums;
988
+ }
989
+ .alert-numbers {
990
+ margin-top: 4px;
991
+ font-size: 13px;
992
+ font-weight: 600;
993
+ color: var(--yellow);
994
+ background: var(--yellow-bg);
995
+ padding: 6px 12px;
996
+ border-radius: var(--radius-sm);
997
+ border-left: 3px solid var(--yellow);
998
+ }
999
+
1000
+ .alert-history-toggle-wrap {
1001
+ margin-top: 8px;
1002
+ }
1003
+ .alert-history-toggle-btn {
1004
+ background: none;
1005
+ border: none;
1006
+ color: var(--text-tertiary);
1007
+ font-size: 11px;
1008
+ cursor: pointer;
1009
+ padding: 2px 0;
1010
+ transition: color var(--transition);
1011
+ }
1012
+ .alert-history-toggle-btn:hover { color: var(--blue); }
1013
+
1014
+ .alert-history {
1015
+ margin-top: 8px;
1016
+ border-top: 1px solid var(--border-subtle);
1017
+ padding-top: 8px;
1018
+ }
1019
+ .alert-history.alert-history-collapsed { display: none; }
1020
+ .alert-history-label {
1021
+ font-size: 10px;
1022
+ font-weight: 700;
1023
+ text-transform: uppercase;
1024
+ letter-spacing: .06em;
1025
+ color: var(--text-tertiary);
1026
+ margin-bottom: 6px;
1027
+ }
1028
+ .alert-history-row {
1029
+ display: flex;
1030
+ align-items: center;
1031
+ gap: 8px;
1032
+ padding: 3px 0;
1033
+ font-size: 11px;
1034
+ }
1035
+ .badge-expired {
1036
+ background: var(--gray-bg);
1037
+ color: var(--gray);
1038
+ border-color: rgba(100,116,139,.18);
1039
+ font-size: 9px;
1040
+ padding: 1px 6px;
1041
+ }
1042
+
1043
+ /* ── Alert Files Table ───────────────────────────────────── */
1044
+
1045
+ .alert-files-section {
1046
+ margin-top: 10px;
1047
+ border-top: 1px solid var(--border-subtle);
1048
+ padding-top: 8px;
1049
+ }
1050
+ .alert-files-toggle {
1051
+ background: none;
1052
+ border: none;
1053
+ color: var(--blue);
1054
+ font-size: 11px;
1055
+ font-weight: 600;
1056
+ cursor: pointer;
1057
+ padding: 2px 0;
1058
+ transition: color var(--transition);
1059
+ }
1060
+ .alert-files-toggle:hover { color: var(--text-heading); }
1061
+
1062
+ .alert-files-table-wrap {
1063
+ margin-top: 8px;
1064
+ max-height: 220px;
1065
+ overflow: auto;
1066
+ border: 1px solid var(--border-subtle);
1067
+ border-radius: var(--radius-sm);
1068
+ }
1069
+ .alert-files-hidden { display: none; }
1070
+
1071
+ .alert-files-table {
1072
+ width: 100%;
1073
+ border-collapse: collapse;
1074
+ font-size: 11px;
1075
+ }
1076
+ .alert-files-table th {
1077
+ text-align: left;
1078
+ padding: 6px 10px;
1079
+ font-size: 9px;
1080
+ font-weight: 700;
1081
+ text-transform: uppercase;
1082
+ letter-spacing: .06em;
1083
+ color: var(--text-tertiary);
1084
+ border-bottom: 1px solid var(--border-subtle);
1085
+ background: var(--bg-elevated);
1086
+ position: sticky;
1087
+ top: 0;
1088
+ z-index: 1;
1089
+ }
1090
+ .alert-files-table td {
1091
+ padding: 4px 10px;
1092
+ border-bottom: 1px solid rgba(71,85,105,.15);
1093
+ vertical-align: middle;
1094
+ }
1095
+ .alert-file-path {
1096
+ max-width: 240px;
1097
+ overflow: hidden;
1098
+ text-overflow: ellipsis;
1099
+ white-space: nowrap;
1100
+ color: var(--text-primary);
1101
+ }
1102
+ .alert-file-changes {
1103
+ white-space: nowrap;
1104
+ font-variant-numeric: tabular-nums;
1105
+ color: var(--text-secondary);
1106
+ }
1107
+
1108
+ .alert-action-badge {
1109
+ display: inline-block;
1110
+ padding: 1px 6px;
1111
+ border-radius: 8px;
1112
+ font-size: 9px;
1113
+ font-weight: 600;
1114
+ letter-spacing: .02em;
1115
+ }
1116
+ .alert-action-modified { background: var(--blue-bg); color: var(--blue); }
1117
+ .alert-action-added { background: var(--green-bg); color: var(--green); }
1118
+ .alert-action-deleted { background: var(--red-bg); color: var(--red); }
1119
+ .alert-action-renamed { background: var(--purple-bg); color: var(--purple); }
1120
+
1121
+ /* ── File Search ─────────────────────────────────────────── */
1122
+
1123
+ .file-search-wrap {
1124
+ margin-bottom: 12px;
1125
+ }
1126
+ .file-search-input {
1127
+ width: 100%;
1128
+ max-width: 360px;
1129
+ background: var(--bg-secondary);
1130
+ color: var(--text-primary);
1131
+ border: 1px solid var(--border);
1132
+ border-radius: var(--radius-sm);
1133
+ padding: 8px 14px;
1134
+ font-size: 13px;
1135
+ font-family: inherit;
1136
+ transition: border-color var(--transition);
1137
+ }
1138
+ .file-search-input::placeholder { color: var(--text-tertiary); }
1139
+ .file-search-input:focus {
1140
+ outline: none;
1141
+ border-color: var(--blue);
1142
+ box-shadow: 0 0 0 3px var(--blue-bg);
1143
+ }
1144
+
1145
+ /* ── Filter Count ────────────────────────────────────────── */
1146
+
1147
+ .filter-count {
1148
+ font-size: 10px;
1149
+ opacity: .65;
1150
+ font-weight: 400;
1151
+ }
1152
+
1153
+ /* ── Restore Command Section ─────────────────────────────── */
1154
+
1155
+ .restore-cmd-section {
1156
+ margin-top: 18px;
1157
+ padding-top: 16px;
1158
+ border-top: 1px solid var(--border);
1159
+ }
1160
+ .restore-cmd-label {
1161
+ font-size: 10px;
1162
+ font-weight: 700;
1163
+ text-transform: uppercase;
1164
+ letter-spacing: .08em;
1165
+ color: var(--text-tertiary);
1166
+ margin-bottom: 10px;
1167
+ }
1168
+ .restore-cmd-row {
1169
+ display: flex;
1170
+ align-items: center;
1171
+ gap: 8px;
1172
+ margin-bottom: 8px;
1173
+ }
1174
+ .restore-cmd-code {
1175
+ flex: 1;
1176
+ font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
1177
+ font-size: 11px;
1178
+ background: var(--bg-primary);
1179
+ border: 1px solid var(--border-subtle);
1180
+ border-radius: var(--radius-sm);
1181
+ padding: 8px 12px;
1182
+ color: var(--green);
1183
+ word-break: break-all;
1184
+ line-height: 1.5;
1185
+ }
1186
+ .btn-restore-cmd {
1187
+ flex-shrink: 0;
1188
+ white-space: nowrap;
1189
+ }
1190
+
853
1191
  /* ── Utility ──────────────────────────────────────────────── */
854
1192
 
855
1193
  .hidden { display: none !important; }
@@ -8,7 +8,7 @@ const path = require('path');
8
8
 
9
9
  const { getDashboard } = require('../lib/core/dashboard');
10
10
  const { runDiagnostics } = require('../lib/core/doctor');
11
- const { listBackups } = require('../lib/core/backups');
11
+ const { listBackups, getBackupFiles } = require('../lib/core/backups');
12
12
 
13
13
  const PUBLIC_DIR = path.join(__dirname, 'public');
14
14
  const DEFAULT_PORT = 3120;
@@ -171,6 +171,13 @@ function handleApi(pathname, query, registry, res) {
171
171
  }
172
172
  }
173
173
 
174
+ if (pathname === '/api/backup-files') {
175
+ const hash = query.get('hash');
176
+ if (!hash) return json(res, { error: 'Missing hash parameter' }, 400);
177
+ try { return json(res, getBackupFiles(pp, hash)); }
178
+ catch (e) { return json(res, { error: e.message }, 500); }
179
+ }
180
+
174
181
  if (pathname === '/api/doctor') {
175
182
  try { return json(res, runDiagnostics(pp)); }
176
183
  catch (e) { return json(res, { error: e.message }, 500); }
@@ -2,11 +2,10 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { execFileSync } = require('child_process');
6
5
  const {
7
6
  color, loadConfig, gitAvailable, git, isGitRepo, gitDir: getGitDir,
8
7
  walkDir, filterFiles, buildManifest, loadManifest, saveManifest,
9
- manifestChanged, createLogger, unquoteGitPath,
8
+ manifestChanged, createLogger,
10
9
  } = require('./utils');
11
10
  const { createGitSnapshot, createShadowCopy } = require('./core/snapshot');
12
11
  const { cleanShadowRetention, cleanGitRetention } = require('./core/backups');
@@ -235,33 +234,9 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
235
234
  }
236
235
  if (!hasChanges) continue;
237
236
 
238
- // V4: Record change event and check for anomalies
237
+ // Shadow: pre-compute changed file count from manifest diff (already accurate)
239
238
  let changedFileCount = 0;
240
- let porcelain = '';
241
- if (repo) {
242
- // Use execFileSync directly — git() helper's trim() strips leading spaces
243
- // from porcelain output, corrupting the first line when it starts with ' '.
244
- try {
245
- porcelain = execFileSync('git', ['status', '--porcelain'], {
246
- cwd: projectDir, stdio: 'pipe', encoding: 'utf-8',
247
- });
248
- } catch { /* ignore */ }
249
- if (porcelain) {
250
- const lines = porcelain.split('\n').filter(Boolean);
251
- if (cfg.protect.length === 0 && cfg.ignore.length === 0) {
252
- changedFileCount = lines.length;
253
- } else {
254
- const changedPaths = lines.map(line => {
255
- const filePart = line.substring(3);
256
- const arrowIdx = filePart.indexOf(' -> ');
257
- const raw = arrowIdx >= 0 ? filePart.substring(arrowIdx + 4) : filePart;
258
- return unquoteGitPath(raw);
259
- });
260
- const fakeFiles = changedPaths.map(rel => ({ rel, full: path.join(projectDir, rel) }));
261
- changedFileCount = filterFiles(fakeFiles, cfg).length;
262
- }
263
- }
264
- } else if (pendingManifest) {
239
+ if (!repo && pendingManifest) {
265
240
  if (!lastManifest) {
266
241
  changedFileCount = Object.keys(pendingManifest).length;
267
242
  } else {
@@ -278,18 +253,14 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
278
253
  }
279
254
  }
280
255
 
281
- recordChange(tracker, changedFileCount);
282
- const anomalyResult = checkAnomaly(tracker);
283
- if (anomalyResult.anomaly && anomalyResult.alert && !anomalyResult.suppressed) {
284
- saveAlert(projectDir, anomalyResult.alert);
285
- logger.warn(`ALERT: ${anomalyResult.alert.fileCount} files changed in ${anomalyResult.alert.windowSeconds}s (threshold: ${anomalyResult.alert.threshold})`);
286
- }
287
-
288
- // Git snapshot via Core
256
+ // Git snapshot via Core — changedFileCount comes from diff-tree (accurate incremental)
257
+ let changedFiles;
289
258
  if ((cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both') && repo) {
290
- const context = { trigger: 'auto', changedFileCount };
259
+ const context = { trigger: 'auto' };
291
260
  const snapResult = createGitSnapshot(projectDir, cfg, { branchRef, context });
292
261
  if (snapResult.status === 'created') {
262
+ changedFileCount = snapResult.changedCount != null ? snapResult.changedCount : 0;
263
+ changedFiles = snapResult.changedFiles;
293
264
  let msg = `Git snapshot ${snapResult.shortHash} (${snapResult.fileCount} files)`;
294
265
  if (snapResult.secretsExcluded) {
295
266
  msg += ` [secrets excluded: ${snapResult.secretsExcluded.join(', ')}]`;
@@ -302,11 +273,20 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
302
273
  }
303
274
  }
304
275
 
276
+ // V4: Record change event and check for anomalies (after snapshot, using accurate count)
277
+ recordChange(tracker, changedFileCount, changedFiles);
278
+ const anomalyResult = checkAnomaly(tracker);
279
+ if (anomalyResult.anomaly && anomalyResult.alert && !anomalyResult.suppressed) {
280
+ saveAlert(projectDir, anomalyResult.alert);
281
+ logger.warn(`ALERT: ${anomalyResult.alert.fileCount} files changed in ${anomalyResult.alert.windowSeconds}s (threshold: ${anomalyResult.alert.threshold})`);
282
+ }
283
+
305
284
  // Shadow copy via Core
306
285
  if (cfg.backup_strategy === 'shadow' || cfg.backup_strategy === 'both') {
307
286
  const shadowResult = createShadowCopy(projectDir, cfg, { backupDir });
308
287
  if (shadowResult.status === 'created') {
309
- logger.log(`Shadow copy ${shadowResult.timestamp} (${shadowResult.fileCount} files)`);
288
+ const linkInfo = shadowResult.linkedCount ? ` [${shadowResult.linkedCount} hard-linked]` : '';
289
+ logger.log(`Shadow copy ${shadowResult.timestamp} (${shadowResult.fileCount} files${linkInfo})`);
310
290
  if (pendingManifest) {
311
291
  saveManifest(backupDir, pendingManifest);
312
292
  pendingManifest = null;
@@ -84,6 +84,27 @@ function checkAnomaly(tracker) {
84
84
  return { anomaly: true, alert: lastAlert, suppressed: true };
85
85
  }
86
86
 
87
+ // Aggregate per-file details from recent events, deduplicated by path (latest wins)
88
+ const fileMap = new Map();
89
+ for (const e of recentEvents) {
90
+ if (Array.isArray(e.files)) {
91
+ for (const f of e.files) {
92
+ if (f && f.path) {
93
+ const existing = fileMap.get(f.path);
94
+ if (existing) {
95
+ existing.added = (existing.added || 0) + (f.added || 0);
96
+ existing.deleted = (existing.deleted || 0) + (f.deleted || 0);
97
+ } else {
98
+ fileMap.set(f.path, { path: f.path, action: f.action || 'modified', added: f.added || 0, deleted: f.deleted || 0 });
99
+ }
100
+ }
101
+ }
102
+ }
103
+ }
104
+ const alertFiles = [...fileMap.values()]
105
+ .sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted))
106
+ .slice(0, 50);
107
+
87
108
  const alert = {
88
109
  type: 'high_change_velocity',
89
110
  detectedAt: now,
@@ -93,6 +114,7 @@ function checkAnomaly(tracker) {
93
114
  threshold: tracker.config.filesPerWindow,
94
115
  expiresAt: new Date(now + 5 * 60 * 1000).toISOString(),
95
116
  recommendation: 'High volume of file changes detected. Consider reviewing recent modifications and creating a manual snapshot.',
117
+ files: alertFiles.length > 0 ? alertFiles : undefined,
96
118
  };
97
119
 
98
120
  tracker.alerts.push(alert);
@@ -48,6 +48,9 @@ const TRAILER_MAP = {
48
48
  'Intent': { key: 'intent' },
49
49
  'Agent': { key: 'agent' },
50
50
  'Session': { key: 'session' },
51
+ 'From': { key: 'from' },
52
+ 'Restore-To': { key: 'restoreTo' },
53
+ 'File': { key: 'restoreFile' },
51
54
  };
52
55
 
53
56
  function parseCommitTrailers(body) {
@@ -395,4 +398,65 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
395
398
  return { kept: keepCount, pruned: total - keepCount, mode, rebuilt: true };
396
399
  }
397
400
 
398
- module.exports = { listBackups, cleanShadowRetention, cleanGitRetention, parseShadowTimestamp };
401
+ // ── Get backup file details ─────────────────────────────────────
402
+
403
+ /**
404
+ * Get structured file-level changes for a specific git backup commit.
405
+ * Runs diff-tree --numstat + --name-status against parent (or ls-tree for root).
406
+ *
407
+ * @param {string} projectDir
408
+ * @param {string} commitHash - Full or short commit hash
409
+ * @returns {{ files: Array<{path: string, action: string, added: number, deleted: number}>, error?: string }}
410
+ */
411
+ function getBackupFiles(projectDir, commitHash) {
412
+ if (!isGitRepo(projectDir)) {
413
+ return { files: [], error: 'not a git repository' };
414
+ }
415
+
416
+ const resolved = git(['rev-parse', '--verify', commitHash], { cwd: projectDir, allowFail: true });
417
+ if (!resolved) {
418
+ return { files: [], error: `cannot resolve commit: ${commitHash}` };
419
+ }
420
+
421
+ const parentCheck = git(['rev-parse', '--verify', `${resolved}^`], { cwd: projectDir, allowFail: true });
422
+
423
+ if (!parentCheck) {
424
+ const lsOut = git(['ls-tree', '--name-only', '-r', resolved], { cwd: projectDir, allowFail: true });
425
+ if (!lsOut) return { files: [] };
426
+ return {
427
+ files: lsOut.split('\n').filter(Boolean).map(f => ({ path: f, action: 'added', added: 0, deleted: 0 })),
428
+ };
429
+ }
430
+
431
+ const nameStatusOut = git(['diff-tree', '--no-commit-id', '--name-status', '-r', `${resolved}^`, resolved], { cwd: projectDir, allowFail: true });
432
+ const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', `${resolved}^`, resolved], { cwd: projectDir, allowFail: true });
433
+
434
+ const stats = {};
435
+ if (numstatOut) {
436
+ for (const line of numstatOut.split('\n').filter(Boolean)) {
437
+ const [add, del, ...nameParts] = line.split('\t');
438
+ const fname = nameParts.join('\t');
439
+ stats[fname] = { added: add === '-' ? 0 : parseInt(add, 10), deleted: del === '-' ? 0 : parseInt(del, 10) };
440
+ }
441
+ }
442
+
443
+ const ACTION_MAP = { M: 'modified', A: 'added', D: 'deleted' };
444
+ const files = [];
445
+ if (nameStatusOut) {
446
+ for (const line of nameStatusOut.split('\n').filter(Boolean)) {
447
+ const tab = line.indexOf('\t');
448
+ if (tab < 0) continue;
449
+ const code = line.substring(0, tab).trim();
450
+ const filePart = line.substring(tab + 1);
451
+ const action = code.startsWith('R') ? 'renamed' : (ACTION_MAP[code] || 'modified');
452
+ const fileName = filePart.split('\t').pop();
453
+ const s = stats[fileName] || { added: 0, deleted: 0 };
454
+ files.push({ path: fileName, action, added: s.added, deleted: s.deleted });
455
+ }
456
+ }
457
+
458
+ files.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
459
+ return { files };
460
+ }
461
+
462
+ module.exports = { listBackups, getBackupFiles, cleanShadowRetention, cleanGitRetention, parseShadowTimestamp };
@@ -621,6 +621,46 @@ test('restoreFile respects pre_restore_backup=never from config', () => {
621
621
  }
622
622
  });
623
623
 
624
+ test('restoreFile rejects directory pathspec (git tree)', () => {
625
+ const tmpDir = createTempGitRepo();
626
+ try {
627
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
628
+ const result = restoreFile(tmpDir, 'src', headHash, { preserveCurrent: false });
629
+ assert.strictEqual(result.status, 'error');
630
+ assert.ok(result.error.includes('tree') || result.error.includes('directory'), `expected tree/directory error, got: ${result.error}`);
631
+ } finally {
632
+ cleanupDir(tmpDir);
633
+ }
634
+ });
635
+
636
+ test('restoreFile rejects protected .cursor directory', () => {
637
+ const tmpDir = createTempGitRepo();
638
+ try {
639
+ fs.mkdirSync(path.join(tmpDir, '.cursor'), { recursive: true });
640
+ fs.writeFileSync(path.join(tmpDir, '.cursor', 'mcp.json'), '{}');
641
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
642
+ execFileSync('git', ['commit', '-m', 'add .cursor'], { cwd: tmpDir, stdio: 'pipe' });
643
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
644
+ const result = restoreFile(tmpDir, '.cursor', headHash, { preserveCurrent: false });
645
+ assert.strictEqual(result.status, 'error');
646
+ assert.ok(result.error.includes('protected'), `expected protected path error, got: ${result.error}`);
647
+ } finally {
648
+ cleanupDir(tmpDir);
649
+ }
650
+ });
651
+
652
+ test('restoreFile rejects project root "."', () => {
653
+ const tmpDir = createTempGitRepo();
654
+ try {
655
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
656
+ const result = restoreFile(tmpDir, '.', headHash, { preserveCurrent: false });
657
+ assert.strictEqual(result.status, 'error');
658
+ assert.ok(result.error.includes('project root') || result.error.includes('specific file'), `expected root rejection, got: ${result.error}`);
659
+ } finally {
660
+ cleanupDir(tmpDir);
661
+ }
662
+ });
663
+
624
664
  test('createPreRestoreSnapshot creates ref under refs/guard/pre-restore/', () => {
625
665
  const tmpDir = createTempGitRepo();
626
666
  try {
@@ -217,14 +217,28 @@ function runDiagnostics(projectDir) {
217
217
  check('Disk space', 'WARN', 'could not determine free space');
218
218
  }
219
219
 
220
- // 12. Lock file
220
+ // 12. Lock file — distinguish running watcher from stale lock
221
221
  const lockFile = gDir
222
222
  ? path.join(gDir, 'cursor-guard.lock')
223
223
  : path.join(backupDir, 'cursor-guard.lock');
224
224
  if (fs.existsSync(lockFile)) {
225
225
  let content = '';
226
226
  try { content = fs.readFileSync(lockFile, 'utf-8').trim(); } catch { /* ignore */ }
227
- check('Lock file', 'WARN', `lock file exists — another instance may be running. ${content}`);
227
+ const pidMatch = content.match(/pid=(\d+)/);
228
+ const startedMatch = content.match(/started=(.+)/);
229
+ const lockPid = pidMatch ? parseInt(pidMatch[1], 10) : null;
230
+ let pidAlive = false;
231
+ if (lockPid) {
232
+ try { process.kill(lockPid, 0); pidAlive = true; } catch { /* not running */ }
233
+ }
234
+ if (lockPid && pidAlive) {
235
+ const since = startedMatch ? startedMatch[1] : 'unknown';
236
+ check('Lock file', 'PASS', `watcher running (pid=${lockPid}, since ${since})`);
237
+ } else if (lockPid && !pidAlive) {
238
+ check('Lock file', 'WARN', `stale lock file (pid=${lockPid} is dead) — safe to delete or run doctor_fix`);
239
+ } else {
240
+ check('Lock file', 'WARN', `lock file exists — another instance may be running. ${content}`);
241
+ }
228
242
  } else {
229
243
  check('Lock file', 'PASS', 'no lock file (no running instance)');
230
244
  }