cursor-guard 1.3.2 → 1.4.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/SKILL.md CHANGED
@@ -20,6 +20,7 @@ Use this skill when any of the following appear:
20
20
  - **Parallel context**: Multiple repos or branches; unclear which folder is the workspace root.
21
21
  - **Recovery asks**: e.g. "改不回来", "丢版本", "回滚", "reflog", "误删", or English equivalents.
22
22
  - **Time/version recovery**: e.g. "恢复到5分钟前", "恢复到前3个版本", "回到上一个版本", "restore to 10 minutes ago", "go back 2 versions", "恢复到下午3点的状态".
23
+ - **Health check**: e.g. "guard doctor", "检查备份配置", "自检", "诊断guard", "check guard setup". Run `guard-doctor.ps1` and report results.
23
24
 
24
25
  If none of the above, do not expand scope; answer normally.
25
26
 
@@ -100,19 +101,22 @@ When the target file of an edit **falls outside the protected scope**, the agent
100
101
  Use a **temporary index and dedicated ref** so the user's staged/unstaged state is never touched:
101
102
 
102
103
  ```bash
104
+ GIT_DIR=$(git rev-parse --git-dir)
105
+ GUARD_IDX="$GIT_DIR/guard-snapshot-index"
106
+
103
107
  # 1. Create temp index from HEAD
104
- GIT_INDEX_FILE=.git/guard-snapshot-index git read-tree HEAD
108
+ GIT_INDEX_FILE="$GUARD_IDX" git read-tree HEAD
105
109
 
106
110
  # 2. Stage working-tree files into temp index
107
- GIT_INDEX_FILE=.git/guard-snapshot-index git add -A
111
+ GIT_INDEX_FILE="$GUARD_IDX" git add -A
108
112
 
109
113
  # 3. Write tree and create commit on a guard ref (not on the user's branch)
110
- TREE=$(GIT_INDEX_FILE=.git/guard-snapshot-index git write-tree)
111
- COMMIT=$(git commit-tree $TREE -p HEAD -m "guard: snapshot before ai edit")
112
- git update-ref refs/guard/snapshot $COMMIT
114
+ TREE=$(GIT_INDEX_FILE="$GUARD_IDX" git write-tree)
115
+ COMMIT=$(git commit-tree "$TREE" -p HEAD -m "guard: snapshot before ai edit")
116
+ git update-ref refs/guard/snapshot "$COMMIT"
113
117
 
114
118
  # 4. Cleanup
115
- rm .git/guard-snapshot-index
119
+ rm -f "$GUARD_IDX"
116
120
  ```
117
121
 
118
122
  **PowerShell equivalent** (for agent Shell calls):
@@ -359,9 +363,12 @@ Then jump to Step 5.
359
363
 
360
364
  Use the same temp-index plumbing as §2a to avoid polluting the user's staging area:
361
365
 
362
- **Git repo (preferred):**
366
+ **Git repo (preferred) — timestamped ref stack:**
367
+
368
+ Each pre-restore snapshot writes to a unique ref `refs/guard/pre-restore/<yyyyMMdd_HHmmss>` so consecutive restores never overwrite each other:
363
369
 
364
370
  ```powershell
371
+ $ts = Get-Date -Format 'yyyyMMdd_HHmmss'
365
372
  $guardIdx = Join-Path (git rev-parse --git-dir) "guard-pre-restore-index"
366
373
  $env:GIT_INDEX_FILE = $guardIdx
367
374
 
@@ -378,10 +385,16 @@ $env:GIT_INDEX_FILE = $null
378
385
  Remove-Item $guardIdx -Force -ErrorAction SilentlyContinue
379
386
 
380
387
  $commit = git commit-tree $tree -p HEAD -m "guard: preserve current before restore to <target>"
388
+ git update-ref "refs/guard/pre-restore/$ts" $commit
389
+ ```
390
+
391
+ Record the short hash and the ref path. Also update `refs/guard/pre-restore` as an alias pointing to the latest:
392
+
393
+ ```powershell
381
394
  git update-ref refs/guard/pre-restore $commit
382
395
  ```
383
396
 
384
- Record the short hash and the ref `refs/guard/pre-restore`.
397
+ To list all pre-restore snapshots: `git for-each-ref refs/guard/pre-restore/ --sort=-creatordate --format="%(refname:short) %(creatordate:short) %(objectname:short)"`
385
398
 
386
399
  **Non-Git fallback (shadow copy):**
387
400
 
@@ -404,12 +417,14 @@ If the snapshot fails (e.g. disk full, permission error):
404
417
  Before executing restore, tell the user:
405
418
  ```
406
419
  在恢复前,我已保留当前版本:
407
- - 备份引用: refs/guard/pre-restore (abc1234)
408
- - 恢复方式: git restore --source=refs/guard/pre-restore -- <file>
420
+ - 备份引用: refs/guard/pre-restore/20260321_163005 (abc1234)
421
+ - 恢复方式: git restore --source=refs/guard/pre-restore/20260321_163005 -- <file>
422
+ - 历史栈: git for-each-ref refs/guard/pre-restore/ --sort=-creatordate
409
423
 
410
424
  Current version preserved before restore:
411
- - Backup ref: refs/guard/pre-restore (abc1234)
412
- - To undo: git restore --source=refs/guard/pre-restore -- <file>
425
+ - Backup ref: refs/guard/pre-restore/20260321_163005 (abc1234)
426
+ - To undo: git restore --source=refs/guard/pre-restore/20260321_163005 -- <file>
427
+ - History: git for-each-ref refs/guard/pre-restore/ --sort=-creatordate
413
428
  ```
414
429
 
415
430
  ### Step 5: Execute Recovery
@@ -451,11 +466,12 @@ After restoring, always:
451
466
 
