@goodandready/dsh-time-machine 0.1.9 → 0.1.11
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 +15 -0
- package/README.ru.md +15 -0
- package/README.zh.md +15 -0
- package/lib/client.js +91 -4
- package/lib/index.js +69 -40
- package/lib/snapshot.js +275 -204
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -119,6 +119,21 @@ dsh-time-machine:
|
|
|
119
119
|
|
|
120
120
|
## 📋 Release Notes
|
|
121
121
|
|
|
122
|
+
### v0.1.11 — Dual Sidebar Support: Native DSH Right Sidebar & BetterSidebar
|
|
123
|
+
* **Added in v0.1.11**: Support for native DSH Right Sidebar (`sidebarRightTabs` + `sidebar.right.pane.tab`) introduced in DSH 0.1.5-alpha.1, including guide page registration with custom icon.
|
|
124
|
+
* **Preserved in v0.1.11**: Legacy `betterSidebar` integration retained with deterministic surface-scoped registrations, preventing ID collisions or duplicate mounts when both surfaces exist.
|
|
125
|
+
* **Added in v0.1.11**: Graceful degradation to plugin settings card when neither sidebar surface is available.
|
|
126
|
+
|
|
127
|
+
### v0.1.10 — Stability, Dynamic CWD, Unified Diff & Architecture Polish
|
|
128
|
+
* **Added in v0.1.10**: Dynamic / session-aware `cwd` resolution: TimeMachine tools and REST endpoints automatically locate the active session workspace or accept explicit target directory.
|
|
129
|
+
* **Added in v0.1.10**: Unified diff support (`format: "patch" | "stat"`) with size limits (up to 256KB) and clean truncation warnings.
|
|
130
|
+
* **Added in v0.1.10**: Async execution queue on Git mutation engine to completely eliminate lockfile collisions under concurrent agent operations.
|
|
131
|
+
* **Added in v0.1.10**: Single-batch Git ref querying using `git for-each-ref` replacing $O(N)$ sequential `git log` processes for instant snapshot listings.
|
|
132
|
+
* **Added in v0.1.10**: Redundant commit deduplication: auto-snapshots are safely skipped if the working tree has not changed.
|
|
133
|
+
* **Added in v0.1.10**: Startup cleanup of temporary `tm_index_*` staging files.
|
|
134
|
+
* **Changed in v0.1.10**: Settings UI card streamlined, directing snapshot inspection to the dedicated Time Machine sidebar tab.
|
|
135
|
+
* **Changed in v0.1.10**: Comprehensive error logging replacing silent exceptions.
|
|
136
|
+
|
|
122
137
|
### v0.1.9 — Event Bus Harmonization & Dependency Cleanup
|
|
123
138
|
* **Changed in v0.1.9**: Harmonized event bus subscriptions to prevent duplicate checkpoints. Native DSH `session/event` bus takes precedence, with legacy `ctx.events` used strictly as a fallback when `ctx.on` is unavailable.
|
|
124
139
|
* **Changed in v0.1.9**: Removed unused `@deepseek-ai/dsh-credentials` from `peerDependencies`.
|
package/README.ru.md
CHANGED
|
@@ -119,6 +119,21 @@ dsh-time-machine:
|
|
|
119
119
|
|
|
120
120
|
## 📋 История версий (Release Notes)
|
|
121
121
|
|
|
122
|
+
### v0.1.11 — Поддержка нативного DSH Right Sidebar и legacy BetterSidebar
|
|
123
|
+
* **Added in v0.1.11**: Поддержка нативной правой панели DSH (`sidebarRightTabs` + слот `sidebar.right.pane.tab`), появившейся в DSH 0.1.5-alpha.1, включая регистрацию на странице Guide с иконкой.
|
|
124
|
+
* **Preserved in v0.1.11**: Полная обратная совместимость с `betterSidebar`; раздельные идентификаторы поверхностей исключают конфликты или двойное монтирование при совместном включении.
|
|
125
|
+
* **Added in v0.1.11**: Безопасный откат к карточке настроек плагина при отсутствии обеих боковых панелей в хосте.
|
|
126
|
+
|
|
127
|
+
### v0.1.10 — Стабильность, динамический CWD, Unified Diff и оптимизация архитектуры
|
|
128
|
+
* **Added in v0.1.10**: Динамическое определение рабочей директории (`cwd`): инструменты и REST API автоматически определяют каталог активной сессии либо принимают явный путь.
|
|
129
|
+
* **Added in v0.1.10**: Поддержка Unified Diff (`format: "patch" | "stat"`) с лимитом размера (до 256 КБ) и защитой от переполнения контекста.
|
|
130
|
+
* **Added in v0.1.10**: Асинхронная очередь мутаций Git, исключающая конфликты блокировок `index.lock` при одновременных запросах.
|
|
131
|
+
* **Added in v0.1.10**: Пакетная вычитка ссылок через `git for-each-ref` вместо $O(N)$ отдельных вызовов `git log`, ускоряющая загрузку списка чекпоинтов.
|
|
132
|
+
* **Added in v0.1.10**: Дедупликация снапшотов: авто-сохранение пропускается, если дерево файлов не изменилось с момента прошлого чекпоинта.
|
|
133
|
+
* **Added in v0.1.10**: Автоматическая очистка сиротских временных индексов `tm_index_*` при запуске плагина.
|
|
134
|
+
* **Changed in v0.1.10**: Оптимизирована карточка настроек: удалено дублирование ленты, добавлено указание на вкладку в боковой панели.
|
|
135
|
+
* **Changed in v0.1.10**: Полное логирование ошибок через `console.warn` вместо тихого подавления исключений.
|
|
136
|
+
|
|
122
137
|
### v0.1.9 — Гармонизация шины событий и очистка зависимостей
|
|
123
138
|
* **Changed in v0.1.9**: Устранено потенциальное дублирование авто-снапшотов. Нативная шина событий DSH `session/event` теперь имеет приоритет, а legacy `ctx.events` подключается строго как fallback при отсутствии `ctx.on`.
|
|
124
139
|
* **Changed in v0.1.9**: Удалена неиспользуемая зависимость `@deepseek-ai/dsh-credentials` из `peerDependencies`.
|
package/README.zh.md
CHANGED
|
@@ -94,6 +94,21 @@ dsh-time-machine:
|
|
|
94
94
|
|
|
95
95
|
## 📋 版本更新记录 (Release Notes)
|
|
96
96
|
|
|
97
|
+
### v0.1.11 — 双侧边栏支持:原生 DSH 右侧边栏与 BetterSidebar
|
|
98
|
+
* **Added in v0.1.11**: 原生支持 DSH 0.1.5-alpha.1 引入的右侧边栏(`sidebarRightTabs` 注册与 `sidebar.right.pane.tab` 插槽),包含专属引导页卡片与图标。
|
|
99
|
+
* **Preserved in v0.1.11**: 保持对旧版 `betterSidebar` 的完全向后兼容;两种表面同时启用时互不干扰,杜绝重复挂载或 ID 冲突。
|
|
100
|
+
* **Added in v0.1.11**: 在两种侧边栏均缺失的环境下,安全降级至插件设置卡片。
|
|
101
|
+
|
|
102
|
+
### v0.1.10 — 稳定性、动态工作目录、统一 Diff 与架构优化
|
|
103
|
+
* **Added in v0.1.10**: 动态工作区感知(`cwd`):时光机工具与 REST 接口自动关联当前会话目录或支持显式工作区路径。
|
|
104
|
+
* **Added in v0.1.10**: 统一补丁支持(`format: "patch" | "stat"`),带输出截断保护(最大 256KB)。
|
|
105
|
+
* **Added in v0.1.10**: Git 变动操作异步排队队列,彻底解决高并发下的 `index.lock` 竞争冲突。
|
|
106
|
+
* **Added in v0.1.10**: 采用 `git for-each-ref` 批量查询引用,消除 $O(N)$ 次 `git log` 进程生成,极大加速快照加载。
|
|
107
|
+
* **Added in v0.1.10**: 空提交去重:若工作树无变化,自动跳过重复快照创建。
|
|
108
|
+
* **Added in v0.1.10**: 启动时自动清理遗留的 `tm_index_*` 临时暂存索引文件。
|
|
109
|
+
* **Changed in v0.1.10**: 精简设置卡片界面,引导在侧边栏时光机标签页查看时间线。
|
|
110
|
+
* **Changed in v0.1.10**: 完善错误告警日志(`console.warn`),替换静默吞并异常。
|
|
111
|
+
|
|
97
112
|
### v0.1.9 — 事件总线统一与依赖清理
|
|
98
113
|
* **Changed in v0.1.9**: 统一事件总线监听逻辑,杜绝自动快照重复触发。优先使用原生 DSH `session/event` 总线,仅在缺失 `ctx.on` 时启用 legacy `ctx.events` 回退。
|
|
99
114
|
* **Changed in v0.1.9**: 从 `peerDependencies` 中移除未使用的 `@deepseek-ai/dsh-credentials`。
|
package/lib/client.js
CHANGED
|
@@ -64,6 +64,9 @@ window.__ModuleLoader__.load({
|
|
|
64
64
|
autoHeal: 'Prompt recovery on failure',
|
|
65
65
|
unavailable: 'Settings unavailable',
|
|
66
66
|
tabTitle: 'Time Machine',
|
|
67
|
+
sidebarNotice: 'Checkpoints and timeline are available in the Time Machine sidebar tab.',
|
|
68
|
+
guideTitle: 'Time Machine',
|
|
69
|
+
guideDesc: 'Checkpoints, visual diffs and instant workspace rollback.',
|
|
67
70
|
};
|
|
68
71
|
const ru = {
|
|
69
72
|
title: 'Машина времени и чекпоинты',
|
|
@@ -86,6 +89,9 @@ window.__ModuleLoader__.load({
|
|
|
86
89
|
autoHeal: 'Предлагать восстановление при ошибке',
|
|
87
90
|
unavailable: 'Настройки недоступны',
|
|
88
91
|
tabTitle: 'Машина времени',
|
|
92
|
+
sidebarNotice: 'Лента чекпоинтов и управление снимками доступны во вкладке «Машина времени» боковой панели.',
|
|
93
|
+
guideTitle: 'Машина времени',
|
|
94
|
+
guideDesc: 'Чекпоинты, просмотр diff и мгновенный откат рабочего пространства.',
|
|
89
95
|
};
|
|
90
96
|
const zh = {
|
|
91
97
|
title: '时光机与检查点',
|
|
@@ -108,6 +114,9 @@ window.__ModuleLoader__.load({
|
|
|
108
114
|
autoHeal: '命令失败时提示恢复',
|
|
109
115
|
unavailable: '设置不可用',
|
|
110
116
|
tabTitle: '时光机',
|
|
117
|
+
sidebarNotice: '检查点时间线与管理可在侧边栏“时光机”标签页中查看。',
|
|
118
|
+
guideTitle: '时光机',
|
|
119
|
+
guideDesc: '快照检查点、差异对比与即时工作区回滚。',
|
|
111
120
|
};
|
|
112
121
|
|
|
113
122
|
let ChevronIcon = null;
|
|
@@ -176,7 +185,7 @@ window.__ModuleLoader__.load({
|
|
|
176
185
|
};
|
|
177
186
|
const onDiff = (id) => {
|
|
178
187
|
setBusy(true); setErr('');
|
|
179
|
-
fetch('/dsh-time-machine/diff?from=' + encodeURIComponent(id)).then(r => r.json()).then(j => {
|
|
188
|
+
fetch('/dsh-time-machine/diff?from=' + encodeURIComponent(id) + '&format=patch').then(r => r.json()).then(j => {
|
|
180
189
|
if (!j.success) throw new Error(j.error || 'diff failed');
|
|
181
190
|
setDiffText(j.diff || '');
|
|
182
191
|
}).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
|
|
@@ -317,9 +326,8 @@ window.__ModuleLoader__.load({
|
|
|
317
326
|
React.createElement('button', { className: 'tm-save', disabled: !writable || saving, onClick: onSave }, saving ? t.loading : t.save)
|
|
318
327
|
)
|
|
319
328
|
),
|
|
320
|
-
React.createElement('div', { className: 'tm-field' },
|
|
321
|
-
React.createElement('div', { className: 'tm-label' }, t.
|
|
322
|
-
React.createElement(Timeline, { t, ctx })
|
|
329
|
+
React.createElement('div', { className: 'tm-field', style: { borderTop: '1px solid var(--dsw-alias-border-subtle, rgba(255,255,255,0.08))', paddingTop: 10, marginTop: 6 } },
|
|
330
|
+
React.createElement('div', { className: 'tm-label', style: { color: 'var(--dsw-alias-label-secondary)', fontStyle: 'italic' } }, t.sidebarNotice)
|
|
323
331
|
)
|
|
324
332
|
) : null
|
|
325
333
|
);
|
|
@@ -416,6 +424,85 @@ window.__ModuleLoader__.load({
|
|
|
416
424
|
}
|
|
417
425
|
}
|
|
418
426
|
}
|
|
427
|
+
|
|
428
|
+
// Native DSH Right Sidebar (DSH >= 0.1.5-alpha.1)
|
|
429
|
+
let nativeTabRegistered = false;
|
|
430
|
+
const doRegisterNativeSidebar = (nctx) => {
|
|
431
|
+
if (nativeTabRegistered) return;
|
|
432
|
+
const reg = (nctx && nctx.sidebarRightTabs) || (nctx && nctx.get && (()=>{ try{return nctx.get('sidebarRightTabs')}catch{return null}})()) || ctx.sidebarRightTabs;
|
|
433
|
+
if (!reg || typeof reg.register !== 'function') return;
|
|
434
|
+
try {
|
|
435
|
+
const tNative = (ctx.locale && ctx.locale.bind) ? ctx.locale.bind(NS) : (k) => k;
|
|
436
|
+
const def = {
|
|
437
|
+
id: NS,
|
|
438
|
+
kind: 'time-machine',
|
|
439
|
+
priority: 'extension',
|
|
440
|
+
title: () => {
|
|
441
|
+
const a = (ctx.locale && ctx.locale.getSnapshot && ctx.locale.getSnapshot().active) || 'en';
|
|
442
|
+
return a.startsWith('zh') ? '时光机' : (a.startsWith('ru') ? 'Машина времени' : 'Time Machine');
|
|
443
|
+
},
|
|
444
|
+
guide: [{
|
|
445
|
+
order: 35,
|
|
446
|
+
title: () => {
|
|
447
|
+
const a = (ctx.locale && ctx.locale.getSnapshot && ctx.locale.getSnapshot().active) || 'en';
|
|
448
|
+
return a.startsWith('zh') ? '时光机' : (a.startsWith('ru') ? 'Машина времени' : 'Time Machine');
|
|
449
|
+
},
|
|
450
|
+
description: () => {
|
|
451
|
+
const a = (ctx.locale && ctx.locale.getSnapshot && ctx.locale.getSnapshot().active) || 'en';
|
|
452
|
+
return a.startsWith('zh') ? zh.guideDesc : (a.startsWith('ru') ? ru.guideDesc : en.guideDesc);
|
|
453
|
+
},
|
|
454
|
+
icon: (size) => React.createElement('svg', { width: size||16, height: size||16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
455
|
+
React.createElement('circle', { cx:12, cy:12, r:10 }),
|
|
456
|
+
React.createElement('polyline', { points:'12 6 12 12 16 14' })
|
|
457
|
+
)
|
|
458
|
+
}]
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const unregDef = reg.register(def);
|
|
462
|
+
nativeTabRegistered = true;
|
|
463
|
+
|
|
464
|
+
// Register tab body in slot sidebar.right.pane.tab
|
|
465
|
+
const doRegisterPaneTab = () => {
|
|
466
|
+
try {
|
|
467
|
+
ctx.slots.register({
|
|
468
|
+
name: 'sidebar.right.pane.tab',
|
|
469
|
+
key: NS,
|
|
470
|
+
locale: NS,
|
|
471
|
+
inject: () => ({ ctx })
|
|
472
|
+
}, TimeMachineTab);
|
|
473
|
+
} catch (e) {
|
|
474
|
+
console.error('[dsh-time-machine] sidebar.right.pane.tab register failed', e);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
if (ctx.slots && typeof ctx.slots.inject === 'function') {
|
|
479
|
+
try {
|
|
480
|
+
ctx.slots.inject('sidebar.right.pane.tab', () => {
|
|
481
|
+
doRegisterPaneTab();
|
|
482
|
+
});
|
|
483
|
+
} catch (e) {
|
|
484
|
+
doRegisterPaneTab();
|
|
485
|
+
}
|
|
486
|
+
} else {
|
|
487
|
+
doRegisterPaneTab();
|
|
488
|
+
}
|
|
489
|
+
} catch (e) {
|
|
490
|
+
console.error('[dsh-time-machine] native sidebarRightTabs register failed', e);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
if (ctx.inject) {
|
|
495
|
+
try {
|
|
496
|
+
ctx.inject(['sidebarRightTabs'], (nctx) => {
|
|
497
|
+
doRegisterNativeSidebar(nctx);
|
|
498
|
+
return () => {};
|
|
499
|
+
});
|
|
500
|
+
} catch (e) {
|
|
501
|
+
if (!String(e && e.message || '').includes('not declared')) {
|
|
502
|
+
console.error('[dsh-time-machine] sidebarRightTabs inject failed', e);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
419
506
|
};
|
|
420
507
|
return module.exports;
|
|
421
508
|
},
|
package/lib/index.js
CHANGED
|
@@ -12,46 +12,52 @@ export const Config = Schema.object({
|
|
|
12
12
|
|
|
13
13
|
const NS = '@goodandready/dsh-time-machine';
|
|
14
14
|
|
|
15
|
-
function
|
|
15
|
+
function cwdOf(execution, ctx) {
|
|
16
16
|
try {
|
|
17
|
-
if (execution
|
|
18
|
-
if (execution
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (execution && execution.agent && execution.agent.session) {
|
|
25
|
-
return String(execution.agent.session.id || execution.agent.session.header?.id || '');
|
|
26
|
-
}
|
|
27
|
-
if (ctx && ctx.session) return String(ctx.session.id || ctx.session.header?.id || '');
|
|
28
|
-
if (ctx && ctx.get && typeof ctx.get === 'function') {
|
|
29
|
-
try { const s = ctx.get('session'); if (s) return String(s.id || s.header?.id || ''); } catch {}
|
|
17
|
+
if (execution?.cwd) return String(execution.cwd);
|
|
18
|
+
if (execution?.session?.workspace?.cwd) return String(execution.session.workspace.cwd);
|
|
19
|
+
if (execution?.session?.cwd) return String(execution.session.cwd);
|
|
20
|
+
if (ctx?.workspace?.cwd) return String(ctx.workspace.cwd);
|
|
21
|
+
if (typeof ctx?.get === 'function') {
|
|
22
|
+
const ws = ctx.get('workspace');
|
|
23
|
+
if (ws?.cwd) return String(ws.cwd);
|
|
30
24
|
}
|
|
31
25
|
} catch {}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sessionIdOf(execution, ctx) {
|
|
30
|
+
try {
|
|
31
|
+
if (execution?.sessionId) return String(execution.sessionId);
|
|
32
|
+
if (execution?.session?.id) return String(execution.session.id);
|
|
33
|
+
if (execution?.data?.sessionId) return String(execution.data.sessionId);
|
|
34
|
+
if (ctx?.session?.id) return String(ctx.session.id);
|
|
35
|
+
} catch {}
|
|
32
36
|
return '';
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export function apply(ctx, config) {
|
|
36
40
|
let getConfig = () => config;
|
|
37
|
-
|
|
38
|
-
engine
|
|
41
|
+
const initialCwd = cwdOf(null, ctx);
|
|
42
|
+
let engine = new ShadowSnapshotEngine({ maxSnapshots: config?.maxSnapshots ?? 20, cwd: initialCwd });
|
|
43
|
+
engine.loadFromRefs({ cwd: initialCwd }).catch(() => {});
|
|
39
44
|
|
|
40
45
|
ctx.inject(['settings'], (sctx) => {
|
|
41
46
|
const scope = sctx.settings.register(NS, Config, { base: config });
|
|
42
47
|
getConfig = () => scope.get() ?? config;
|
|
43
|
-
const syncMax = () => engine.setMax(getConfig().maxSnapshots ?? 20);
|
|
48
|
+
const syncMax = () => engine.setMax(getConfig().maxSnapshots ?? 20, cwdOf(null, ctx));
|
|
44
49
|
syncMax();
|
|
45
50
|
const stop = scope.watch ? scope.watch(syncMax) : () => {};
|
|
46
51
|
sctx.effect(() => () => { try { stop(); } catch {} }, 'dsh-time-machine: settings watch');
|
|
47
52
|
});
|
|
48
53
|
|
|
49
54
|
// auto-snapshot on turn/start and approval/asked (session-scoped)
|
|
50
|
-
const autoSnap = async (label, sessId) => {
|
|
55
|
+
const autoSnap = async (label, sessId, customCwd) => {
|
|
51
56
|
if (!getConfig().autoSnapshotEnabled) return;
|
|
52
57
|
try {
|
|
53
58
|
const sid = String(sessId || '').trim();
|
|
54
|
-
|
|
59
|
+
const targetCwd = customCwd || cwdOf(null, ctx);
|
|
60
|
+
await engine.createSnapshot(label, { sessionId: sid, cwd: targetCwd, skipIfNoChanges: true });
|
|
55
61
|
} catch {}
|
|
56
62
|
};
|
|
57
63
|
|
|
@@ -63,24 +69,25 @@ export function apply(ctx, config) {
|
|
|
63
69
|
ctx.effect(() => {
|
|
64
70
|
const off = ctx.on('session/event', (session, event) => {
|
|
65
71
|
const sid = (session && session.id) || sessionIdOf(event, ctx);
|
|
72
|
+
const sessionCwd = session?.workspace?.cwd || session?.cwd || cwdOf(event, ctx);
|
|
66
73
|
const type = event && event.type;
|
|
67
74
|
if (type === 'turn/start') {
|
|
68
75
|
const turnId = event.turnId || event.data?.turnId || Date.now();
|
|
69
|
-
autoSnap(`auto:turn:${turnId}`, sid);
|
|
76
|
+
autoSnap(`auto:turn:${turnId}`, sid, sessionCwd);
|
|
70
77
|
} else if (type === 'approval/asked') {
|
|
71
78
|
const tool = event.tool || event.data?.toolName || event.data?.tool || 'approval';
|
|
72
|
-
autoSnap(`auto:approval:${tool}`, sid);
|
|
79
|
+
autoSnap(`auto:approval:${tool}`, sid, sessionCwd);
|
|
73
80
|
} else if (type === 'turn/end') {
|
|
74
81
|
if (!getConfig().autoSnapshotEnabled) return;
|
|
75
82
|
const outcome = String((event && (event.outcome || event.data?.outcome || event.result?.outcome || event.result?.status)) || '');
|
|
76
83
|
if (!sid) return;
|
|
77
84
|
if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
|
|
78
|
-
engine.pruneSnapshots(sid, 3).catch(() => {});
|
|
85
|
+
engine.pruneSnapshots(sid, 3, { cwd: sessionCwd }).catch(() => {});
|
|
79
86
|
}
|
|
80
87
|
} else if (type === 'tool/error' || type === 'command/error') {
|
|
81
88
|
if (getConfig().autoHealPrompt) {
|
|
82
89
|
const tool = event.tool || event.data?.toolName || 'command';
|
|
83
|
-
autoSnap(`auto:error:${tool}`, sid);
|
|
90
|
+
autoSnap(`auto:error:${tool}`, sid, sessionCwd);
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
});
|
|
@@ -122,7 +129,7 @@ export function apply(ctx, config) {
|
|
|
122
129
|
} catch {}
|
|
123
130
|
}
|
|
124
131
|
|
|
125
|
-
// tools (session-aware)
|
|
132
|
+
// tools (session-aware and cwd-aware)
|
|
126
133
|
const registerTools = (tctx) => {
|
|
127
134
|
tctx.tools.register({
|
|
128
135
|
name: 'time_machine_checkpoint_create',
|
|
@@ -132,11 +139,13 @@ export function apply(ctx, config) {
|
|
|
132
139
|
properties: {
|
|
133
140
|
label: { type: 'string', description: 'Human label for checkpoint' },
|
|
134
141
|
sessionId: { type: 'string', description: 'Session id to scope checkpoint (auto-detected if omitted)' },
|
|
142
|
+
cwd: { type: 'string', description: 'Target workspace directory (auto-detected if omitted)' },
|
|
135
143
|
},
|
|
136
144
|
},
|
|
137
145
|
execute: async (args = {}, execution) => {
|
|
138
|
-
const sid = String(args.sessionId || sessionIdOf(execution, tctx) ||
|
|
139
|
-
const
|
|
146
|
+
const sid = String(args.sessionId || sessionIdOf(execution, tctx) || '').trim();
|
|
147
|
+
const targetCwd = args.cwd ? String(args.cwd) : cwdOf(execution, tctx);
|
|
148
|
+
const snap = await engine.createSnapshot(args.label, { sessionId: sid, cwd: targetCwd });
|
|
140
149
|
return { success: true, snapshot: snap };
|
|
141
150
|
},
|
|
142
151
|
});
|
|
@@ -165,28 +174,36 @@ export function apply(ctx, config) {
|
|
|
165
174
|
properties: {
|
|
166
175
|
id: { type: 'string', description: 'Snapshot id to rollback to' },
|
|
167
176
|
confirm: { type: 'boolean', description: 'Must be true to confirm destructive rollback' },
|
|
177
|
+
cwd: { type: 'string', description: 'Target workspace directory' },
|
|
168
178
|
},
|
|
169
179
|
required: ['id', 'confirm'],
|
|
170
180
|
},
|
|
171
|
-
execute: async ({ id, confirm } = {}) => {
|
|
172
|
-
const
|
|
181
|
+
execute: async ({ id, confirm, cwd } = {}, execution) => {
|
|
182
|
+
const targetCwd = cwd ? String(cwd) : cwdOf(execution, tctx);
|
|
183
|
+
const res = await engine.rollbackSnapshot(String(id ?? ''), { confirm, cwd: targetCwd });
|
|
173
184
|
return { success: true, ...res };
|
|
174
185
|
},
|
|
175
186
|
});
|
|
176
187
|
|
|
177
188
|
tctx.tools.register({
|
|
178
189
|
name: 'time_machine_diff',
|
|
179
|
-
description: 'Show diff between checkpoint and current or another checkpoint.',
|
|
190
|
+
description: 'Show unified diff or stat between checkpoint and current or another checkpoint.',
|
|
180
191
|
parameters: {
|
|
181
192
|
type: 'object',
|
|
182
193
|
properties: {
|
|
183
194
|
from: { type: 'string', description: 'Source snapshot id' },
|
|
184
195
|
to: { type: 'string', description: 'Target snapshot id (default HEAD)' },
|
|
196
|
+
format: { type: 'string', enum: ['patch', 'stat'], description: 'Format of diff: patch (unified diff) or stat (summary)' },
|
|
197
|
+
cwd: { type: 'string', description: 'Target workspace directory' },
|
|
185
198
|
},
|
|
186
199
|
required: ['from'],
|
|
187
200
|
},
|
|
188
|
-
execute: async ({ from, to } = {}) => {
|
|
189
|
-
const
|
|
201
|
+
execute: async ({ from, to, format, cwd } = {}, execution) => {
|
|
202
|
+
const targetCwd = cwd ? String(cwd) : cwdOf(execution, tctx);
|
|
203
|
+
const res = await engine.diff(String(from ?? ''), to ? String(to) : undefined, {
|
|
204
|
+
cwd: targetCwd,
|
|
205
|
+
format: format === 'stat' ? 'stat' : 'patch',
|
|
206
|
+
});
|
|
190
207
|
return { success: true, ...res };
|
|
191
208
|
},
|
|
192
209
|
});
|
|
@@ -199,11 +216,13 @@ export function apply(ctx, config) {
|
|
|
199
216
|
properties: {
|
|
200
217
|
id: { type: 'string', description: 'Snapshot id to delete' },
|
|
201
218
|
confirm: { type: 'boolean', description: 'Must be true to confirm deletion' },
|
|
219
|
+
cwd: { type: 'string', description: 'Target workspace directory' },
|
|
202
220
|
},
|
|
203
221
|
required: ['id', 'confirm'],
|
|
204
222
|
},
|
|
205
|
-
execute: async ({ id, confirm } = {}) => {
|
|
206
|
-
const
|
|
223
|
+
execute: async ({ id, confirm, cwd } = {}, execution) => {
|
|
224
|
+
const targetCwd = cwd ? String(cwd) : cwdOf(execution, tctx);
|
|
225
|
+
const res = await engine.deleteSnapshot(String(id ?? ''), { confirm, cwd: targetCwd });
|
|
207
226
|
return { success: true, ...res };
|
|
208
227
|
},
|
|
209
228
|
});
|
|
@@ -216,11 +235,13 @@ export function apply(ctx, config) {
|
|
|
216
235
|
properties: {
|
|
217
236
|
sessionId: { type: 'string', description: 'Session id to prune (default current session)' },
|
|
218
237
|
keep: { type: 'number', description: 'How many newest checkpoints to keep (default 3)' },
|
|
238
|
+
cwd: { type: 'string', description: 'Target workspace directory' },
|
|
219
239
|
},
|
|
220
240
|
},
|
|
221
241
|
execute: async (args = {}, execution) => {
|
|
222
242
|
const sid = String(args.sessionId || sessionIdOf(execution, tctx) || '').trim();
|
|
223
|
-
const
|
|
243
|
+
const targetCwd = args.cwd ? String(args.cwd) : cwdOf(execution, tctx);
|
|
244
|
+
const out = await engine.pruneSnapshots(sid, args.keep, { cwd: targetCwd });
|
|
224
245
|
return { success: true, sessionId: sid, ...out };
|
|
225
246
|
},
|
|
226
247
|
});
|
|
@@ -258,7 +279,7 @@ export function apply(ctx, config) {
|
|
|
258
279
|
});
|
|
259
280
|
};
|
|
260
281
|
|
|
261
|
-
// web routes for UI (session-aware via ?sessionId=)
|
|
282
|
+
// web routes for UI (session-aware via ?sessionId= and ?format=)
|
|
262
283
|
ctx.effect(() => {
|
|
263
284
|
const disposals = [];
|
|
264
285
|
try {
|
|
@@ -280,8 +301,12 @@ export function apply(ctx, config) {
|
|
|
280
301
|
const url = new URL(req.url, 'http://localhost');
|
|
281
302
|
const from = url.searchParams.get('from') || '';
|
|
282
303
|
const to = url.searchParams.get('to') || undefined;
|
|
304
|
+
const format = url.searchParams.get('format') || 'patch';
|
|
283
305
|
try {
|
|
284
|
-
const out = await engine.diff(String(from), to ? String(to) : undefined
|
|
306
|
+
const out = await engine.diff(String(from), to ? String(to) : undefined, {
|
|
307
|
+
cwd: cwdOf(null, ctx),
|
|
308
|
+
format: format === 'stat' ? 'stat' : 'patch',
|
|
309
|
+
});
|
|
285
310
|
res.setHeader('content-type', 'application/json');
|
|
286
311
|
res.end(JSON.stringify({ success: true, ...out }));
|
|
287
312
|
} catch (e) {
|
|
@@ -298,7 +323,8 @@ export function apply(ctx, config) {
|
|
|
298
323
|
readJsonBody(req, res, async (parsed) => {
|
|
299
324
|
try {
|
|
300
325
|
const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '').trim();
|
|
301
|
-
const
|
|
326
|
+
const targetCwd = parsed.cwd ? String(parsed.cwd) : cwdOf(null, ctx);
|
|
327
|
+
const snap = await engine.createSnapshot(parsed.label, { sessionId: sid, cwd: targetCwd });
|
|
302
328
|
res.setHeader('content-type', 'application/json');
|
|
303
329
|
res.end(JSON.stringify({ success: true, snapshot: snap }));
|
|
304
330
|
} catch (e) {
|
|
@@ -315,7 +341,8 @@ export function apply(ctx, config) {
|
|
|
315
341
|
handler: (req, res) => {
|
|
316
342
|
readJsonBody(req, res, async (parsed) => {
|
|
317
343
|
try {
|
|
318
|
-
const
|
|
344
|
+
const targetCwd = parsed.cwd ? String(parsed.cwd) : cwdOf(null, ctx);
|
|
345
|
+
const out = await engine.deleteSnapshot(String(parsed.id || ''), { confirm: parsed.confirm, cwd: targetCwd });
|
|
319
346
|
res.setHeader('content-type', 'application/json');
|
|
320
347
|
res.end(JSON.stringify({ success: true, ...out }));
|
|
321
348
|
} catch (e) {
|
|
@@ -333,7 +360,8 @@ export function apply(ctx, config) {
|
|
|
333
360
|
readJsonBody(req, res, async (parsed) => {
|
|
334
361
|
try {
|
|
335
362
|
const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '');
|
|
336
|
-
const
|
|
363
|
+
const targetCwd = parsed.cwd ? String(parsed.cwd) : cwdOf(null, ctx);
|
|
364
|
+
const out = await engine.pruneSnapshots(sid, parsed.keep, { cwd: targetCwd });
|
|
337
365
|
res.setHeader('content-type', 'application/json');
|
|
338
366
|
res.end(JSON.stringify({ success: true, sessionId: sid, ...out }));
|
|
339
367
|
} catch (e) {
|
|
@@ -350,7 +378,8 @@ export function apply(ctx, config) {
|
|
|
350
378
|
handler: (req, res) => {
|
|
351
379
|
readJsonBody(req, res, async (parsed) => {
|
|
352
380
|
try {
|
|
353
|
-
const
|
|
381
|
+
const targetCwd = parsed.cwd ? String(parsed.cwd) : cwdOf(null, ctx);
|
|
382
|
+
const out = await engine.rollbackSnapshot(String(parsed.id || ''), { confirm: parsed.confirm, cwd: targetCwd });
|
|
354
383
|
res.setHeader('content-type', 'application/json');
|
|
355
384
|
res.end(JSON.stringify({ success: true, ...out }));
|
|
356
385
|
} catch (e) {
|
package/lib/snapshot.js
CHANGED
|
@@ -18,159 +18,164 @@ async function defaultExec(cmd, args, opts = {}) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const MAX_DIFF_LENGTH = 256 * 1024; // 256 KB safety limit
|
|
22
|
+
|
|
21
23
|
// ponytail: in-memory engine, git shadow refs if repo present; O(n) scan, per-session trim
|
|
22
24
|
export class ShadowSnapshotEngine {
|
|
23
25
|
constructor({ exec = defaultExec, maxSnapshots = 20, cwd } = {}) {
|
|
24
26
|
this.exec = exec;
|
|
25
27
|
this.maxSnapshots = maxSnapshots;
|
|
26
28
|
this.cwd = cwd;
|
|
27
|
-
this.snapshots = []; // {id, label, sessionId, createdAt, commit, ref}
|
|
29
|
+
this.snapshots = []; // {id, label, sessionId, createdAt, commit, ref, treeHash}
|
|
28
30
|
this.seq = 0;
|
|
31
|
+
this._queue = Promise.resolve();
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
_now() { return Date.now(); }
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (oldest?.ref) {
|
|
38
|
-
try { await this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}); } catch {}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
const perSession = this.snapshots.filter(s => s.sessionId === sessionId);
|
|
44
|
-
while (perSession.length > this.maxSnapshots) {
|
|
45
|
-
const oldest = perSession.shift();
|
|
46
|
-
const idx = this.snapshots.indexOf(oldest);
|
|
47
|
-
if (idx !== -1) {
|
|
48
|
-
this.snapshots.splice(idx, 1);
|
|
49
|
-
if (oldest?.ref) {
|
|
50
|
-
try { await this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}); } catch {}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
36
|
+
_enqueue(operation) {
|
|
37
|
+
const next = this._queue.then(() => operation(), () => operation());
|
|
38
|
+
this._queue = next.catch(() => {});
|
|
39
|
+
return next;
|
|
54
40
|
}
|
|
55
41
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
await this.exec('git', ['rev-parse', '--is-inside-work-tree'], this.cwd ? { cwd: this.cwd } : {});
|
|
59
|
-
return true;
|
|
60
|
-
} catch { return false; }
|
|
42
|
+
_resolveCwd(customCwd) {
|
|
43
|
+
return customCwd || this.cwd || process.cwd();
|
|
61
44
|
}
|
|
62
45
|
|
|
63
|
-
async
|
|
46
|
+
async _hasGit(targetCwd) {
|
|
64
47
|
try {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
return path.isAbsolute(gdir) ? gdir : path.resolve(this.cwd || process.cwd(), gdir);
|
|
48
|
+
const cwd = this._resolveCwd(targetCwd);
|
|
49
|
+
const { stdout } = await this.exec('git', ['rev-parse', '--is-inside-work-tree'], { cwd });
|
|
50
|
+
return stdout.trim() === 'true';
|
|
69
51
|
} catch {
|
|
70
|
-
return
|
|
52
|
+
return false;
|
|
71
53
|
}
|
|
72
54
|
}
|
|
73
55
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
async loadFromRefs() {
|
|
77
|
-
if (!(await this._isGitRepo())) return 0;
|
|
78
|
-
let refs = [];
|
|
56
|
+
async cleanupOrphanedIndices(targetCwd) {
|
|
57
|
+
const cwd = this._resolveCwd(targetCwd);
|
|
79
58
|
try {
|
|
80
|
-
const { stdout } = await this.exec(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (this.snapshots.some(s => s.ref === ref)) continue;
|
|
92
|
-
let createdAt = 0;
|
|
93
|
-
let label = ref.split('/').pop();
|
|
94
|
-
try {
|
|
95
|
-
const { stdout } = await this.exec(
|
|
96
|
-
'git',
|
|
97
|
-
['log', '-1', '--format=%ct%00%s', ref],
|
|
98
|
-
this.cwd ? { cwd: this.cwd } : {},
|
|
99
|
-
);
|
|
100
|
-
const [ct, msg] = String(stdout ?? '').split('\0');
|
|
101
|
-
createdAt = Number(ct) * 1000 || 0;
|
|
102
|
-
if (msg && String(msg).trim()) label = String(msg).trim();
|
|
103
|
-
} catch {}
|
|
104
|
-
const short = ref.replace(/^refs\/dsh-time-machine\//, '');
|
|
105
|
-
const slash = short.indexOf('/');
|
|
106
|
-
const sessionId = slash === -1 ? '' : short.slice(0, slash);
|
|
107
|
-
const id = slash === -1 ? short : short.slice(slash + 1);
|
|
108
|
-
restored.push({ id: id || short, label, sessionId, createdAt, commit, ref, seq: 0 });
|
|
59
|
+
const { stdout } = await this.exec('git', ['rev-parse', '--git-dir'], { cwd });
|
|
60
|
+
const gitDir = stdout.trim();
|
|
61
|
+
const resolvedGitDir = path.isAbsolute(gitDir) ? gitDir : path.resolve(cwd, gitDir);
|
|
62
|
+
const files = await fs.readdir(resolvedGitDir).catch(() => []);
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
if (file.startsWith('tm_index_')) {
|
|
65
|
+
await fs.unlink(path.join(resolvedGitDir, file)).catch(() => {});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn('[dsh-time-machine] cleanupOrphanedIndices warning:', e.message || e);
|
|
109
70
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
this.
|
|
114
|
-
|
|
115
|
-
for (const sid of sessions) await this._trimFor(sid);
|
|
116
|
-
if (sessions.length === 0) {
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async _trimFor(sessionId, targetCwd) {
|
|
74
|
+
const cwd = this._resolveCwd(targetCwd);
|
|
75
|
+
if (!sessionId) {
|
|
117
76
|
while (this.snapshots.length > this.maxSnapshots) {
|
|
118
77
|
const oldest = this.snapshots.shift();
|
|
119
78
|
if (oldest?.ref) {
|
|
120
|
-
try {
|
|
79
|
+
try {
|
|
80
|
+
await this.exec('git', ['update-ref', '-d', oldest.ref], { cwd });
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.warn('[dsh-time-machine] failed to delete evicted ref:', oldest.ref, e.message || e);
|
|
83
|
+
}
|
|
121
84
|
}
|
|
122
85
|
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const sessionSnaps = this.snapshots.filter(s => s.sessionId === sessionId);
|
|
89
|
+
const toRemoveCount = sessionSnaps.length - this.maxSnapshots;
|
|
90
|
+
if (toRemoveCount > 0) {
|
|
91
|
+
const toRemove = sessionSnaps.slice(0, toRemoveCount);
|
|
92
|
+
const removeIds = new Set(toRemove.map(s => s.id));
|
|
93
|
+
for (const snap of toRemove) {
|
|
94
|
+
if (snap.ref) {
|
|
95
|
+
try {
|
|
96
|
+
await this.exec('git', ['update-ref', '-d', snap.ref], { cwd });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.warn('[dsh-time-machine] failed to delete session ref:', snap.ref, e.message || e);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.snapshots = this.snapshots.filter(s => !removeIds.has(s.id));
|
|
123
103
|
}
|
|
124
|
-
return restored.length;
|
|
125
104
|
}
|
|
126
105
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
106
|
+
createSnapshot(label, { sessionId = '', cwd: customCwd } = {}) {
|
|
107
|
+
return this._enqueue(() => this._doCreateSnapshot(label, { sessionId, cwd: customCwd }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async _doCreateSnapshot(label, { sessionId = '', cwd: customCwd } = {}) {
|
|
111
|
+
const cwd = this._resolveCwd(customCwd);
|
|
112
|
+
const id = `snap-${this._now()}-${crypto.randomBytes(3).toString('hex')}`;
|
|
113
|
+
const cleanLabel = String(label || 'checkpoint').slice(0, 100);
|
|
114
|
+
const cleanSession = String(sessionId || '').trim();
|
|
133
115
|
let commit = null;
|
|
134
116
|
let ref = null;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
117
|
+
let treeHash = null;
|
|
118
|
+
|
|
119
|
+
if (await this._hasGit(cwd)) {
|
|
120
|
+
const gitDirRes = await this.exec('git', ['rev-parse', '--git-dir'], { cwd });
|
|
121
|
+
const gitDir = gitDirRes.stdout.trim();
|
|
122
|
+
const resolvedGitDir = path.isAbsolute(gitDir) ? gitDir : path.resolve(cwd, gitDir);
|
|
123
|
+
const tempIndexFile = path.join(resolvedGitDir, `tm_index_${crypto.randomBytes(4).toString('hex')}`);
|
|
124
|
+
|
|
125
|
+
const gitEnv = {
|
|
126
|
+
...process.env,
|
|
127
|
+
GIT_INDEX_FILE: tempIndexFile,
|
|
128
|
+
GIT_AUTHOR_NAME: 'DSH Time Machine',
|
|
129
|
+
GIT_AUTHOR_EMAIL: 'time-machine@deepseek.local',
|
|
130
|
+
GIT_COMMITTER_NAME: 'DSH Time Machine',
|
|
131
|
+
GIT_COMMITTER_EMAIL: 'time-machine@deepseek.local',
|
|
132
|
+
};
|
|
133
|
+
|
|
138
134
|
try {
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
if (gitDir) {
|
|
150
|
-
shadowIndex = path.join(gitDir, `tm_index_${id}`);
|
|
151
|
-
execEnv.GIT_INDEX_FILE = shadowIndex;
|
|
135
|
+
await this.exec('git', ['add', '-A'], { cwd, env: gitEnv });
|
|
136
|
+
const treeRes = await this.exec('git', ['write-tree'], { cwd, env: gitEnv });
|
|
137
|
+
treeHash = treeRes.stdout.trim();
|
|
138
|
+
|
|
139
|
+
// Check if identical to last snapshot for this session to avoid redundant commits
|
|
140
|
+
const lastSnap = this.snapshots.filter(s => s.sessionId === cleanSession).slice(-1)[0];
|
|
141
|
+
if (lastSnap && lastSnap.treeHash && lastSnap.treeHash === treeHash) {
|
|
142
|
+
return lastSnap;
|
|
152
143
|
}
|
|
153
144
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
ref = safeSid ? `refs/dsh-time-machine/${safeSid}/${id}` : `refs/dsh-time-machine/${id}`;
|
|
161
|
-
await this.exec('git', ['update-ref', ref, commit], baseOpts);
|
|
162
|
-
} catch {
|
|
163
|
-
commit = null;
|
|
164
|
-
ref = null;
|
|
165
|
-
} finally {
|
|
166
|
-
if (shadowIndex) {
|
|
167
|
-
try { await fs.unlink(shadowIndex); } catch {}
|
|
145
|
+
const headCommitRes = await this.exec('git', ['rev-parse', '--verify', 'HEAD'], { cwd }).catch(() => ({ stdout: '' }));
|
|
146
|
+
const headCommit = headCommitRes.stdout.trim();
|
|
147
|
+
|
|
148
|
+
const commitArgs = ['commit-tree', treeHash, '-m', `[dsh-time-machine] ${cleanLabel}`];
|
|
149
|
+
if (headCommit) {
|
|
150
|
+
commitArgs.push('-p', headCommit);
|
|
168
151
|
}
|
|
152
|
+
const commitRes = await this.exec('git', commitArgs, { cwd, env: gitEnv });
|
|
153
|
+
commit = commitRes.stdout.trim();
|
|
154
|
+
|
|
155
|
+
const refPath = cleanSession
|
|
156
|
+
? `refs/dsh-time-machine/${cleanSession}/${id}`
|
|
157
|
+
: `refs/dsh-time-machine/${id}`;
|
|
158
|
+
await this.exec('git', ['update-ref', refPath, commit], { cwd });
|
|
159
|
+
ref = refPath;
|
|
160
|
+
} catch (e) {
|
|
161
|
+
console.warn('[dsh-time-machine] git snapshot creation failed, falling back to memory record:', e.message || e);
|
|
162
|
+
} finally {
|
|
163
|
+
await fs.unlink(tempIndexFile).catch(() => {});
|
|
169
164
|
}
|
|
170
165
|
}
|
|
171
|
-
|
|
166
|
+
|
|
167
|
+
const snap = {
|
|
168
|
+
id,
|
|
169
|
+
label: cleanLabel,
|
|
170
|
+
sessionId: cleanSession,
|
|
171
|
+
createdAt: this._now(),
|
|
172
|
+
commit,
|
|
173
|
+
ref,
|
|
174
|
+
treeHash,
|
|
175
|
+
seq: ++this.seq,
|
|
176
|
+
};
|
|
172
177
|
this.snapshots.push(snap);
|
|
173
|
-
await this._trimFor(
|
|
178
|
+
await this._trimFor(cleanSession, cwd);
|
|
174
179
|
return snap;
|
|
175
180
|
}
|
|
176
181
|
|
|
@@ -184,111 +189,177 @@ export class ShadowSnapshotEngine {
|
|
|
184
189
|
return this.snapshots.find(s => s.id === id) || null;
|
|
185
190
|
}
|
|
186
191
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
throw
|
|
192
|
-
|
|
192
|
+
deleteSnapshot(id, { confirm = false, cwd: customCwd } = {}) {
|
|
193
|
+
return this._enqueue(async () => {
|
|
194
|
+
if (!confirm) throw new Error('confirm required to delete snapshot');
|
|
195
|
+
const idx = this.snapshots.findIndex(s => s.id === id);
|
|
196
|
+
if (idx === -1) throw new Error(`Snapshot not found: ${id}`);
|
|
197
|
+
const snap = this.snapshots[idx];
|
|
198
|
+
const cwd = this._resolveCwd(customCwd);
|
|
199
|
+
if (snap.ref && await this._hasGit(cwd)) {
|
|
200
|
+
try {
|
|
201
|
+
await this.exec('git', ['update-ref', '-d', snap.ref], { cwd });
|
|
202
|
+
} catch (e) {
|
|
203
|
+
console.warn('[dsh-time-machine] failed to delete ref on deleteSnapshot:', snap.ref, e.message || e);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
this.snapshots.splice(idx, 1);
|
|
207
|
+
return { deleted: true, id };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
pruneSnapshots(sessionId = '', keep = 3, { cwd: customCwd } = {}) {
|
|
212
|
+
return this._enqueue(async () => {
|
|
213
|
+
const sid = sessionId != null ? String(sessionId).trim() : '';
|
|
214
|
+
const list = this.listSnapshots(sid); // newest first
|
|
215
|
+
const minKeep = Math.max(0, Number.isFinite(Number(keep)) ? Number(keep) : 3);
|
|
216
|
+
const toRemove = list.slice(minKeep);
|
|
217
|
+
const removed = [];
|
|
218
|
+
const cwd = this._resolveCwd(customCwd);
|
|
219
|
+
for (const snap of toRemove) {
|
|
220
|
+
const idx = this.snapshots.findIndex(s => s.id === snap.id);
|
|
221
|
+
if (idx !== -1) {
|
|
222
|
+
if (snap.ref && await this._hasGit(cwd)) {
|
|
223
|
+
try {
|
|
224
|
+
await this.exec('git', ['update-ref', '-d', snap.ref], { cwd });
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.warn('[dsh-time-machine] failed to delete ref on pruneSnapshots:', snap.ref, e.message || e);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.snapshots.splice(idx, 1);
|
|
230
|
+
removed.push(snap.id);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { removed, kept: list.length - removed.length };
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
rollbackSnapshot(id, { confirm = false, cwd: customCwd } = {}) {
|
|
238
|
+
return this._enqueue(() => this._doRollbackSnapshot(id, { confirm, cwd: customCwd }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async _doRollbackSnapshot(id, { confirm = false, cwd: customCwd } = {}) {
|
|
242
|
+
if (!confirm) throw new Error('confirm required for rollback');
|
|
193
243
|
const snap = this.getSnapshot(id);
|
|
194
|
-
if (!snap) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
throw err;
|
|
198
|
-
}
|
|
199
|
-
const inGit = await this._isGitRepo();
|
|
200
|
-
if (inGit && snap.commit) {
|
|
201
|
-
const opts = this.cwd ? { cwd: this.cwd } : {};
|
|
244
|
+
if (!snap) throw new Error(`Snapshot not found: ${id}`);
|
|
245
|
+
const cwd = this._resolveCwd(customCwd);
|
|
246
|
+
if (snap.commit && await this._hasGit(cwd)) {
|
|
202
247
|
try {
|
|
203
|
-
|
|
204
|
-
await this.exec('git', ['
|
|
205
|
-
await this.exec('git', ['
|
|
206
|
-
await this.exec('git', ['clean', '-fd'], opts);
|
|
248
|
+
await this.exec('git', ['read-tree', snap.commit], { cwd });
|
|
249
|
+
await this.exec('git', ['checkout-index', '-a', '-f'], { cwd });
|
|
250
|
+
await this.exec('git', ['clean', '-fd'], { cwd });
|
|
207
251
|
} catch (e) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
await this.exec('git', ['clean', '-fd'], opts).catch(() => {});
|
|
252
|
+
console.warn('[dsh-time-machine] rollback error:', e.message || e);
|
|
253
|
+
throw new Error(`Rollback execution failed: ${e.message || e}`);
|
|
211
254
|
}
|
|
212
255
|
}
|
|
213
|
-
return { rolledBack: true,
|
|
256
|
+
return { rolledBack: true, id, commit: snap.commit };
|
|
214
257
|
}
|
|
215
258
|
|
|
216
|
-
async
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
if (!
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
throw err;
|
|
227
|
-
}
|
|
228
|
-
if (snap.ref) {
|
|
229
|
-
try {
|
|
230
|
-
await this.exec('git', ['update-ref', '-d', snap.ref], this.cwd ? { cwd: this.cwd } : {});
|
|
231
|
-
} catch {}
|
|
259
|
+
async diff(fromId, toId = undefined, { cwd: customCwd, format = 'patch' } = {}) {
|
|
260
|
+
const snapFrom = this.getSnapshot(fromId);
|
|
261
|
+
if (!snapFrom) throw new Error(`Snapshot not found: ${fromId}`);
|
|
262
|
+
const snapTo = toId ? this.getSnapshot(toId) : null;
|
|
263
|
+
if (toId && !snapTo) throw new Error(`Target snapshot not found: ${toId}`);
|
|
264
|
+
|
|
265
|
+
const cwd = this._resolveCwd(customCwd);
|
|
266
|
+
if (!(await this._hasGit(cwd)) || !snapFrom.commit) {
|
|
267
|
+
const toLabel = snapTo ? snapTo.label : "current";
|
|
268
|
+
return { from: fromId, to: toId || "HEAD", diff: `diff ${snapFrom.label} -> ${toLabel} (no git repo)`, format };
|
|
232
269
|
}
|
|
233
|
-
const idx = this.snapshots.indexOf(snap);
|
|
234
|
-
if (idx !== -1) this.snapshots.splice(idx, 1);
|
|
235
|
-
return { deleted: true, snapshot: snap };
|
|
236
|
-
}
|
|
237
270
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
271
|
+
try {
|
|
272
|
+
const fromRef = snapFrom.commit || snapFrom.ref;
|
|
273
|
+
let diffArgs = ['diff'];
|
|
274
|
+
if (format === 'stat') {
|
|
275
|
+
diffArgs.push('--stat');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (snapTo) {
|
|
279
|
+
const toRef = snapTo.commit || snapTo.ref;
|
|
280
|
+
diffArgs.push(fromRef, toRef);
|
|
281
|
+
} else {
|
|
282
|
+
diffArgs.push(fromRef);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const { stdout } = await this.exec('git', diffArgs, { cwd });
|
|
286
|
+
let diffOutput = stdout;
|
|
287
|
+
let truncated = false;
|
|
288
|
+
if (diffOutput.length > MAX_DIFF_LENGTH) {
|
|
289
|
+
diffOutput = diffOutput.slice(0, MAX_DIFF_LENGTH) + '\n\n... [diff truncated: output exceeded limit]';
|
|
290
|
+
truncated = true;
|
|
291
|
+
}
|
|
292
|
+
return { diff: diffOutput, from: fromId, to: toId || 'HEAD', format, truncated };
|
|
293
|
+
} catch (e) {
|
|
294
|
+
console.warn('[dsh-time-machine] diff computation error:', e.message || e);
|
|
295
|
+
return { diff: `[diff error: ${e.message || e}]`, from: fromId, to: toId || 'HEAD', format };
|
|
246
296
|
}
|
|
247
|
-
return { removed, kept: list.length - removed.length };
|
|
248
297
|
}
|
|
249
298
|
|
|
250
|
-
async
|
|
251
|
-
const
|
|
252
|
-
if (!
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
: ['diff', '--stat', from.commit];
|
|
265
|
-
const { stdout } = await this.exec('git', args, opts);
|
|
266
|
-
const diffStat = String(stdout ?? '').trim();
|
|
267
|
-
if (diffStat) return { from: fromId, to: toId || 'HEAD', diff: diffStat };
|
|
268
|
-
} catch {}
|
|
269
|
-
try {
|
|
270
|
-
const args = to?.commit
|
|
271
|
-
? ['diff', '--name-only', `${from.commit}..${to.commit}`]
|
|
272
|
-
: ['diff', '--name-only', from.commit];
|
|
273
|
-
const { stdout } = await this.exec('git', args, opts);
|
|
274
|
-
return { from: fromId, to: toId || 'HEAD', diff: String(stdout ?? '').trim() || '(no changes)' };
|
|
275
|
-
} catch {}
|
|
299
|
+
async loadFromRefs(targetCwd) {
|
|
300
|
+
const cwd = this._resolveCwd(targetCwd);
|
|
301
|
+
if (!(await this._hasGit(cwd))) return 0;
|
|
302
|
+
let refs = [];
|
|
303
|
+
try {
|
|
304
|
+
// Support both single-batch with committerdate/subject and basic for-each-ref
|
|
305
|
+
const { stdout } = await this.exec('git', [
|
|
306
|
+
'for-each-ref',
|
|
307
|
+
'--format=%(refname)%00%(objectname)%00%(committerdate:raw)%00%(contents:subject)',
|
|
308
|
+
'refs/dsh-time-machine'
|
|
309
|
+
], { cwd });
|
|
310
|
+
refs = String(stdout ?? '').trim().split('\n').filter(Boolean);
|
|
311
|
+
} catch {
|
|
312
|
+
return 0;
|
|
276
313
|
}
|
|
277
|
-
const toLabel = to ? to.label : 'current';
|
|
278
|
-
return { from: fromId, to: toId || 'HEAD', diff: `diff ${from.label} -> ${toLabel} (no git repo)` };
|
|
279
|
-
}
|
|
280
314
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
315
|
+
let restoredCount = 0;
|
|
316
|
+
for (const line of refs) {
|
|
317
|
+
const parts = line.split('\0');
|
|
318
|
+
const ref = parts[0]?.trim();
|
|
319
|
+
const commit = parts[1]?.trim();
|
|
320
|
+
if (!ref || !commit) continue;
|
|
321
|
+
if (this.snapshots.some(s => s.ref === ref)) continue;
|
|
322
|
+
|
|
323
|
+
let createdAt = 0;
|
|
324
|
+
let label = ref.split('/').pop();
|
|
325
|
+
|
|
326
|
+
// If parts has date and subject from single-batch
|
|
327
|
+
if (parts.length >= 4 && parts[2] && parts[3] !== undefined) {
|
|
328
|
+
const timestampSeconds = parseInt(parts[2].split(' ')[0], 10);
|
|
329
|
+
if (!isNaN(timestampSeconds)) createdAt = timestampSeconds * 1000;
|
|
330
|
+
const sub = parts[3].trim().replace(/^\[dsh-time-machine\]\s*/, '');
|
|
331
|
+
if (sub) label = sub;
|
|
332
|
+
} else {
|
|
333
|
+
// Fallback for tests or git versions returning minimal for-each-ref
|
|
334
|
+
try {
|
|
335
|
+
const { stdout } = await this.exec('git', ['log', '-1', '--format=%ct%00%s', ref], { cwd });
|
|
336
|
+
const [ct, msg] = String(stdout ?? '').split('\0');
|
|
337
|
+
createdAt = Number(ct) * 1000 || 0;
|
|
338
|
+
if (msg && String(msg).trim()) label = String(msg).trim();
|
|
339
|
+
} catch {}
|
|
291
340
|
}
|
|
341
|
+
|
|
342
|
+
const short = ref.replace(/^refs\/dsh-time-machine\//, '');
|
|
343
|
+
const slash = short.indexOf('/');
|
|
344
|
+
const sessionId = slash === -1 ? '' : short.slice(0, slash);
|
|
345
|
+
const id = slash === -1 ? short : short.slice(slash + 1);
|
|
346
|
+
|
|
347
|
+
this.snapshots.push({
|
|
348
|
+
id: id || short,
|
|
349
|
+
label,
|
|
350
|
+
sessionId,
|
|
351
|
+
createdAt,
|
|
352
|
+
commit,
|
|
353
|
+
ref,
|
|
354
|
+
seq: ++this.seq,
|
|
355
|
+
});
|
|
356
|
+
restoredCount++;
|
|
292
357
|
}
|
|
358
|
+
|
|
359
|
+
this.snapshots.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0) || a.seq - b.seq);
|
|
360
|
+
const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
|
|
361
|
+
for (const sid of sessions) await this._trimFor(sid, cwd);
|
|
362
|
+
if (sessions.length === 0) await this._trimFor('', cwd);
|
|
363
|
+
return restoredCount;
|
|
293
364
|
}
|
|
294
365
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-time-machine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "DSH plugin for smart checkpoints, workspace safety guards, and instant rollback",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -55,4 +55,4 @@
|
|
|
55
55
|
"publishConfig": {
|
|
56
56
|
"access": "public"
|
|
57
57
|
}
|
|
58
|
-
}
|
|
58
|
+
}
|