cursor-guard 4.4.0 → 4.4.1
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 +2 -0
- package/README.zh-CN.md +2 -0
- package/SKILL.md +4 -2
- package/package.json +1 -1
- package/references/bin/cursor-guard-backup.js +9 -1
- package/references/dashboard/public/app.js +61 -21
- package/references/dashboard/public/index.html +13 -19
- package/references/dashboard/public/style.css +88 -14
- package/references/dashboard/server.js +118 -51
- package/references/lib/auto-backup.js +13 -1
- package/references/lib/core/backups.js +15 -14
- package/references/lib/core/doctor-fix.js +5 -0
- package/references/lib/core/restore.js +31 -7
- package/references/lib/core/snapshot.js +22 -4
package/README.md
CHANGED
|
@@ -380,9 +380,11 @@ The skill activates on these signals:
|
|
|
380
380
|
### v4.4.0 — V4 Final
|
|
381
381
|
|
|
382
382
|
- **Fix**: First snapshot now generates "Added N: file1, file2, ..." summary instead of blank — previously the very first backup had no summary because there was no parent tree to diff against
|
|
383
|
+
- **Feature**: `--dashboard` flag for watcher — `npx cursor-guard-backup --path <dir> --dashboard` starts the web dashboard alongside the watcher in a single process. Optional port: `--dashboard 4000`. Auto-increments if port is busy
|
|
383
384
|
- **Feature**: Doctor check "Git retention" — warns when git backup commits exceed 500 and `git_retention.enabled` is `false`, guiding users to enable auto-pruning before refs grow unbounded
|
|
384
385
|
- **Feature**: Doctor check "Backup integrity" — verifies that the latest auto-backup commit's tree object is reachable via `git cat-file -t`, catching silent corruption early
|
|
385
386
|
- **Improve**: `cursor-guard-init` now detects existing `.cursor-guard.json` and displays an upgrade notice instead of silently overwriting
|
|
387
|
+
- **Improve**: Dashboard server refactored to export `startDashboardServer()` for embedding into other processes
|
|
386
388
|
|
|
387
389
|
### v4.3.5
|
|
388
390
|
|
package/README.zh-CN.md
CHANGED
|
@@ -380,9 +380,11 @@ node references\dashboard\server.js --path "D:\MyProject"
|
|
|
380
380
|
### v4.4.0 — V4 收官版
|
|
381
381
|
|
|
382
382
|
- **修复**:首次快照现在会生成 "Added N: file1, file2, ..." 摘要,而不是空白——之前第一次备份因为没有 parent tree 对比所以 summary 始终为空
|
|
383
|
+
- **功能**:Watcher `--dashboard` 参数——`npx cursor-guard-backup --path <dir> --dashboard` 启动时同时启动 Web 仪表盘,单进程完成监控+查看。可选端口:`--dashboard 4000`,端口被占自动递增
|
|
383
384
|
- **功能**:Doctor 新增 "Git retention" 检查——当 Git 备份 commit 数超过 500 且 `git_retention.enabled` 为 `false` 时发出 WARN,引导用户开启自动清理防止 ref 无限增长
|
|
384
385
|
- **功能**:Doctor 新增 "Backup integrity" 检查——通过 `git cat-file -t` 验证最近一次 auto-backup commit 的 tree 对象是否可达,尽早发现静默损坏
|
|
385
386
|
- **改进**:`cursor-guard-init` 现在检测已有 `.cursor-guard.json`,显示升级提示而非静默覆盖
|
|
387
|
+
- **改进**:Dashboard server 重构,导出 `startDashboardServer()` 供嵌入其他进程使用
|
|
386
388
|
|
|
387
389
|
### v4.3.5
|
|
388
390
|
|
package/SKILL.md
CHANGED
|
@@ -147,7 +147,7 @@ When the target file of an edit **falls outside the protected scope**, the agent
|
|
|
147
147
|
|
|
148
148
|
> **MCP shortcut**: if `snapshot_now` tool is available, call it with `{ "path": "<project>", "strategy": "git" }` instead of the shell commands below. The tool handles temp index, secrets exclusion, and ref creation internally, and returns `{ "git": { "status": "created", "commitHash": "...", "shortHash": "..." } }`. Report the `shortHash` to the user and proceed.
|
|
149
149
|
>
|
|
150
|
-
> **Best practice — intent context**:
|
|
150
|
+
> **Best practice — intent context**: Before making high-risk changes, call `snapshot_now` with `intent` to directly record what you are about to do. The intent is stored in the Git commit trailer — no intermediary, no file bridge, no concurrency issues. Example:
|
|
151
151
|
> ```json
|
|
152
152
|
> {
|
|
153
153
|
> "path": "/project",
|
|
@@ -158,7 +158,9 @@ When the target file of an edit **falls outside the protected scope**, the agent
|
|
|
158
158
|
> "session": "6290c87f"
|
|
159
159
|
> }
|
|
160
160
|
> ```
|
|
161
|
-
> The `intent`, `agent`, and `session` fields are stored as Git commit trailers and displayed in the dashboard restore-point list and detail drawer
|
|
161
|
+
> The `intent`, `agent`, and `session` fields are stored as Git commit trailers and displayed in the dashboard restore-point list and detail drawer.
|
|
162
|
+
>
|
|
163
|
+
> **Timeline the user sees**: manual snapshot with intent ("AI准备重构 calculator.js") → auto-backup with file changes ("Modified 2: src/app.js (+15 -3)"). The causal relationship is clear from ordering — the manual snapshot explains WHY, the auto-backup shows WHAT changed.
|
|
162
164
|
|
|
163
165
|
Use a **temporary index and dedicated ref** so the user's staged/unstaged state is never touched:
|
|
164
166
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-guard",
|
|
3
|
-
"version": "4.4.
|
|
3
|
+
"version": "4.4.1",
|
|
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",
|
|
@@ -12,6 +12,7 @@ if (args.help || args.h) {
|
|
|
12
12
|
Options:
|
|
13
13
|
--path <dir> Project directory to watch (default: current dir)
|
|
14
14
|
--interval <sec> Override backup interval in seconds
|
|
15
|
+
--dashboard [port] Start dashboard server alongside watcher (default port: 3120)
|
|
15
16
|
--help, -h Show this help message
|
|
16
17
|
--version, -v Show version number`);
|
|
17
18
|
process.exit(0);
|
|
@@ -27,5 +28,12 @@ const targetPath = args.path || '.';
|
|
|
27
28
|
const interval = parseInt(args.interval, 10) || 0;
|
|
28
29
|
const resolved = path.resolve(targetPath);
|
|
29
30
|
|
|
31
|
+
const opts = {};
|
|
32
|
+
if (args.dashboard !== undefined) {
|
|
33
|
+
opts.dashboardPort = (typeof args.dashboard === 'string' && /^\d+$/.test(args.dashboard))
|
|
34
|
+
? parseInt(args.dashboard, 10)
|
|
35
|
+
: 3120;
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
const { runBackup } = require('../lib/auto-backup');
|
|
31
|
-
runBackup(resolved, interval);
|
|
39
|
+
runBackup(resolved, interval, opts);
|
|
@@ -565,7 +565,9 @@ function relativeTime(ts) {
|
|
|
565
565
|
/* ── Data fetching ────────────────────────────────────────── */
|
|
566
566
|
|
|
567
567
|
async function fetchJson(url) {
|
|
568
|
-
const
|
|
568
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
569
|
+
const tokenParam = window.__GUARD_TOKEN__ ? `${sep}token=${window.__GUARD_TOKEN__}` : '';
|
|
570
|
+
const r = await fetch(url + tokenParam);
|
|
569
571
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
570
572
|
return r.json();
|
|
571
573
|
}
|
|
@@ -577,10 +579,45 @@ async function loadProjects() {
|
|
|
577
579
|
}
|
|
578
580
|
}
|
|
579
581
|
|
|
580
|
-
async function loadPageData() {
|
|
582
|
+
async function loadPageData(opts = {}) {
|
|
581
583
|
if (!state.currentProjectId) return;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
+
const id = state.currentProjectId;
|
|
585
|
+
|
|
586
|
+
if (opts.progressive) {
|
|
587
|
+
state.pageData = { dashboard: null, doctor: null, backups: null };
|
|
588
|
+
const dashPromise = fetchJson(`/api/page-data?id=${id}&scope=dashboard`);
|
|
589
|
+
const restPromise = Promise.allSettled([
|
|
590
|
+
fetchJson(`/api/page-data?id=${id}&scope=backups`),
|
|
591
|
+
fetchJson(`/api/page-data?id=${id}&scope=doctor`),
|
|
592
|
+
]);
|
|
593
|
+
|
|
594
|
+
const dash = await dashPromise;
|
|
595
|
+
state.pageData.dashboard = dash.dashboard;
|
|
596
|
+
state.lastRefreshAt = Date.now();
|
|
597
|
+
showContent();
|
|
598
|
+
if (dash.dashboard && !dash.dashboard.error) {
|
|
599
|
+
renderStrategyBadge(dash.dashboard.strategy);
|
|
600
|
+
renderOverview(dash.dashboard);
|
|
601
|
+
renderProtection(dash.dashboard.protectionScope);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const [backupsResult, doctorResult] = await restPromise;
|
|
605
|
+
if (backupsResult.status === 'fulfilled') {
|
|
606
|
+
state.pageData.backups = backupsResult.value.backups;
|
|
607
|
+
if (state.pageData.dashboard) {
|
|
608
|
+
renderBackupsSection(state.pageData.dashboard, Array.isArray(state.pageData.backups) ? state.pageData.backups : []);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (doctorResult.status === 'fulfilled') {
|
|
612
|
+
state.pageData.doctor = doctorResult.value.doctor;
|
|
613
|
+
if (state.pageData.doctor && !state.pageData.doctor.error) {
|
|
614
|
+
renderDiagnostics(state.pageData.doctor);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
} else {
|
|
618
|
+
state.pageData = await fetchJson(`/api/page-data?id=${state.currentProjectId}`);
|
|
619
|
+
state.lastRefreshAt = Date.now();
|
|
620
|
+
}
|
|
584
621
|
}
|
|
585
622
|
|
|
586
623
|
/* ── Refresh ──────────────────────────────────────────────── */
|
|
@@ -633,21 +670,18 @@ function renderStrategyBadge(strategy) {
|
|
|
633
670
|
|
|
634
671
|
/* ── Rendering: Global states ─────────────────────────────── */
|
|
635
672
|
|
|
636
|
-
function
|
|
637
|
-
show($('#loading-state'));
|
|
673
|
+
function showSkeleton() {
|
|
638
674
|
hide($('#error-state'));
|
|
639
|
-
$$('.screen').forEach(s =>
|
|
675
|
+
$$('.screen').forEach(s => show(s));
|
|
640
676
|
}
|
|
641
677
|
|
|
642
678
|
function showGlobalError(msg) {
|
|
643
|
-
hide($('#loading-state'));
|
|
644
679
|
show($('#error-state'));
|
|
645
680
|
$$('.screen').forEach(s => hide(s));
|
|
646
681
|
$('#error-message').textContent = msg || t('error.fetchFailed');
|
|
647
682
|
}
|
|
648
683
|
|
|
649
684
|
function showContent() {
|
|
650
|
-
hide($('#loading-state'));
|
|
651
685
|
hide($('#error-state'));
|
|
652
686
|
$$('.screen').forEach(s => show(s));
|
|
653
687
|
}
|
|
@@ -819,9 +853,10 @@ function translateSummary(raw) {
|
|
|
819
853
|
}
|
|
820
854
|
|
|
821
855
|
function formatSummaryCell(b) {
|
|
822
|
-
|
|
823
|
-
if (b.filesChanged != null)
|
|
824
|
-
|
|
856
|
+
let line1 = '';
|
|
857
|
+
if (b.filesChanged != null) {
|
|
858
|
+
line1 = `<div class="summary-meta"><span class="summary-files">${b.filesChanged} ${t('summary.files')}</span></div>`;
|
|
859
|
+
}
|
|
825
860
|
|
|
826
861
|
let line2 = '';
|
|
827
862
|
if (b.intent) {
|
|
@@ -834,13 +869,12 @@ function formatSummaryCell(b) {
|
|
|
834
869
|
|
|
835
870
|
let line3 = '';
|
|
836
871
|
if (b.summary) {
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
line3 = `<div class="summary-detail">${esc(short)}</div>`;
|
|
872
|
+
const categories = b.summary.split('; ').map(s => translateSummary(s));
|
|
873
|
+
line3 = categories.map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
|
|
840
874
|
}
|
|
841
875
|
|
|
842
|
-
if (!line1
|
|
843
|
-
return `<div class="summary-stack">${line1
|
|
876
|
+
if (!line1 && !line2 && !line3) return '<span class="text-muted text-sm">-</span>';
|
|
877
|
+
return `<div class="summary-stack">${line1}${line2}${line3}</div>`;
|
|
844
878
|
}
|
|
845
879
|
|
|
846
880
|
function renderBackupTable(backups) {
|
|
@@ -971,7 +1005,10 @@ function openRestoreDrawer(backup) {
|
|
|
971
1005
|
if (backup.agent) fields.push({ key: 'drawer.field.agent', val: backup.agent });
|
|
972
1006
|
if (backup.session) fields.push({ key: 'drawer.field.session', val: backup.session });
|
|
973
1007
|
if (backup.message) fields.push({ key: 'drawer.field.message', val: backup.message });
|
|
974
|
-
if (backup.summary)
|
|
1008
|
+
if (backup.summary) {
|
|
1009
|
+
const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
|
|
1010
|
+
fields.push({ key: 'drawer.field.summary', val: translated, pre: true });
|
|
1011
|
+
}
|
|
975
1012
|
|
|
976
1013
|
const refText = backup.ref || backup.shortHash || backup.timestamp || '';
|
|
977
1014
|
const jsonText = JSON.stringify(backup, null, 2);
|
|
@@ -980,7 +1017,10 @@ function openRestoreDrawer(backup) {
|
|
|
980
1017
|
${fields.map(f => `
|
|
981
1018
|
<div class="restore-field">
|
|
982
1019
|
<div class="restore-field-label">${t(f.key)}</div>
|
|
983
|
-
|
|
1020
|
+
${f.pre
|
|
1021
|
+
? `<pre class="restore-field-value text-mono summary-pre">${esc(f.val)}</pre>`
|
|
1022
|
+
: `<div class="restore-field-value text-mono">${esc(f.val)}</div>`
|
|
1023
|
+
}
|
|
984
1024
|
</div>
|
|
985
1025
|
`).join('')}
|
|
986
1026
|
<div class="restore-actions">
|
|
@@ -1123,12 +1163,12 @@ async function init() {
|
|
|
1123
1163
|
document.documentElement.lang = state.locale === 'zh-CN' ? 'zh-CN' : 'en';
|
|
1124
1164
|
document.title = t('app.title');
|
|
1125
1165
|
updateStaticI18n();
|
|
1126
|
-
|
|
1166
|
+
showSkeleton();
|
|
1127
1167
|
|
|
1128
1168
|
try {
|
|
1129
1169
|
await loadProjects();
|
|
1130
1170
|
renderProjectSelect();
|
|
1131
|
-
await loadPageData();
|
|
1171
|
+
await loadPageData({ progressive: true });
|
|
1132
1172
|
renderAll();
|
|
1133
1173
|
startRefresh();
|
|
1134
1174
|
} catch (e) {
|
|
@@ -33,12 +33,6 @@
|
|
|
33
33
|
|
|
34
34
|
<main id="content">
|
|
35
35
|
|
|
36
|
-
<!-- Loading -->
|
|
37
|
-
<div id="loading-state" class="state-panel">
|
|
38
|
-
<div class="spinner"></div>
|
|
39
|
-
<p data-i18n="state.loading">Loading…</p>
|
|
40
|
-
</div>
|
|
41
|
-
|
|
42
36
|
<!-- Global Error -->
|
|
43
37
|
<div id="error-state" class="state-panel hidden">
|
|
44
38
|
<div class="error-icon">⚠</div>
|
|
@@ -47,35 +41,35 @@
|
|
|
47
41
|
</div>
|
|
48
42
|
|
|
49
43
|
<!-- Screen 1: Overview ───────────────────────────────── -->
|
|
50
|
-
<section id="screen-overview" class="screen
|
|
44
|
+
<section id="screen-overview" class="screen">
|
|
51
45
|
<h2 class="section-title" data-i18n="overview.title">Overview</h2>
|
|
52
46
|
<div id="overview-grid" class="card-grid">
|
|
53
|
-
<div id="card-health" class="card card-health"></div>
|
|
54
|
-
<div id="card-git-backup" class="card"></div>
|
|
55
|
-
<div id="card-shadow-backup" class="card"></div>
|
|
56
|
-
<div id="card-watcher" class="card"></div>
|
|
57
|
-
<div id="card-alert" class="card"></div>
|
|
47
|
+
<div id="card-health" class="card card-health"><div class="skeleton-block"></div></div>
|
|
48
|
+
<div id="card-git-backup" class="card"><div class="skeleton-block"></div></div>
|
|
49
|
+
<div id="card-shadow-backup" class="card"><div class="skeleton-block"></div></div>
|
|
50
|
+
<div id="card-watcher" class="card"><div class="skeleton-block"></div></div>
|
|
51
|
+
<div id="card-alert" class="card"><div class="skeleton-block"></div></div>
|
|
58
52
|
</div>
|
|
59
53
|
</section>
|
|
60
54
|
|
|
61
55
|
<!-- Screen 2: Backups & Recovery ─────────────────────── -->
|
|
62
|
-
<section id="screen-backups" class="screen
|
|
56
|
+
<section id="screen-backups" class="screen">
|
|
63
57
|
<h2 class="section-title" data-i18n="backups.title">Backups & Recovery</h2>
|
|
64
|
-
<div id="backup-stats" class="stats-row"></div>
|
|
58
|
+
<div id="backup-stats" class="stats-row"><div class="skeleton-row"></div></div>
|
|
65
59
|
<div id="backup-filters" class="filter-bar"></div>
|
|
66
|
-
<div id="backup-table-wrap" class="table-wrap"></div>
|
|
60
|
+
<div id="backup-table-wrap" class="table-wrap"><div class="skeleton-table"></div></div>
|
|
67
61
|
</section>
|
|
68
62
|
|
|
69
63
|
<!-- Screen 3: Protection Scope ───────────────────────── -->
|
|
70
|
-
<section id="screen-protection" class="screen
|
|
64
|
+
<section id="screen-protection" class="screen">
|
|
71
65
|
<h2 class="section-title" data-i18n="protection.title">Protection Scope</h2>
|
|
72
|
-
<div id="protection-content"></div>
|
|
66
|
+
<div id="protection-content"><div class="skeleton-block"></div></div>
|
|
73
67
|
</section>
|
|
74
68
|
|
|
75
69
|
<!-- Screen 4: Diagnostics ────────────────────────────── -->
|
|
76
|
-
<section id="screen-diagnostics" class="screen
|
|
70
|
+
<section id="screen-diagnostics" class="screen">
|
|
77
71
|
<h2 class="section-title" data-i18n="diagnostics.title">Diagnostics</h2>
|
|
78
|
-
<div id="diagnostics-summary"></div>
|
|
72
|
+
<div id="diagnostics-summary"><div class="skeleton-block"></div></div>
|
|
79
73
|
</section>
|
|
80
74
|
|
|
81
75
|
</main>
|
|
@@ -326,38 +326,64 @@ main {
|
|
|
326
326
|
.badge-trigger { background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.7rem; border-color: var(--border-subtle); }
|
|
327
327
|
.badge-intent { background: var(--blue-bg); color: var(--blue); font-size: 0.7rem; border-color: rgba(59,130,246,.18); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: middle; }
|
|
328
328
|
|
|
329
|
-
.backup-summary-cell { max-width:
|
|
329
|
+
.backup-summary-cell { max-width: 420px; min-width: 180px; }
|
|
330
330
|
|
|
331
|
-
.summary-stack { display: flex; flex-direction: column; gap: 4px; }
|
|
332
|
-
.summary-meta { display: flex; align-items: center; gap:
|
|
333
|
-
.summary-files {
|
|
331
|
+
.summary-stack { display: flex; flex-direction: column; gap: 6px; padding: 4px 0; }
|
|
332
|
+
.summary-meta { display: flex; align-items: center; gap: 8px; }
|
|
333
|
+
.summary-files {
|
|
334
|
+
font-size: 13px;
|
|
335
|
+
font-weight: 700;
|
|
336
|
+
color: var(--text-heading);
|
|
337
|
+
font-variant-numeric: tabular-nums;
|
|
338
|
+
}
|
|
334
339
|
.summary-intent {
|
|
335
340
|
font-size: 12px;
|
|
336
341
|
color: var(--blue);
|
|
337
342
|
background: var(--blue-bg);
|
|
338
|
-
padding:
|
|
339
|
-
border-radius:
|
|
340
|
-
border-left:
|
|
343
|
+
padding: 4px 10px;
|
|
344
|
+
border-radius: 4px;
|
|
345
|
+
border-left: 3px solid var(--blue);
|
|
341
346
|
overflow: hidden;
|
|
342
347
|
text-overflow: ellipsis;
|
|
343
348
|
white-space: nowrap;
|
|
344
|
-
max-width:
|
|
349
|
+
max-width: 400px;
|
|
350
|
+
line-height: 1.4;
|
|
345
351
|
}
|
|
346
352
|
.summary-message {
|
|
347
353
|
font-size: 12px;
|
|
348
|
-
color: var(--text-
|
|
354
|
+
color: var(--text-primary);
|
|
355
|
+
background: var(--bg-tertiary);
|
|
356
|
+
padding: 3px 8px;
|
|
357
|
+
border-radius: 3px;
|
|
358
|
+
border-left: 3px solid var(--text-tertiary);
|
|
349
359
|
overflow: hidden;
|
|
350
360
|
text-overflow: ellipsis;
|
|
351
361
|
white-space: nowrap;
|
|
352
|
-
max-width:
|
|
362
|
+
max-width: 400px;
|
|
363
|
+
line-height: 1.4;
|
|
353
364
|
}
|
|
354
|
-
.summary-detail {
|
|
355
|
-
font-size:
|
|
356
|
-
color: var(--text-
|
|
365
|
+
.summary-detail-line {
|
|
366
|
+
font-size: 12px;
|
|
367
|
+
color: var(--text-secondary);
|
|
368
|
+
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
|
369
|
+
line-height: 1.5;
|
|
370
|
+
padding: 1px 0;
|
|
357
371
|
overflow: hidden;
|
|
358
372
|
text-overflow: ellipsis;
|
|
359
373
|
white-space: nowrap;
|
|
360
|
-
max-width:
|
|
374
|
+
max-width: 400px;
|
|
375
|
+
}
|
|
376
|
+
.summary-detail-line:first-child { padding-top: 2px; }
|
|
377
|
+
|
|
378
|
+
.summary-pre {
|
|
379
|
+
font-size: 12px;
|
|
380
|
+
line-height: 1.6;
|
|
381
|
+
white-space: pre-wrap;
|
|
382
|
+
margin: 0;
|
|
383
|
+
padding: 8px 10px;
|
|
384
|
+
background: var(--bg-tertiary);
|
|
385
|
+
border-radius: 4px;
|
|
386
|
+
border-left: 3px solid var(--blue);
|
|
361
387
|
}
|
|
362
388
|
|
|
363
389
|
/* ── Stats Row ────────────────────────────────────────────── */
|
|
@@ -776,6 +802,54 @@ main {
|
|
|
776
802
|
|
|
777
803
|
.icon-spin-active { animation: spin .6s linear infinite; }
|
|
778
804
|
|
|
805
|
+
/* ── Skeleton loading ────────────────────────────────────── */
|
|
806
|
+
|
|
807
|
+
@keyframes shimmer {
|
|
808
|
+
0% { background-position: -200% 0; }
|
|
809
|
+
100% { background-position: 200% 0; }
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
.skeleton-block {
|
|
813
|
+
height: 48px;
|
|
814
|
+
border-radius: var(--radius-sm);
|
|
815
|
+
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
|
|
816
|
+
background-size: 200% 100%;
|
|
817
|
+
animation: shimmer 1.5s ease-in-out infinite;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
.skeleton-row {
|
|
821
|
+
display: flex;
|
|
822
|
+
gap: 12px;
|
|
823
|
+
}
|
|
824
|
+
.skeleton-row::before,
|
|
825
|
+
.skeleton-row::after {
|
|
826
|
+
content: '';
|
|
827
|
+
flex: 1;
|
|
828
|
+
height: 64px;
|
|
829
|
+
border-radius: var(--radius-sm);
|
|
830
|
+
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
|
|
831
|
+
background-size: 200% 100%;
|
|
832
|
+
animation: shimmer 1.5s ease-in-out infinite;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
.skeleton-table {
|
|
836
|
+
display: flex;
|
|
837
|
+
flex-direction: column;
|
|
838
|
+
gap: 8px;
|
|
839
|
+
padding: 12px 0;
|
|
840
|
+
}
|
|
841
|
+
.skeleton-table::before,
|
|
842
|
+
.skeleton-table::after {
|
|
843
|
+
content: '';
|
|
844
|
+
display: block;
|
|
845
|
+
height: 36px;
|
|
846
|
+
border-radius: var(--radius-sm);
|
|
847
|
+
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
|
|
848
|
+
background-size: 200% 100%;
|
|
849
|
+
animation: shimmer 1.5s ease-in-out infinite;
|
|
850
|
+
}
|
|
851
|
+
.skeleton-table::after { width: 75%; opacity: .6; }
|
|
852
|
+
|
|
779
853
|
/* ── Utility ──────────────────────────────────────────────── */
|
|
780
854
|
|
|
781
855
|
.hidden { display: none !important; }
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const http = require('http');
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const path = require('path');
|
|
7
8
|
|
|
@@ -12,6 +13,7 @@ const { listBackups } = require('../lib/core/backups');
|
|
|
12
13
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
13
14
|
const DEFAULT_PORT = 3120;
|
|
14
15
|
const MAX_PORT_RETRIES = 10;
|
|
16
|
+
const ALLOWED_HOSTS = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
|
|
15
17
|
|
|
16
18
|
const MIME = {
|
|
17
19
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -78,7 +80,7 @@ function forbidden(res) { res.writeHead(403); res.end('Forbidden'); }
|
|
|
78
80
|
|
|
79
81
|
/* ── Static file server (strict) ────────────────────────────── */
|
|
80
82
|
|
|
81
|
-
function serveStatic(reqUrl, res) {
|
|
83
|
+
function serveStatic(reqUrl, res, serverToken) {
|
|
82
84
|
let pathname;
|
|
83
85
|
try { pathname = decodeURIComponent(new URL(reqUrl, 'http://x').pathname); }
|
|
84
86
|
catch { return notFound(res); }
|
|
@@ -94,6 +96,23 @@ function serveStatic(reqUrl, res) {
|
|
|
94
96
|
fs.readFile(resolved, (err, data) => {
|
|
95
97
|
if (err) return notFound(res);
|
|
96
98
|
const ext = path.extname(resolved).toLowerCase();
|
|
99
|
+
|
|
100
|
+
// Inject per-process token into index.html so the frontend can authenticate API calls
|
|
101
|
+
if (pathname === '/index.html' && serverToken) {
|
|
102
|
+
const html = data.toString('utf-8').replace(
|
|
103
|
+
'</head>',
|
|
104
|
+
`<script>window.__GUARD_TOKEN__="${serverToken}";</script></head>`
|
|
105
|
+
);
|
|
106
|
+
const buf = Buffer.from(html, 'utf-8');
|
|
107
|
+
res.writeHead(200, {
|
|
108
|
+
'Content-Type': MIME[ext] || 'text/html; charset=utf-8',
|
|
109
|
+
'Content-Length': buf.length,
|
|
110
|
+
'X-Content-Type-Options': 'nosniff',
|
|
111
|
+
'Cache-Control': 'no-store',
|
|
112
|
+
});
|
|
113
|
+
return res.end(buf);
|
|
114
|
+
}
|
|
115
|
+
|
|
97
116
|
res.writeHead(200, {
|
|
98
117
|
'Content-Type': MIME[ext] || 'application/octet-stream',
|
|
99
118
|
'Content-Length': data.length,
|
|
@@ -121,13 +140,21 @@ function handleApi(pathname, query, registry, res) {
|
|
|
121
140
|
}
|
|
122
141
|
|
|
123
142
|
if (pathname === '/api/page-data') {
|
|
143
|
+
const scope = query.get('scope');
|
|
124
144
|
const result = { timestamp: new Date().toISOString() };
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
145
|
+
|
|
146
|
+
if (!scope || scope === 'dashboard') {
|
|
147
|
+
try { result.dashboard = getDashboard(pp); }
|
|
148
|
+
catch (e) { result.dashboard = { error: e.message }; }
|
|
149
|
+
}
|
|
150
|
+
if (!scope || scope === 'doctor') {
|
|
151
|
+
try { result.doctor = runDiagnostics(pp); }
|
|
152
|
+
catch (e) { result.doctor = { error: e.message }; }
|
|
153
|
+
}
|
|
154
|
+
if (!scope || scope === 'backups') {
|
|
155
|
+
try { result.backups = listBackups(pp, { limit: 50 }).sources || []; }
|
|
156
|
+
catch (e) { result.backups = { error: e.message }; }
|
|
157
|
+
}
|
|
131
158
|
return json(res, result);
|
|
132
159
|
}
|
|
133
160
|
|
|
@@ -154,53 +181,93 @@ function handleApi(pathname, query, registry, res) {
|
|
|
154
181
|
|
|
155
182
|
/* ── Server ─────────────────────────────────────────────────── */
|
|
156
183
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Start the dashboard HTTP server.
|
|
186
|
+
* Can be called standalone (CLI) or embedded (from watcher).
|
|
187
|
+
*
|
|
188
|
+
* @param {string[]} paths - Project directories to serve
|
|
189
|
+
* @param {object} [opts]
|
|
190
|
+
* @param {number} [opts.port=3120] - Starting port
|
|
191
|
+
* @param {boolean} [opts.silent=false] - Suppress banner output
|
|
192
|
+
* @returns {Promise<{server: http.Server, port: number, registry: Map}>}
|
|
193
|
+
*/
|
|
194
|
+
function startDashboardServer(paths, opts = {}) {
|
|
195
|
+
const port = opts.port || DEFAULT_PORT;
|
|
196
|
+
const silent = opts.silent || false;
|
|
197
|
+
const registry = buildRegistry(paths);
|
|
198
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
199
|
+
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
let currentPort = port;
|
|
202
|
+
let retries = 0;
|
|
203
|
+
|
|
204
|
+
const server = http.createServer((req, res) => {
|
|
205
|
+
// DNS rebinding protection: reject unexpected Host headers
|
|
206
|
+
const host = req.headers.host || '';
|
|
207
|
+
if (!ALLOWED_HOSTS.test(host)) {
|
|
208
|
+
res.writeHead(403);
|
|
209
|
+
return res.end('Forbidden: invalid host');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (req.method !== 'GET') {
|
|
213
|
+
res.writeHead(405);
|
|
214
|
+
return res.end('Method Not Allowed');
|
|
215
|
+
}
|
|
216
|
+
let parsed;
|
|
217
|
+
try { parsed = new URL(req.url, `http://${host}`); }
|
|
218
|
+
catch { return notFound(res); }
|
|
219
|
+
|
|
220
|
+
// API endpoints require per-process token
|
|
221
|
+
if (parsed.pathname.startsWith('/api/')) {
|
|
222
|
+
const reqToken = parsed.searchParams.get('token');
|
|
223
|
+
if (reqToken !== token) {
|
|
224
|
+
res.writeHead(403);
|
|
225
|
+
return res.end('Forbidden: invalid token');
|
|
226
|
+
}
|
|
227
|
+
handleApi(parsed.pathname, parsed.searchParams, registry, res);
|
|
228
|
+
} else {
|
|
229
|
+
serveStatic(req.url, res, token);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
178
232
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
});
|
|
233
|
+
server.on('error', (err) => {
|
|
234
|
+
if (err.code === 'EADDRINUSE' && retries < MAX_PORT_RETRIES) {
|
|
235
|
+
retries++;
|
|
236
|
+
currentPort++;
|
|
237
|
+
server.listen(currentPort, '127.0.0.1');
|
|
238
|
+
} else {
|
|
239
|
+
reject(err);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
189
242
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
243
|
+
server.on('listening', () => {
|
|
244
|
+
const addr = server.address();
|
|
245
|
+
if (!silent) {
|
|
246
|
+
console.log('');
|
|
247
|
+
console.log(' Cursor Guard Dashboard');
|
|
248
|
+
console.log(' ─────────────────────────');
|
|
249
|
+
console.log(` URL: http://127.0.0.1:${addr.port}`);
|
|
250
|
+
console.log(` Projects: ${registry.size}`);
|
|
251
|
+
for (const p of registry.values()) {
|
|
252
|
+
console.log(` [${p.id}] ${p.name} → ${p._path}`);
|
|
253
|
+
}
|
|
254
|
+
console.log('');
|
|
255
|
+
}
|
|
256
|
+
resolve({ server, port: addr.port, registry });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
server.listen(currentPort, '127.0.0.1');
|
|
201
260
|
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* ── CLI entry ─────────────────────────────────────────────── */
|
|
202
264
|
|
|
203
|
-
|
|
265
|
+
if (require.main === module) {
|
|
266
|
+
const args = parseCliArgs();
|
|
267
|
+
startDashboardServer(args.paths, { port: args.port }).catch(err => {
|
|
268
|
+
console.error('Failed to start:', err.message);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
});
|
|
204
271
|
}
|
|
205
272
|
|
|
206
|
-
|
|
273
|
+
module.exports = { startDashboardServer };
|
|
@@ -25,7 +25,7 @@ function isProcessAlive(pid) {
|
|
|
25
25
|
|
|
26
26
|
// ── Main ────────────────────────────────────────────────────────
|
|
27
27
|
|
|
28
|
-
async function runBackup(projectDir, intervalOverride) {
|
|
28
|
+
async function runBackup(projectDir, intervalOverride, opts = {}) {
|
|
29
29
|
const hasGit = gitAvailable();
|
|
30
30
|
const repo = hasGit && isGitRepo(projectDir);
|
|
31
31
|
const gDir = repo ? getGitDir(projectDir) : null;
|
|
@@ -177,6 +177,18 @@ async function runBackup(projectDir, intervalOverride) {
|
|
|
177
177
|
console.log(color.cyan(`[guard] Watching '${projectDir}' every ${interval}s (Ctrl+C to stop)`));
|
|
178
178
|
console.log(color.cyan(`[guard] Strategy: ${cfg.backup_strategy} | Ref: ${branchRef} | Retention: ${cfg.retention.mode}`));
|
|
179
179
|
console.log(color.cyan(`[guard] Log: ${logFilePath}`));
|
|
180
|
+
|
|
181
|
+
// Optional embedded dashboard
|
|
182
|
+
if (opts.dashboardPort) {
|
|
183
|
+
try {
|
|
184
|
+
const { startDashboardServer } = require('../dashboard/server');
|
|
185
|
+
const { port } = await startDashboardServer([projectDir], { port: opts.dashboardPort, silent: true });
|
|
186
|
+
console.log(color.cyan(`[guard] Dashboard: http://127.0.0.1:${port}`));
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.log(color.yellow(`[guard] Dashboard failed to start: ${e.message}`));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
180
192
|
console.log('');
|
|
181
193
|
|
|
182
194
|
// Main loop
|
|
@@ -313,25 +313,25 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
|
|
|
313
313
|
return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'retention disabled' };
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
const
|
|
316
|
+
const RS = '\x1e', US = '\x1f';
|
|
317
|
+
const out = git(['log', branchRef, `--format=%H${US}%aI${US}%cI${US}%s${US}%B${RS}`], { cwd, allowFail: true });
|
|
317
318
|
if (!out) {
|
|
318
319
|
return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'no commits on ref' };
|
|
319
320
|
}
|
|
320
321
|
|
|
321
|
-
const
|
|
322
|
+
const records = out.split(RS).filter(r => r.trim());
|
|
322
323
|
const guardCommits = [];
|
|
323
|
-
for (const
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
const
|
|
329
|
-
const
|
|
330
|
-
const
|
|
324
|
+
for (const record of records) {
|
|
325
|
+
const fields = record.split(US);
|
|
326
|
+
if (fields.length < 5) continue;
|
|
327
|
+
const hash = fields[0].trim();
|
|
328
|
+
const authorDate = fields[1].trim();
|
|
329
|
+
const committerDate = fields[2].trim();
|
|
330
|
+
const subject = fields[3].trim();
|
|
331
|
+
const fullBody = fields[4].trim();
|
|
331
332
|
if (subject.startsWith('guard: auto-backup') || subject.startsWith('guard: snapshot')) {
|
|
332
|
-
guardCommits.push({ hash, authorDate, committerDate, subject });
|
|
333
|
+
guardCommits.push({ hash, authorDate, committerDate, subject, fullBody });
|
|
333
334
|
}
|
|
334
|
-
// Non-guard commits are silently skipped; continue scanning older history
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
const total = guardCommits.length;
|
|
@@ -373,7 +373,8 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
|
|
|
373
373
|
if (!rootTree) {
|
|
374
374
|
return { kept: total, pruned: 0, mode, rebuilt: false, reason: 'could not resolve root tree' };
|
|
375
375
|
}
|
|
376
|
-
|
|
376
|
+
const msgOf = (c) => c.fullBody || c.subject;
|
|
377
|
+
let prevHash = commitTreeWithDate(['commit-tree', rootTree, '-m', msgOf(toKeep[0])], toKeep[0]);
|
|
377
378
|
if (!prevHash) {
|
|
378
379
|
return { kept: total, pruned: 0, mode, rebuilt: false, reason: 'commit-tree failed for root' };
|
|
379
380
|
}
|
|
@@ -383,7 +384,7 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
|
|
|
383
384
|
if (!tree) {
|
|
384
385
|
return { kept: total, pruned: 0, mode, rebuilt: false, reason: `could not resolve tree for commit ${i}` };
|
|
385
386
|
}
|
|
386
|
-
prevHash = commitTreeWithDate(['commit-tree', tree, '-p', prevHash, '-m', toKeep[i]
|
|
387
|
+
prevHash = commitTreeWithDate(['commit-tree', tree, '-p', prevHash, '-m', msgOf(toKeep[i])], toKeep[i]);
|
|
387
388
|
if (!prevHash) {
|
|
388
389
|
return { kept: total, pruned: 0, mode, rebuilt: false, reason: `commit-tree failed at index ${i}` };
|
|
389
390
|
}
|
|
@@ -92,6 +92,11 @@ function runFixes(projectDir, opts = {}) {
|
|
|
92
92
|
if (!existingIgnore.includes('.cursor-guard-backup')) {
|
|
93
93
|
missingPatterns.push('# cursor-guard shadow copies', '.cursor-guard-backup/', '');
|
|
94
94
|
}
|
|
95
|
+
const nmEntries = ['node_modules/', '.cursor/skills/**/node_modules/'];
|
|
96
|
+
const missingNm = nmEntries.filter(e => !existingIgnore.includes(e));
|
|
97
|
+
if (missingNm.length > 0) {
|
|
98
|
+
missingPatterns.push('# Dependencies', ...missingNm, '');
|
|
99
|
+
}
|
|
95
100
|
const missingSecrets = initCfg.secrets_patterns.filter(p => !existingIgnore.includes(p));
|
|
96
101
|
if (missingSecrets.length > 0) {
|
|
97
102
|
missingPatterns.push('# Secrets (cursor-guard defaults)', ...missingSecrets, '');
|
|
@@ -11,10 +11,16 @@ const { createGitSnapshot, formatTimestamp, removeSecretsFromIndex } = require('
|
|
|
11
11
|
// ── Path safety ─────────────────────────────────────────────────
|
|
12
12
|
|
|
13
13
|
function validateRelativePath(file) {
|
|
14
|
+
if (!file || typeof file !== 'string') {
|
|
15
|
+
return { valid: false, error: 'file path is required' };
|
|
16
|
+
}
|
|
14
17
|
const normalized = path.normalize(file).replace(/\\/g, '/');
|
|
15
18
|
if (path.isAbsolute(normalized) || normalized.startsWith('..')) {
|
|
16
19
|
return { valid: false, error: 'file path must be relative and within project directory' };
|
|
17
20
|
}
|
|
21
|
+
if (normalized === '.' || normalized === '') {
|
|
22
|
+
return { valid: false, error: 'file path must target a specific file, not the project root' };
|
|
23
|
+
}
|
|
18
24
|
return { valid: true, normalized };
|
|
19
25
|
}
|
|
20
26
|
|
|
@@ -66,6 +72,10 @@ function restoreFile(projectDir, file, source, opts = {}) {
|
|
|
66
72
|
return { status: 'error', restoredFrom: source, error: pathCheck.error };
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
if (isToolPath(pathCheck.normalized)) {
|
|
76
|
+
return { status: 'error', restoredFrom: source, error: `refusing to restore protected path '${pathCheck.normalized}' — use restore_project instead` };
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
const preserveCurrent = resolvePreserve(projectDir, opts);
|
|
70
80
|
const repo = isGitRepo(projectDir);
|
|
71
81
|
const result = { restoredFrom: source };
|
|
@@ -299,15 +309,29 @@ function executeProjectRestore(projectDir, source, opts = {}) {
|
|
|
299
309
|
cwd: projectDir, stdio: 'pipe',
|
|
300
310
|
});
|
|
301
311
|
|
|
302
|
-
// Restore protected paths
|
|
312
|
+
// Restore protected paths: keep HEAD state, don't let old snapshots resurrect deleted files
|
|
303
313
|
const head = git(['rev-parse', 'HEAD'], { cwd: projectDir, allowFail: true });
|
|
304
314
|
if (head) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
315
|
+
const protectedPatterns = ['.cursor/', ...GUARD_CONFIGS];
|
|
316
|
+
for (const p of protectedPatterns) {
|
|
317
|
+
const existsInHead = git(['ls-tree', '--name-only', head, '--', p], { cwd: projectDir, allowFail: true });
|
|
318
|
+
if (existsInHead) {
|
|
319
|
+
try {
|
|
320
|
+
execFileSync('git', ['restore', `--source=HEAD`, '--', p], {
|
|
321
|
+
cwd: projectDir, stdio: 'pipe',
|
|
322
|
+
});
|
|
323
|
+
} catch { /* restore failed, keep whatever is there */ }
|
|
324
|
+
} else {
|
|
325
|
+
// HEAD intentionally doesn't have this path — remove if old snapshot resurrected it
|
|
326
|
+
const fullPath = path.join(projectDir, p);
|
|
327
|
+
try {
|
|
328
|
+
if (p.endsWith('/')) {
|
|
329
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
330
|
+
} else {
|
|
331
|
+
fs.unlinkSync(fullPath);
|
|
332
|
+
}
|
|
333
|
+
} catch { /* already gone */ }
|
|
334
|
+
}
|
|
311
335
|
}
|
|
312
336
|
}
|
|
313
337
|
|
|
@@ -159,11 +159,29 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
159
159
|
const fileName = filePart.split('\t').pop();
|
|
160
160
|
groups[key].push(fileName);
|
|
161
161
|
}
|
|
162
|
+
|
|
163
|
+
const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', parentTree, newTree], { cwd, allowFail: true });
|
|
164
|
+
const stats = {};
|
|
165
|
+
if (numstatOut) {
|
|
166
|
+
for (const line of numstatOut.split('\n').filter(Boolean)) {
|
|
167
|
+
const [add, del, ...nameParts] = line.split('\t');
|
|
168
|
+
const fname = nameParts.join('\t');
|
|
169
|
+
if (add !== '-') stats[fname] = `+${add} -${del}`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function fmtFiles(arr) {
|
|
174
|
+
return arr.slice(0, 5).map(f => {
|
|
175
|
+
const s = stats[f];
|
|
176
|
+
return s ? `${f} (${s})` : f;
|
|
177
|
+
}).join(', ');
|
|
178
|
+
}
|
|
179
|
+
|
|
162
180
|
const parts = [];
|
|
163
|
-
if (groups.M.length) parts.push(`Modified ${groups.M.length}: ${groups.M.
|
|
164
|
-
if (groups.A.length) parts.push(`Added ${groups.A.length}: ${groups.A.
|
|
165
|
-
if (groups.D.length) parts.push(`Deleted ${groups.D.length}: ${groups.D.
|
|
166
|
-
if (groups.R.length) parts.push(`Renamed ${groups.R.length}: ${groups.R.
|
|
181
|
+
if (groups.M.length) parts.push(`Modified ${groups.M.length}: ${fmtFiles(groups.M)}${groups.M.length > 5 ? ', ...' : ''}`);
|
|
182
|
+
if (groups.A.length) parts.push(`Added ${groups.A.length}: ${fmtFiles(groups.A)}${groups.A.length > 5 ? ', ...' : ''}`);
|
|
183
|
+
if (groups.D.length) parts.push(`Deleted ${groups.D.length}: ${fmtFiles(groups.D)}${groups.D.length > 5 ? ', ...' : ''}`);
|
|
184
|
+
if (groups.R.length) parts.push(`Renamed ${groups.R.length}: ${fmtFiles(groups.R)}${groups.R.length > 5 ? ', ...' : ''}`);
|
|
167
185
|
if (parts.length) incrementalSummary = parts.join('; ');
|
|
168
186
|
}
|
|
169
187
|
} else {
|