452
467
  ```markdown
453
468
  **Cursor Guard — restore status**
454
- - **Pre-restore backup**: `refs/guard/pre-restore` (`<short-hash>`) or `shadow copy at .cursor-guard-backup/pre-restore-<ts>/` or `skipped (user opted out)` or `skipped (no changes)`
469
+ - **Pre-restore backup**: `refs/guard/pre-restore/<ts>` (`<short-hash>`) or `shadow copy at .cursor-guard-backup/pre-restore-<ts>/` or `skipped (user opted out)` or `skipped (no changes)`
455
470
  - **Restored to**: `<target-hash>` / `<target description>`
456
471
  - **Scope**: single file `<path>` / N files / entire project
457
472
  - **Result**: success / failed
458
- - **To undo restore**: `git restore --source=refs/guard/pre-restore -- <file>`
473
+ - **To undo restore**: `git restore --source=refs/guard/pre-restore/<ts> -- <file>`
474
+ - **All pre-restore snapshots**: `git for-each-ref refs/guard/pre-restore/ --sort=-creatordate`
459
475
  ```
460
476
 
461
477
  ---
@@ -511,3 +527,4 @@ Skip the block for unrelated turns.
511
527
  - Example config: [references/cursor-guard.example.json](references/cursor-guard.example.json)
512
528
  - Config field reference (EN): [references/config-reference.md](references/config-reference.md)
513
529
  - 配置参数说明(中文): [references/config-reference.zh-CN.md](references/config-reference.zh-CN.md)
