@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 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.list),
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 sessionIdOf(execution, ctx) {
15
+ function cwdOf(execution, ctx) {
16
16
  try {
17
- if (execution && execution.sessionId) return String(execution.sessionId);
18
- if (execution && execution.session) {
19
- return String(execution.session.id || execution.session.header?.id || '');
20
- }
21
- if (execution && execution.data && execution.data.sessionId) {
22
- return String(execution.data.sessionId);
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
- let engine = new ShadowSnapshotEngine({ maxSnapshots: config?.maxSnapshots ?? 20 });
38
- engine.loadFromRefs().catch(() => {});
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
- await engine.createSnapshot(label, { sessionId: sid });
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) || sessionIdOf(args, tctx) || '').trim();
139
- const snap = await engine.createSnapshot(args.label, { sessionId: sid });
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 res = await engine.rollbackSnapshot(String(id ?? ''), { confirm });
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 res = await engine.diff(String(from ?? ''), to ? String(to) : undefined);
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 res = await engine.deleteSnapshot(String(id ?? ''), { confirm });
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 out = await engine.pruneSnapshots(sid, args.keep);
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 snap = await engine.createSnapshot(parsed.label, { sessionId: sid });
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 out = await engine.deleteSnapshot(String(parsed.id || ''), { confirm: parsed.confirm });
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 out = await engine.pruneSnapshots(sid, parsed.keep);
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 out = await engine.rollbackSnapshot(String(parsed.id || ''), { confirm: parsed.confirm });
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
- async _trimFor(sessionId) {
34
- if (!sessionId) {
35
- while (this.snapshots.length > this.maxSnapshots) {
36
- const oldest = this.snapshots.shift();
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
- async _isGitRepo() {
57
- try {
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 _gitDir() {
46
+ async _hasGit(targetCwd) {
64
47
  try {
65
- const { stdout } = await this.exec('git', ['rev-parse', '--git-dir'], this.cwd ? { cwd: this.cwd } : {});
66
- const gdir = String(stdout ?? '').trim();
67
- if (!gdir) return null;
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 null;
52
+ return false;
71
53
  }
72
54
  }
73
55
 
74
- // Restore checkpoint metadata from git shadow refs after a restart.
75
- // git is the durable store; memory is rebuilt from refs/dsh-time-machine/*.
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
- 'git',
82
- ['for-each-ref', '--format=%(refname)%00%(objectname)', 'refs/dsh-time-machine/'],
83
- this.cwd ? { cwd: this.cwd } : {},
84
- );
85
- refs = String(stdout ?? '').trim().split('\n').filter(Boolean);
86
- } catch { return 0; }
87
- const restored = [];
88
- for (const line of refs) {
89
- const [ref, commit] = line.split('\0');
90
- if (!ref || !commit) continue;
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
- // stable order: oldest first, tie-break by ref name
111
- restored.sort((a, b) => a.createdAt - b.createdAt || (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0));
112
- for (const snap of restored) snap.seq = ++this.seq;
113
- this.snapshots.push(...restored);
114
- const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
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 { await this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}); } catch {}
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
- async createSnapshot(label = '', { sessionId = '' } = {}) {
128
- const id = crypto.randomUUID().slice(0, 8);
129
- const createdAt = this._now();
130
- const safeLabel = String(label ?? '').trim() || `checkpoint-${id}`;
131
- const rawSid = String(sessionId || '').trim();
132
- const safeSid = rawSid ? rawSid.replace(/[^a-zA-Z0-9_-]/g, '_') : '';
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
- const inGit = await this._isGitRepo();
136
- if (inGit) {
137
- let shadowIndex = null;
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
- const gitDir = await this._gitDir();
140
- const baseOpts = this.cwd ? { cwd: this.cwd } : {};
141
- const execEnv = {
142
- ...process.env,
143
- GIT_AUTHOR_NAME: 'DSH Time Machine',
144
- GIT_AUTHOR_EMAIL: 'time-machine@dsh.local',
145
- GIT_COMMITTER_NAME: 'DSH Time Machine',
146
- GIT_COMMITTER_EMAIL: 'time-machine@dsh.local',
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 shadowOpts = { ...baseOpts, env: execEnv };
155
- await this.exec('git', ['add', '-A'], shadowOpts);
156
- const { stdout: tree } = await this.exec('git', ['write-tree'], shadowOpts);
157
- const treeHash = String(tree).trim();
158
- const { stdout: commitHash } = await this.exec('git', ['commit-tree', treeHash, '-m', safeLabel], shadowOpts);
159
- commit = String(commitHash).trim();
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
- const snap = { id, label: safeLabel, sessionId: rawSid, createdAt, commit, ref, seq: ++this.seq };
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(rawSid);
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
- async rollbackSnapshot(id, { confirm } = {}) {
188
- if (confirm !== true) {
189
- const err = new Error('confirm:true required to rollback');
190
- err.code = 'CONFIRM_REQUIRED';
191
- throw err;
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
- const err = new Error(`snapshot ${id} not found`);
196
- err.code = 'NOT_FOUND';
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
- // Safe rollback: restore index and working tree to snapshot without moving branch HEAD
204
- await this.exec('git', ['read-tree', snap.commit], opts);
205
- await this.exec('git', ['checkout-index', '-a', '-f'], opts);
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
- // Fallback: checkout files directly from commit tree
209
- await this.exec('git', ['checkout', snap.commit, '--', '.'], opts).catch(() => {});
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, snapshot: snap };
256
+ return { rolledBack: true, id, commit: snap.commit };
214
257
  }
215
258
 
216
- async deleteSnapshot(id, { confirm } = {}) {
217
- if (confirm !== true) {
218
- const err = new Error('confirm:true required to delete');
219
- err.code = 'CONFIRM_REQUIRED';
220
- throw err;
221
- }
222
- const snap = this.getSnapshot(id);
223
- if (!snap) {
224
- const err = new Error(`snapshot ${id} not found`);
225
- err.code = 'NOT_FOUND';
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
- async pruneSnapshots(sessionId, keep = 3) {
239
- const sid = sessionId != null ? String(sessionId).trim() : undefined;
240
- const list = this.listSnapshots(sid); // newest first
241
- const minKeep = Math.max(0, Number.isFinite(Number(keep)) ? Number(keep) : 3);
242
- const toRemove = list.slice(minKeep);
243
- const removed = [];
244
- for (const snap of toRemove) {
245
- try { await this.deleteSnapshot(snap.id, { confirm: true }); removed.push(snap.id); } catch {}
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 diff(fromId, toId) {
251
- const from = this.getSnapshot(fromId);
252
- if (!from) {
253
- const err = new Error(`snapshot ${fromId} not found`);
254
- err.code = 'NOT_FOUND';
255
- throw err;
256
- }
257
- let to = toId ? this.getSnapshot(toId) : null;
258
- const inGit = await this._isGitRepo();
259
- if (inGit && from.commit) {
260
- const opts = this.cwd ? { cwd: this.cwd } : {};
261
- try {
262
- const args = to?.commit
263
- ? ['diff', '--stat', `${from.commit}..${to.commit}`]
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
- setMax(n) {
282
- this.maxSnapshots = Math.max(1, Number(n) || 20);
283
- const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
284
- for (const sid of sessions) this._trimFor(sid).catch(() => {});
285
- if (sessions.length === 0) {
286
- while (this.snapshots.length > this.maxSnapshots) {
287
- const oldest = this.snapshots.shift();
288
- if (oldest?.ref) {
289
- this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}).catch(() => {});
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.9",
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
+ }