530
+ - Health check: [references/guard-doctor.ps1](references/guard-doctor.ps1) — run `.\guard-doctor.ps1 -Path .` to verify setup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "Protects code from accidental AI overwrite or deletion in Cursor IDE — mandatory pre-write snapshots, review-before-apply, local Git safety net, and deterministic recovery. | 保护代码免受 Cursor AI 代理意外覆写或删除——强制写前快照、预览再执行、本地 Git 安全网、确定性恢复。",
5
5
  "keywords": [
6
6
  "cursor",
@@ -37,18 +37,27 @@ $ErrorActionPreference = "Stop"
37
37
  $resolved = (Resolve-Path $Path).Path
38
38
  Set-Location $resolved
39
39
 
40
- # ── Paths (worktree-safe: uses git rev-parse instead of hard-coding .git) ──
41
- $gitDir = (git rev-parse --git-dir 2>$null)
42
- if (-not $gitDir) {
43
- $gitDir = Join-Path $resolved ".git"
44
- } else {
45
- $gitDir = (Resolve-Path $gitDir -ErrorAction SilentlyContinue).Path
46
- if (-not $gitDir) { $gitDir = Join-Path $resolved ".git" }
40
+ # ── Git availability check ────────────────────────────────────────
41
+ $hasGit = $false
42
+ $gitDir = $null
43
+ $isRepo = $false
44
+ if (Get-Command git -ErrorAction SilentlyContinue) {
45
+ $hasGit = $true
46
+ $isRepo = (git rev-parse --is-inside-work-tree 2>$null) -eq "true"
47
+ if ($isRepo) {
48
+ $gitDir = (git rev-parse --git-dir 2>$null)
49
+ if ($gitDir) {
50
+ $gitDir = (Resolve-Path $gitDir -ErrorAction SilentlyContinue).Path
51
+ }
52
+ if (-not $gitDir) { $gitDir = Join-Path $resolved ".git" }
53
+ }
47
54
  }
48
- $lockFile = Join-Path $gitDir "cursor-guard.lock"
49
- $guardIndex = Join-Path $gitDir "cursor-guard-index"
55
+
56
+ # ── Paths ─────────────────────────────────────────────────────────
50
57
  $backupDir = Join-Path $resolved ".cursor-guard-backup"
51
58
  $logFilePath = Join-Path $backupDir "backup.log"
59
+ $lockFile = if ($gitDir) { Join-Path $gitDir "cursor-guard.lock" } else { Join-Path $backupDir "cursor-guard.lock" }
60
+ $guardIndex = if ($gitDir) { Join-Path $gitDir "cursor-guard-index" } else { $null }
52
61
 
53
62
  # ── Cleanup on exit ───────────────────────────────────────────────
54
63
  function Invoke-Cleanup {
@@ -67,6 +76,10 @@ $retentionMode = "days"
67
76
  $retentionDays = 30
68
77
  $retentionMaxCnt = 100
69
78
  $retentionMaxMB = 500
79
+ $gitRetEnabled = $false
80
+ $gitRetMode = "count"
81
+ $gitRetDays = 30
82
+ $gitRetMaxCnt = 200
70
83
 
71
84
  # ── Load .cursor-guard.json ──────────────────────────────────────
72
85
  $cfgPath = Join-Path $resolved ".cursor-guard.json"
@@ -86,7 +99,13 @@ if (Test-Path $cfgPath) {
86
99
  if ($cfg.retention.max_count) { $retentionMaxCnt = $cfg.retention.max_count }
87
100
  if ($cfg.retention.max_size_mb) { $retentionMaxMB = $cfg.retention.max_size_mb }
88
101
  }
89
- Write-Host "[guard] Config loaded protect=$($protectPatterns.Count) ignore=$($ignorePatterns.Count) retention=$retentionMode" -ForegroundColor Cyan
102
+ if ($cfg.git_retention) {
103
+ if ($cfg.git_retention.enabled -eq $true) { $gitRetEnabled = $true }
104
+ if ($cfg.git_retention.mode) { $gitRetMode = $cfg.git_retention.mode }
105
+ if ($cfg.git_retention.days) { $gitRetDays = $cfg.git_retention.days }
106
+ if ($cfg.git_retention.max_count) { $gitRetMaxCnt = $cfg.git_retention.max_count }
107
+ }
108
+ Write-Host "[guard] Config loaded protect=$($protectPatterns.Count) ignore=$($ignorePatterns.Count) retention=$retentionMode git_retention=$(if($gitRetEnabled){'on'}else{'off'})" -ForegroundColor Cyan
90
109
  }
91
110
  catch {
92
111
  Write-Host "[guard] WARNING: .cursor-guard.json parse error - using defaults." -ForegroundColor Yellow
@@ -95,12 +114,21 @@ if (Test-Path $cfgPath) {
95
114
  }
96
115
  if ($IntervalSeconds -eq 0) { $IntervalSeconds = 60 }
97
116
 
98
- # ── Git repo check ───────────────────────────────────────────────
99
- $isRepo = git rev-parse --is-inside-work-tree 2>$null
100
- if ($isRepo -ne "true") {
117
+ # ── Git repo check (only required for git/both strategy) ─────────
118
+ $needsGit = ($backupStrategy -eq "git" -or $backupStrategy -eq "both")
119
+ if ($needsGit -and -not $isRepo) {
120
+ if (-not $hasGit) {
121
+ Write-Host "[guard] ERROR: backup_strategy='$backupStrategy' requires Git, but git is not installed." -ForegroundColor Red
122
+ Write-Host " Either install Git or set backup_strategy to 'shadow' in .cursor-guard.json." -ForegroundColor Yellow
123
+ exit 1
124
+ }
101
125
  $ans = Read-Host "Directory is not a Git repo. Initialize? (y/n)"
102
126
  if ($ans -eq 'y') {
103
127
  git init
128
+ $isRepo = $true
129
+ $gitDir = (Resolve-Path (git rev-parse --git-dir)).Path
130
+ $lockFile = Join-Path $gitDir "cursor-guard.lock"
131
+ $guardIndex = Join-Path $gitDir "cursor-guard-index"
104
132
  $gi = Join-Path $resolved ".gitignore"
105
133
  $entry = ".cursor-guard-backup/"
106
134
  if (Test-Path $gi) {
@@ -114,10 +142,16 @@ if ($isRepo -ne "true") {
114
142
  git add -A; git commit -m "guard: initial snapshot" --no-verify
115
143
  Write-Host "[guard] Repo initialized with snapshot." -ForegroundColor Green
116
144
  } else {
117
- Write-Host "[guard] Git is required. Exiting." -ForegroundColor Red
145
+ Write-Host "[guard] Git is required for '$backupStrategy' strategy. Exiting." -ForegroundColor Red
118
146
  exit 1
119
147
  }
120
148
  }
149
+ if (-not $isRepo -and $backupStrategy -eq "shadow") {
150
+ Write-Host "[guard] Non-Git directory detected. Running in shadow-only mode." -ForegroundColor Cyan
151
+ }
152
+
153
+ # ── Ensure backup dir exists ──────────────────────────────────────
154
+ if (-not (Test-Path $backupDir)) { New-Item -ItemType Directory -Force $backupDir | Out-Null }
121
155
 
122
156
  # ── Lock file (prevent multiple instances) ───────────────────────
123
157
  if (Test-Path $lockFile) {
@@ -127,30 +161,30 @@ if (Test-Path $lockFile) {
127
161
  }
128
162
  Set-Content $lockFile "pid=$PID`nstarted=$(Get-Date -Format 'o')"
129
163
 
130
- # ── Backup branch ───────────────────────────────────────────────
164
+ # ── Git-specific setup (skip entirely for shadow-only) ───────────
131
165
  $branch = "cursor-guard/auto-backup"
132
166
  $branchRef = "refs/heads/$branch"
133
- if (-not (git rev-parse --verify $branchRef 2>$null)) {
134
- git branch $branch HEAD 2>$null
135
- Write-Host "[guard] Created branch: $branch" -ForegroundColor Green
136
- }
167
+ if ($isRepo) {
168
+ if (-not (git rev-parse --verify $branchRef 2>$null)) {
169
+ git branch $branch HEAD 2>$null
170
+ Write-Host "[guard] Created branch: $branch" -ForegroundColor Green
171
+ }
137
172
 
138
- # ── Ensure .cursor-guard-backup/ is git-ignored ─────────────────
139
- $excludeFile = Join-Path $gitDir "info/exclude"
140
- $excludeDir = Split-Path $excludeFile
141
- if (-not (Test-Path $excludeDir)) { New-Item -ItemType Directory -Force $excludeDir | Out-Null }
142
- $excludeEntry = ".cursor-guard-backup/"
143
- if (Test-Path $excludeFile) {
144
- $content = Get-Content $excludeFile -Raw -ErrorAction SilentlyContinue
145
- if (-not $content -or $content -notmatch [regex]::Escape($excludeEntry)) {
146
- Add-Content $excludeFile "`n$excludeEntry"
173
+ $excludeFile = Join-Path $gitDir "info/exclude"
174
+ $excludeDir = Split-Path $excludeFile
175
+ if (-not (Test-Path $excludeDir)) { New-Item -ItemType Directory -Force $excludeDir | Out-Null }
176
+ $excludeEntry = ".cursor-guard-backup/"
177
+ if (Test-Path $excludeFile) {
178
+ $content = Get-Content $excludeFile -Raw -ErrorAction SilentlyContinue
179
+ if (-not $content -or $content -notmatch [regex]::Escape($excludeEntry)) {
180
+ Add-Content $excludeFile "`n$excludeEntry"
181
+ }
182
+ } else {
183
+ Set-Content $excludeFile $excludeEntry
147
184
  }
148
- } else {
149
- Set-Content $excludeFile $excludeEntry
150
185
  }
151
186
 
152
- # ── Log directory & helpers ──────────────────────────────────────
153
- if (-not (Test-Path $backupDir)) { New-Item -ItemType Directory -Force $backupDir | Out-Null }
187
+ # ── Log & helpers ────────────────────────────────────────────────
154
188
 
155
189
  function Write-Log {
156
190
  param([string]$Msg, [ConsoleColor]$Color = "Green")
@@ -232,30 +266,93 @@ function Invoke-RetentionCleanup {
232
266
  } catch {}
233
267
  }
234
268
 
269
+ # ── Git branch retention ─────────────────────────────────────────
270
+ function Invoke-GitRetention {
271
+ if (-not $gitRetEnabled -or -not $isRepo) { return }
272
+ $commits = git rev-list $branchRef 2>$null
273
+ if (-not $commits) { return }
274
+ $commitList = @($commits)
275
+ $total = $commitList.Count
276
+
277
+ $keepCount = $total
278
+ switch ($gitRetMode) {
279
+ "count" {
280
+ $keepCount = [math]::Min($total, $gitRetMaxCnt)
281
+ }
282
+ "days" {
283
+ $cutoff = (Get-Date).AddDays(-$gitRetDays).ToString("yyyy-MM-ddTHH:mm:ss")
284
+ $keepCommits = git rev-list $branchRef --after=$cutoff 2>$null
285
+ $keepCount = if ($keepCommits) { @($keepCommits).Count } else { 0 }
286
+ $keepCount = [math]::Max($keepCount, 10)
287
+ }
288
+ }
289
+
290
+ if ($keepCount -ge $total) { return }
291
+
292
+ $newTip = $commitList[$keepCount - 1]
293
+ $orphanTree = git rev-parse "${newTip}^{tree}" 2>$null
294
+ if (-not $orphanTree) { return }
295
+
296
+ $orphanCommit = git commit-tree $orphanTree -m "guard: retention truncation point"
297
+ if (-not $orphanCommit) { return }
298
+
299
+ $keptCommits = $commitList[0..($keepCount - 2)]
300
+ $grafts = @()
301
+ foreach ($i in 0..($keptCommits.Count - 1)) {
302
+ if ($i -lt ($keptCommits.Count - 1)) {
303
+ $grafts += "$($keptCommits[$i]) $($commitList[$i + 1])"
304
+ } else {
305
+ $grafts += "$($keptCommits[$i]) $orphanCommit"
306
+ }
307
+ }
308
+
309
+ $infoDir = Join-Path $gitDir "info"
310
+ if (-not (Test-Path $infoDir)) { New-Item -ItemType Directory -Force $infoDir | Out-Null }
311
+ $graftsFile = Join-Path $infoDir "grafts"
312
+ $grafts | Set-Content $graftsFile
313
+ git filter-branch -f --tag-name-filter cat -- $branchRef 2>$null
314
+ Remove-Item $graftsFile -Force -ErrorAction SilentlyContinue
315
+
316
+ $pruned = $total - $keepCount
317
+ Write-Log "Git retention ($gitRetMode): pruned $pruned old commit(s), kept $keepCount" DarkGray
318
+ }
319
+
235
320
  # ── Shadow copy helper ────────────────────────────────────────────
236
321
  function Invoke-ShadowCopy {
237
322
  $ts = Get-Date -Format 'yyyyMMdd_HHmmss'
238
323
  $snapDir = Join-Path $backupDir $ts
239
324
  New-Item -ItemType Directory -Force $snapDir | Out-Null
240
325
 
326
+ $allFiles = Get-ChildItem $resolved -Recurse -File -ErrorAction SilentlyContinue |
327
+ Where-Object { $_.FullName -notmatch '[\\/](\.git|\.cursor-guard-backup|node_modules)[\\/]' }
328
+
241
329
  $files = if ($protectPatterns.Count -gt 0) {
242
- $protectPatterns | ForEach-Object { Get-ChildItem $resolved -Recurse -File -Filter $_ -ErrorAction SilentlyContinue }
330
+ $allFiles | Where-Object {
331
+ $rel = $_.FullName.Substring($resolved.Length + 1) -replace '\\','/'
332
+ $matched = $false
333
+ foreach ($pat in $protectPatterns) {
334
+ $p = $pat -replace '\\','/'
335
+ if ($rel -like $p -or (Split-Path $rel -Leaf) -like $p) { $matched = $true; break }
336
+ }
337
+ $matched
338
+ }
243
339
  } else {
244
- Get-ChildItem $resolved -Recurse -File -ErrorAction SilentlyContinue |
245
- Where-Object { $_.FullName -notmatch '[\\/](\.git|\.cursor-guard-backup|node_modules)[\\/]' }
340
+ $allFiles
246
341
  }
247
342
 
248
343
  $copied = 0
249
344
  foreach ($f in $files) {
250
- $rel = $f.FullName.Substring($resolved.Length + 1)
345
+ $rel = $f.FullName.Substring($resolved.Length + 1) -replace '\\','/'
346
+ $leaf = $f.Name
251
347
  $skip = $false
252
348
  foreach ($ig in $ignorePatterns) {
253
- $re = '^' + [regex]::Escape($ig).Replace('\*\*','.*').Replace('\*','[^/\\]*').Replace('\?','.') + '$'
254
- if ($rel -match $re) { $skip = $true; break }
349
+ $p = $ig -replace '\\','/'
350
+ if ($rel -like $p -or $leaf -like $p) { $skip = $true; break }
255
351
  }
256
- foreach ($pat in $secretsPatterns) {
257
- $re = '^' + [regex]::Escape($pat).Replace('\*','.*').Replace('\?','.') + '$'
258
- if ($rel -match $re -or $f.Name -match $re) { $skip = $true; break }
352
+ if (-not $skip) {
353
+ foreach ($pat in $secretsPatterns) {
354
+ if ($rel -like $pat -or $leaf -like $pat) { $skip = $true; break }
355
+ }
259
356
  }
260
357
  if ($skip) { continue }
261
358
  $dest = Join-Path $snapDir $rel
@@ -286,8 +383,28 @@ try {
286
383
  Start-Sleep -Seconds $IntervalSeconds
287
384
  $cycle++
288
385
 
289
- $dirty = git status --porcelain 2>$null
290
- if (-not $dirty) { continue }
386
+ # ── Detect changes ────────────────────────────────────────
387
+ $hasChanges = $false
388
+ if ($isRepo) {
389
+ $dirty = git status --porcelain 2>$null
390
+ $hasChanges = [bool]$dirty
391
+ } else {
392
+ # Non-Git: compare file timestamps against last snapshot
393
+ $lastSnap = Get-ChildItem $backupDir -Directory -ErrorAction SilentlyContinue |
394
+ Where-Object { $_.Name -match '^\d{8}_\d{6}$' } |
395
+ Sort-Object Name -Descending | Select-Object -First 1
396
+ if (-not $lastSnap) {
397
+ $hasChanges = $true
398
+ } else {
399
+ $latest = Get-ChildItem $resolved -Recurse -File -ErrorAction SilentlyContinue |
400
+ Where-Object { $_.FullName -notmatch '[\\/](\.cursor-guard-backup)[\\/]' } |
401
+ Sort-Object LastWriteTime -Descending | Select-Object -First 1
402
+ if ($latest -and $latest.LastWriteTime -gt $lastSnap.CreationTime) {
403
+ $hasChanges = $true
404
+ }
405
+ }
406
+ }
407
+ if (-not $hasChanges) { continue }
291
408
 
292
409
  # ── Git branch snapshot ──────────────────────────────────
293
410
  if ($backupStrategy -eq "git" -or $backupStrategy -eq "both") {
@@ -350,7 +467,10 @@ try {
350
467
  }
351
468
 
352
469
  # Periodic retention cleanup every 10 cycles
353
- if ($cycle % 10 -eq 0) { Invoke-RetentionCleanup }
470
+ if ($cycle % 10 -eq 0) {
471
+ Invoke-RetentionCleanup
472
+ Invoke-GitRetention
473
+ }
354
474
  }
355
475
  }
356
476
  finally {
@@ -132,3 +132,29 @@ Retention policy for **shadow copies** only. Git branch snapshots are not auto-c
132
132
  "max_size_mb": 500
133
133
  }
134
134
  ```
135
+
136
+ ---
137
+
138
+ ## `git_retention`
139
+
140
+ - **Type**: `object`
141
+ - **Default**: `{ "enabled": false, "mode": "count", "max_count": 200 }`
142
+
143
+ Retention policy for the **`cursor-guard/auto-backup` Git branch**. By default, auto-backup commits accumulate indefinitely. Enable this to automatically prune old commits.
144
+
145
+ ### Sub-fields
146
+
147
+ | Field | Type | Default | Description |
148
+ |-------|------|---------|-------------|
149
+ | `enabled` | `boolean` | `false` | Enable automatic pruning. When false, branch grows without limit. |
150
+ | `mode` | `"days"` \| `"count"` | `"count"` | Pruning strategy |
151
+ | `days` | `integer` | `30` | Keep commits from last N days (when mode=days) |
152
+ | `max_count` | `integer` | `200` | Keep N newest commits (when mode=count, minimum 10) |
153
+
154
+ ```json
155
+ "git_retention": {
156
+ "enabled": true,
157
+ "mode": "count",
158
+ "max_count": 200
159
+ }
160
+ ```
@@ -132,3 +132,29 @@
132
132
  "max_size_mb": 500
133
133
  }
134
134
  ```
135
+
136
+ ---
137
+
138
+ ## `git_retention`
139
+
140
+ - **类型**:`object`
141
+ - **默认值**:`{ "enabled": false, "mode": "count", "max_count": 200 }`
142
+
143
+ **`cursor-guard/auto-backup` Git 分支**的保留策略。默认情况下自动备份提交会无限累积。启用此项可自动裁剪旧提交。
144
+
145
+ ### 子字段
146
+
147
+ | 字段 | 类型 | 默认值 | 说明 |
148
+ |------|------|--------|------|
149
+ | `enabled` | `boolean` | `false` | 启用自动裁剪。关闭时分支无限增长。 |
150
+ | `mode` | `"days"` \| `"count"` | `"count"` | 裁剪策略 |
151
+ | `days` | `integer` | `30` | 保留最近 N 天的提交(mode=days 时生效) |
152
+ | `max_count` | `integer` | `200` | 保留最新 N 个提交(mode=count 时生效,最少 10) |
153
+
154
+ ```json
155
+ "git_retention": {
156
+ "enabled": true,
157
+ "mode": "count",
158
+ "max_count": 200
159
+ }
160
+ ```
@@ -32,5 +32,11 @@
32
32
  "days": 30,
33
33
  "max_count": 100,
34
34
  "max_size_mb": 500
35
+ },
36
+ "git_retention": {
37
+ "enabled": false,
38
+ "mode": "count",
39
+ "days": 30,
40
+ "max_count": 200
35
41
  }
36
42
  }
@@ -67,6 +67,36 @@
67
67
  }
68
68
  },
69
69
  "additionalProperties": false
70
+ },
71
+ "git_retention": {
72
+ "type": "object",
73
+ "description": "Controls automatic cleanup of old commits on the cursor-guard/auto-backup branch.",
74
+ "properties": {
75
+ "enabled": {
76
+ "type": "boolean",
77
+ "default": false,
78
+ "description": "Enable automatic pruning of old auto-backup commits. Default false (manual cleanup)."
79
+ },
80
+ "mode": {
81
+ "type": "string",
82
+ "enum": ["days", "count"],
83
+ "default": "count",
84
+ "description": "'days': keep commits from the last N days. 'count': keep N newest commits."
85
+ },
86
+ "days": {
87
+ "type": "integer",
88
+ "minimum": 1,
89
+ "default": 30,
90
+ "description": "Number of days to keep auto-backup commits (when mode='days')."
91
+ },
92
+ "max_count": {
93
+ "type": "integer",
94
+ "minimum": 10,
95
+ "default": 200,
96
+ "description": "Maximum number of auto-backup commits to keep (when mode='count')."
97
+ }
98
+ },
99
+ "additionalProperties": false
70
100
  }
71
101
  },
72
102
  "additionalProperties": true
@@ -0,0 +1,226 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Cursor Guard Doctor — one-command health check for your guard setup.
4
+
5
+ .USAGE
6
+ .\guard-doctor.ps1 -Path "D:\MyProject"
7
+
8
+ .NOTES
9
+ Checks: Git availability, worktree layout, .cursor-guard.json validity,
10
+ backup strategy vs environment compatibility, ignore effectiveness,
11
+ pre-restore refs, shadow copy directory, disk space, and more.
12
+ #>
13
+
14
+ param(
15
+ [Parameter(Mandatory)]
16
+ [string]$Path
17
+ )
18
+
19
+ $ErrorActionPreference = "Continue"
20
+ $resolved = (Resolve-Path $Path -ErrorAction Stop).Path
21
+ Set-Location $resolved
22
+
23
+ $pass = 0; $warn = 0; $fail = 0
24
+
25
+ function Write-Check {
26
+ param([string]$Name, [string]$Status, [string]$Detail = "")
27
+ switch ($Status) {
28
+ "PASS" { $color = "Green"; $script:pass++ }
29
+ "WARN" { $color = "Yellow"; $script:warn++ }
30
+ "FAIL" { $color = "Red"; $script:fail++ }
31
+ default { $color = "Gray" }
32
+ }
33
+ $line = " [$Status] $Name"
34
+ if ($Detail) { $line += " — $Detail" }
35
+ Write-Host $line -ForegroundColor $color
36
+ }
37
+
38
+ Write-Host ""
39
+ Write-Host "=== Cursor Guard Doctor ===" -ForegroundColor Cyan
40
+ Write-Host " Target: $resolved" -ForegroundColor Cyan
41
+ Write-Host ""
42
+
43
+ # ── 1. Git availability ──────────────────────────────────────────
44
+ $hasGit = [bool](Get-Command git -ErrorAction SilentlyContinue)
45
+ if ($hasGit) {
46
+ $gitVer = (git --version 2>$null) -replace 'git version ',''
47
+ Write-Check "Git installed" "PASS" "version $gitVer"
48
+ } else {
49
+ Write-Check "Git installed" "WARN" "git not found in PATH; only shadow strategy available"
50
+ }
51
+
52
+ # ── 2. Git repo status ───────────────────────────────────────────
53
+ $isRepo = $false
54
+ $gitDir = $null
55
+ if ($hasGit) {
56
+ $isRepo = (git rev-parse --is-inside-work-tree 2>$null) -eq "true"
57
+ if ($isRepo) {
58
+ $gitDir = (Resolve-Path (git rev-parse --git-dir 2>$null) -ErrorAction SilentlyContinue).Path
59
+ $isWorktree = (git rev-parse --is-inside-work-tree 2>$null) -eq "true" -and
60
+ (git rev-parse --git-common-dir 2>$null) -ne (git rev-parse --git-dir 2>$null)
61
+ if ($isWorktree) {
62
+ Write-Check "Git repository" "PASS" "worktree detected (git-dir: $gitDir)"
63
+ } else {
64
+ Write-Check "Git repository" "PASS" "standard repo"
65
+ }
66
+ } else {
67
+ Write-Check "Git repository" "WARN" "not a Git repo; git/both strategies won't work"
68
+ }
69
+ }
70
+
71
+ # ── 3. .cursor-guard.json ────────────────────────────────────────
72
+ $cfgPath = Join-Path $resolved ".cursor-guard.json"
73
+ $cfg = $null
74
+ if (Test-Path $cfgPath) {
75
+ try {
76
+ $cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json
77
+ Write-Check "Config file" "PASS" ".cursor-guard.json found and valid JSON"
78
+ } catch {
79
+ Write-Check "Config file" "FAIL" "JSON parse error: $_"
80
+ }
81
+ } else {
82
+ Write-Check "Config file" "WARN" "no .cursor-guard.json found; using defaults (protect everything)"
83
+ }
84
+
85
+ # ── 4. Strategy vs environment ────────────────────────────────────
86
+ $strategy = "git"
87
+ if ($cfg -and $cfg.backup_strategy) { $strategy = $cfg.backup_strategy }
88
+ if ($strategy -eq "git" -or $strategy -eq "both") {
89
+ if (-not $isRepo) {
90
+ Write-Check "Strategy compatibility" "FAIL" "backup_strategy='$strategy' but directory is not a Git repo"
91
+ } else {
92
+ Write-Check "Strategy compatibility" "PASS" "backup_strategy='$strategy' and Git repo exists"
93
+ }
94
+ } elseif ($strategy -eq "shadow") {
95
+ Write-Check "Strategy compatibility" "PASS" "backup_strategy='shadow' — no Git required"
96
+ } else {
97
+ Write-Check "Strategy compatibility" "FAIL" "unknown backup_strategy='$strategy' (must be git/shadow/both)"
98
+ }
99
+
100
+ # ── 5. Backup branch ─────────────────────────────────────────────
101
+ if ($isRepo) {
102
+ $branchRef = "refs/heads/cursor-guard/auto-backup"
103
+ $branchExists = git rev-parse --verify $branchRef 2>$null
104
+ if ($branchExists) {
105
+ $commitCount = (git rev-list --count $branchRef 2>$null)
106
+ Write-Check "Backup branch" "PASS" "cursor-guard/auto-backup exists ($commitCount commits)"
107
+ } else {
108
+ Write-Check "Backup branch" "WARN" "cursor-guard/auto-backup not created yet (will be created on first backup)"
109
+ }
110
+ }
111
+
112
+ # ── 6. Guard refs ────────────────────────────────────────────────
113
+ if ($isRepo) {
114
+ $guardRefs = git for-each-ref refs/guard/ --format="%(refname)" 2>$null
115
+ if ($guardRefs) {
116
+ $refCount = @($guardRefs).Count
117
+ $preRestoreRefs = @($guardRefs | Where-Object { $_ -match 'pre-restore/' })
118
+ Write-Check "Guard refs" "PASS" "$refCount ref(s) found ($($preRestoreRefs.Count) pre-restore snapshots)"
119
+ } else {
120
+ Write-Check "Guard refs" "WARN" "no guard refs yet (created on first snapshot or restore)"
121
+ }
122
+ }
123
+
124
+ # ── 7. Shadow copy directory ─────────────────────────────────────
125
+ $backupDir = Join-Path $resolved ".cursor-guard-backup"
126
+ if (Test-Path $backupDir) {
127
+ $snapDirs = Get-ChildItem $backupDir -Directory -ErrorAction SilentlyContinue |
128
+ Where-Object { $_.Name -match '^\d{8}_\d{6}$' -or $_.Name -match '^pre-restore-' }
129
+ $snapCount = if ($snapDirs) { @($snapDirs).Count } else { 0 }
130
+ $totalMB = [math]::Round(((Get-ChildItem $backupDir -Recurse -File -ErrorAction SilentlyContinue |
131
+ Measure-Object Length -Sum).Sum / 1MB), 1)
132
+ Write-Check "Shadow copies" "PASS" "$snapCount snapshot(s), ${totalMB} MB total"
133
+ } else {
134
+ Write-Check "Shadow copies" "WARN" ".cursor-guard-backup/ not found (will be created on first shadow backup)"
135
+ }
136
+
137
+ # ── 8. .gitignore / exclude coverage ────────────────────────────
138
+ if ($isRepo) {
139
+ $checkIgnored = git check-ignore ".cursor-guard-backup/test" 2>$null
140
+ if ($checkIgnored) {
141
+ Write-Check "Backup dir ignored" "PASS" ".cursor-guard-backup/ is git-ignored"
142
+ } else {
143
+ Write-Check "Backup dir ignored" "WARN" ".cursor-guard-backup/ may NOT be git-ignored — backup changes could trigger commits"
144
+ }
145
+ }
146
+
147
+ # ── 9. Config field validation ────────────────────────────────────
148
+ if ($cfg) {
149
+ $validStrategies = @("git", "shadow", "both")
150
+ if ($cfg.backup_strategy -and $cfg.backup_strategy -notin $validStrategies) {
151
+ Write-Check "Config: backup_strategy" "FAIL" "invalid value '$($cfg.backup_strategy)'"
152
+ }
153
+
154
+ $validPreRestore = @("always", "ask", "never")
155
+ if ($cfg.pre_restore_backup -and $cfg.pre_restore_backup -notin $validPreRestore) {
156
+ Write-Check "Config: pre_restore_backup" "FAIL" "invalid value '$($cfg.pre_restore_backup)'"
157
+ } elseif ($cfg.pre_restore_backup -eq "never") {
158
+ Write-Check "Config: pre_restore_backup" "WARN" "set to 'never' — restores won't auto-preserve current version"
159
+ }
160
+
161
+ if ($cfg.auto_backup_interval_seconds -and $cfg.auto_backup_interval_seconds -lt 5) {
162
+ Write-Check "Config: interval" "WARN" "$($cfg.auto_backup_interval_seconds)s is below minimum (5s), will be clamped"
163
+ }
164
+
165
+ if ($cfg.retention -and $cfg.retention.mode) {
166
+ $validModes = @("days", "count", "size")
167
+ if ($cfg.retention.mode -notin $validModes) {
168
+ Write-Check "Config: retention.mode" "FAIL" "invalid value '$($cfg.retention.mode)'"
169
+ }
170
+ }
171
+ }
172
+
173
+ # ── 10. Protect / Ignore effectiveness ───────────────────────────
174
+ if ($cfg -and $cfg.protect) {
175
+ $allFiles = Get-ChildItem $resolved -Recurse -File -ErrorAction SilentlyContinue |
176
+ Where-Object { $_.FullName -notmatch '[\\/](\.git|\.cursor-guard-backup|node_modules)[\\/]' }
177
+ $protectedCount = 0
178
+ foreach ($f in $allFiles) {
179
+ $rel = $f.FullName.Substring($resolved.Length + 1) -replace '\\','/'
180
+ foreach ($pat in @($cfg.protect)) {
181
+ $p = $pat -replace '\\','/'
182
+ if ($rel -like $p -or (Split-Path $rel -Leaf) -like $p) { $protectedCount++; break }
183
+ }
184
+ }
185
+ $totalCount = if ($allFiles) { @($allFiles).Count } else { 0 }
186
+ Write-Check "Protect patterns" "PASS" "$protectedCount / $totalCount files matched by protect patterns"
187
+ }
188
+
189
+ # ── 11. Disk space ────────────────────────────────────────────────
190
+ try {
191
+ $letter = (Split-Path $resolved -Qualifier) -replace ':$',''
192
+ $drv = Get-PSDrive $letter -ErrorAction Stop
193
+ $freeGB = [math]::Round($drv.Free / 1GB, 1)
194
+ if ($freeGB -lt 1) {
195
+ Write-Check "Disk space" "FAIL" "${freeGB} GB free — critically low"
196
+ } elseif ($freeGB -lt 5) {
197
+ Write-Check "Disk space" "WARN" "${freeGB} GB free"
198
+ } else {
199
+ Write-Check "Disk space" "PASS" "${freeGB} GB free"
200
+ }
201
+ } catch {
202
+ Write-Check "Disk space" "WARN" "could not determine free space"
203
+ }
204
+
205
+ # ── 12. Lock file ────────────────────────────────────────────────
206
+ $lockFile = if ($gitDir) { Join-Path $gitDir "cursor-guard.lock" } else { Join-Path $backupDir "cursor-guard.lock" }
207
+ if (Test-Path $lockFile) {
208
+ $lockContent = Get-Content $lockFile -Raw -ErrorAction SilentlyContinue
209
+ Write-Check "Lock file" "WARN" "lock file exists — another instance may be running. Content: $lockContent"
210
+ } else {
211
+ Write-Check "Lock file" "PASS" "no lock file (no running instance)"
212
+ }
213
+
214
+ # ── Summary ──────────────────────────────────────────────────────
215
+ Write-Host ""
216
+ Write-Host "=== Summary ===" -ForegroundColor Cyan
217
+ Write-Host " PASS: $pass | WARN: $warn | FAIL: $fail" -ForegroundColor $(if ($fail -gt 0) { "Red" } elseif ($warn -gt 0) { "Yellow" } else { "Green" })
218
+ Write-Host ""
219
+ if ($fail -gt 0) {
220
+ Write-Host " Fix FAIL items before relying on Cursor Guard." -ForegroundColor Red
221
+ } elseif ($warn -gt 0) {
222
+ Write-Host " Review WARN items to ensure everything works as expected." -ForegroundColor Yellow
223
+ } else {
224
+ Write-Host " All checks passed. Cursor Guard is ready." -ForegroundColor Green
225
+ }
226
+ Write-Host ""
@@ -13,8 +13,7 @@ Replace `<path>` / `<file>` with real paths. Run from repository root. **Review
13
13
  ### Single file / 单文件
14
14
 
15
15
  ```powershell
16
- # Preserve current file state via temp index (does not touch staging area)
17
- # 通过临时索引保留当前文件(不影响暂存区)
16
+ $ts = Get-Date -Format 'yyyyMMdd_HHmmss'
18
17
  $guardIdx = Join-Path (git rev-parse --git-dir) "guard-pre-restore-index"
19
18
  $env:GIT_INDEX_FILE = $guardIdx
20
19
  git read-tree HEAD
@@ -23,14 +22,15 @@ $tree = git write-tree
23
22
  $env:GIT_INDEX_FILE = $null
24
23
  Remove-Item $guardIdx -Force -ErrorAction SilentlyContinue
25
24
  $commit = git commit-tree $tree -p HEAD -m "guard: preserve current before restore"
26
- git update-ref refs/guard/pre-restore $commit
27
- Write-Host "Pre-restore backup: $($commit.Substring(0,7))"
25
+ git update-ref "refs/guard/pre-restore/$ts" $commit
26
+ git update-ref refs/guard/pre-restore $commit # alias to latest
27
+ Write-Host "Pre-restore backup: refs/guard/pre-restore/$ts ($($commit.Substring(0,7)))"
28
28
  ```
29
29
 
30
30
  ### Entire project / 整个项目
31
31
 
32
32
  ```powershell
33
- # Same as above but with git add -A
33
+ $ts = Get-Date -Format 'yyyyMMdd_HHmmss'
34
34
  $guardIdx = Join-Path (git rev-parse --git-dir) "guard-pre-restore-index"
35
35
  $env:GIT_INDEX_FILE = $guardIdx
36
36
  git read-tree HEAD
@@ -39,8 +39,9 @@ $tree = git write-tree
39
39
  $env:GIT_INDEX_FILE = $null
40
40
  Remove-Item $guardIdx -Force -ErrorAction SilentlyContinue
41
41
  $commit = git commit-tree $tree -p HEAD -m "guard: preserve current before restore"
42
- git update-ref refs/guard/pre-restore $commit
43
- Write-Host "Pre-restore backup: $($commit.Substring(0,7))"
42
+ git update-ref "refs/guard/pre-restore/$ts" $commit
43
+ git update-ref refs/guard/pre-restore $commit # alias to latest
44
+ Write-Host "Pre-restore backup: refs/guard/pre-restore/$ts ($($commit.Substring(0,7)))"
44
45
  ```
45
46
 
46
47
  ### Non-Git fallback (shadow copy) / 非 Git 备选方案
@@ -53,21 +54,40 @@ Copy-Item "<file>" "$dir/<filename>"
53
54
  Write-Host "Pre-restore shadow copy: $dir"
54
55
  ```
55
56
 
57
+ ### List all pre-restore snapshots / 列出所有恢复前快照
58
+
59
+ ```bash
60
+ git for-each-ref refs/guard/pre-restore/ --sort=-creatordate \
61
+ --format="%(refname:short) %(creatordate:short) %(objectname:short)"
62
+ ```
63
+
56
64
  ### Undo a restore (recover pre-restore state) / 撤销恢复(回到恢复前的状态)
57
65
 
58
66
  ```bash
59
- # Restore single file to pre-restore state
60
- # 将单个文件恢复到恢复前的状态
67
+ # Undo using a specific timestamped snapshot
68
+ # 使用特定时间戳快照撤销恢复
69
+ git restore --source=refs/guard/pre-restore/<yyyyMMdd_HHmmss> -- <file>
70
+
71
+ # Undo using the latest pre-restore snapshot (alias)
72
+ # 使用最近一次的恢复前快照撤销
61
73
  git restore --source=refs/guard/pre-restore -- <file>
62
74
 
63
- # Restore entire project to pre-restore state
64
- # 将整个项目恢复到恢复前的状态
65
- git restore --source=refs/guard/pre-restore -- .
75
+ # Restore entire project / 恢复整个项目
76
+ git restore --source=refs/guard/pre-restore/<yyyyMMdd_HHmmss> -- .
66
77
 
67
78
  # From shadow copy / 从影子拷贝恢复
68
79
  Copy-Item ".cursor-guard-backup/pre-restore-<ts>/<file>" "<original-path>"
69
80
  ```
70
81
 
82
+ ### Clean up old pre-restore refs / 清理旧的恢复前快照
83
+
84
+ ```bash
85
+ # Delete all pre-restore refs (keep only the latest alias)
86
+ # 删除所有时间戳快照(仅保留 latest alias)
87
+ git for-each-ref refs/guard/pre-restore/ --format="%(refname)" |
88
+ xargs -n1 git update-ref -d
89
+ ```
90
+
71
91
  ---
72
92
 
73
93
  ## Inspect current state