codemini-cli 0.6.3 → 0.6.5

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.
Files changed (49) hide show
  1. package/codemini-web/dist/assets/{AboutDialog-jgqGjQgl.js → AboutDialog-BUp8EzDg.js} +2 -2
  2. package/codemini-web/dist/assets/CodeWikiPanel-Fp0VKdzo.js +1 -0
  3. package/codemini-web/dist/assets/ConfigDialog-DIpj779O.js +1 -0
  4. package/codemini-web/dist/assets/GitDiffDialog-ZLEuX8Qm.js +3 -0
  5. package/codemini-web/dist/assets/{MemoryDialog-BhxQgG0I.js → MemoryDialog-D2YbENVd.js} +3 -3
  6. package/codemini-web/dist/assets/MessageBubble-BIgpZsLn.js +12 -0
  7. package/codemini-web/dist/assets/PatchDiff-CvKNaHsw.js +230 -0
  8. package/codemini-web/dist/assets/ProjectSelector-DXIep3lE.js +1 -0
  9. package/codemini-web/dist/assets/{SkillDialog-DxS43NpR.js → SkillDialog-DjPF-XBx.js} +4 -4
  10. package/codemini-web/dist/assets/SoulDialog-BfIoKETs.js +1 -0
  11. package/codemini-web/dist/assets/chevron-right-CfNZHlyU.js +1 -0
  12. package/codemini-web/dist/assets/{chunk-BO2N2NFS-Budy_hfO.js → chunk-BO2N2NFS-DMUdjM9q.js} +6 -6
  13. package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-CQS1PAvD.js → highlighted-body-OFNGDK62-8ch0jz7Z.js} +1 -1
  14. package/codemini-web/dist/assets/index-BhMtCC8_.js +65 -0
  15. package/codemini-web/dist/assets/index-DRXwJ-n_.css +2 -0
  16. package/codemini-web/dist/assets/input-CYpdNDlR.js +1 -0
  17. package/codemini-web/dist/assets/lib-BXWizt13.js +1 -0
  18. package/codemini-web/dist/assets/mermaid-GHXKKRXX-KBEtMEB9.js +1 -0
  19. package/codemini-web/dist/assets/{pencil-Ce_LFiEh.js → pencil-BdA2cEeE.js} +1 -1
  20. package/codemini-web/dist/assets/{refresh-cw-BKL-AZu5.js → refresh-cw-CJGgUGiS.js} +1 -1
  21. package/codemini-web/dist/assets/select-BLOccU1M.js +1 -0
  22. package/codemini-web/dist/assets/{trash-2-KmAlCwXd.js → trash-2-CQzNOch5.js} +1 -1
  23. package/codemini-web/dist/index.html +2 -2
  24. package/codemini-web/lib/runtime-bridge.js +332 -296
  25. package/codemini-web/server.js +319 -243
  26. package/package.json +1 -1
  27. package/src/core/agent-loop.js +188 -100
  28. package/src/core/chat-runtime.js +676 -571
  29. package/src/core/config-store.js +9 -3
  30. package/src/core/git-oplog-change-tracker.js +468 -0
  31. package/src/core/non-git-backup.js +116 -0
  32. package/src/core/paths.js +123 -123
  33. package/src/core/session-store.js +148 -99
  34. package/src/core/tools.js +555 -434
  35. package/src/tui/chat-app.js +196 -56
  36. package/codemini-web/dist/assets/CodeWikiPanel-EPuoerNv.js +0 -1
  37. package/codemini-web/dist/assets/ConfigDialog-B5IGZCc9.js +0 -1
  38. package/codemini-web/dist/assets/GitDiffDialog-Bb_Tw5ZK.js +0 -222
  39. package/codemini-web/dist/assets/MessageBubble-wUff4GP4.js +0 -6
  40. package/codemini-web/dist/assets/ProjectSelector-C0leTf6f.js +0 -1
  41. package/codemini-web/dist/assets/SoulDialog-XDTEGWvH.js +0 -1
  42. package/codemini-web/dist/assets/chevron-right-Dbzw7YzA.js +0 -1
  43. package/codemini-web/dist/assets/index-D0EGtNPr.js +0 -65
  44. package/codemini-web/dist/assets/index-wOUf3WkN.css +0 -2
  45. package/codemini-web/dist/assets/input-CNQgbKe6.js +0 -1
  46. package/codemini-web/dist/assets/lib-BOngVP_M.js +0 -11
  47. package/codemini-web/dist/assets/lib-DrOTTm_N.js +0 -1
  48. package/codemini-web/dist/assets/mermaid-GHXKKRXX-DrBu5KyC.js +0 -1
  49. package/codemini-web/dist/assets/select-BZXfigic.js +0 -1
@@ -42,6 +42,7 @@ const DEFAULT_CONFIG = {
42
42
  },
43
43
  execution: {
44
44
  mode: 'normal',
45
+ approval_mode: 'review',
45
46
  always_allow_tools: [
46
47
  'read',
47
48
  'grep',
@@ -148,9 +149,14 @@ function normalizePolicyLists(config) {
148
149
  }
149
150
  next.model.name = String(next.model.name || DEFAULT_CONFIG.model.name).trim() || DEFAULT_CONFIG.model.name;
150
151
  next.model.fast_name = String(next.model.fast_name || '').trim();
151
- next.execution.mode = ['auto', 'normal', 'plan'].includes(String(next.execution.mode || '').toLowerCase())
152
- ? String(next.execution.mode).toLowerCase()
153
- : 'normal';
152
+ const rawExecutionMode = String(next.execution.mode || '').toLowerCase();
153
+ const rawApprovalMode = String(next.execution.approval_mode || '').toLowerCase().replace(/-/g, '_');
154
+ next.execution.mode = ['normal', 'plan'].includes(rawExecutionMode)
155
+ ? rawExecutionMode
156
+ : 'normal';
157
+ next.execution.approval_mode = ['review', 'auto', 'full_access'].includes(rawApprovalMode)
158
+ ? rawApprovalMode
159
+ : 'review';
154
160
  const rawTools = Array.isArray(next.execution.always_allow_tools)
155
161
  ? next.execution.always_allow_tools
156
162
  : [];
@@ -0,0 +1,468 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { spawn } from 'node:child_process';
5
+ import { normalizePath } from './string-utils.js';
6
+
7
+ const CHANGE_OPLOG_VERSION = 1;
8
+ const FILE_TOOLS = new Set(['edit', 'write', 'delete']);
9
+
10
+ function runGit(args, { cwd, input = null, allowFailure = false, timeoutMs = 120_000 } = {}) {
11
+ return new Promise((resolve, reject) => {
12
+ const child = spawn('git', args, {
13
+ cwd,
14
+ windowsHide: true,
15
+ stdio: ['pipe', 'pipe', 'pipe']
16
+ });
17
+ const stdoutChunks = [];
18
+ const stderrChunks = [];
19
+ const timer = setTimeout(() => {
20
+ try { child.kill(); } catch {}
21
+ }, timeoutMs);
22
+ child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
23
+ child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
24
+ child.on('error', (error) => {
25
+ clearTimeout(timer);
26
+ reject(error);
27
+ });
28
+ child.on('close', (code) => {
29
+ clearTimeout(timer);
30
+ const stdoutBuffer = Buffer.concat(stdoutChunks);
31
+ const stderrBuffer = Buffer.concat(stderrChunks);
32
+ const result = {
33
+ code,
34
+ stdout: stdoutBuffer.toString('utf8'),
35
+ stderr: stderrBuffer.toString('utf8'),
36
+ stdoutBuffer,
37
+ stderrBuffer
38
+ };
39
+ if (code === 0 || allowFailure) resolve(result);
40
+ else reject(new Error(result.stderr.trim() || result.stdout.trim() || `git exited with code ${code}`));
41
+ });
42
+ if (input != null) child.stdin.end(input);
43
+ else child.stdin.end();
44
+ });
45
+ }
46
+
47
+ function changeId(prefix = 'op') {
48
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
49
+ const rand = Math.random().toString(36).slice(2, 8);
50
+ return `${prefix}-${stamp}-${rand}`;
51
+ }
52
+
53
+ function normalizeRelativePath(value, { root = '' } = {}) {
54
+ let text = normalizePath(String(value || '').trim()).replace(/:\d+(?:-\d+)?$/, '');
55
+ if (!text || text === '.') return '';
56
+ if (/^[a-zA-Z]:\//.test(text) || text.startsWith('/')) {
57
+ if (!root) return '';
58
+ const absRoot = path.resolve(root);
59
+ const absTarget = path.resolve(text);
60
+ const rel = path.relative(absRoot, absTarget);
61
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '';
62
+ text = normalizePath(rel);
63
+ }
64
+ if (text.startsWith('../')) return '';
65
+ return text;
66
+ }
67
+
68
+ function extractPathCandidates(args = {}, declaredChanges = [], options = {}) {
69
+ const candidates = [];
70
+ for (const change of Array.isArray(declaredChanges) ? declaredChanges : [declaredChanges]) {
71
+ if (change?.path) candidates.push(change.path);
72
+ }
73
+ for (const value of [
74
+ args?.path,
75
+ args?.file,
76
+ args?.file_path,
77
+ args?.target,
78
+ args?.edit?.path,
79
+ args?.edit?.file,
80
+ args?.edit?.file_path,
81
+ args?.edit?.target?.path
82
+ ]) {
83
+ if (typeof value === 'string') candidates.push(value);
84
+ }
85
+ const out = [];
86
+ const seen = new Set();
87
+ for (const value of candidates) {
88
+ const normalized = normalizeRelativePath(value, options);
89
+ if (!normalized || seen.has(normalized)) continue;
90
+ seen.add(normalized);
91
+ out.push(normalized);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function parseStatusPaths(text) {
97
+ const parts = String(text || '').split('\0').filter(Boolean);
98
+ const paths = [];
99
+ for (let i = 0; i < parts.length; i += 1) {
100
+ const entry = parts[i];
101
+ if (entry.length < 4) continue;
102
+ const status = entry.slice(0, 2);
103
+ const filePath = normalizeRelativePath(entry.slice(3));
104
+ if (filePath) paths.push(filePath);
105
+ if (status.includes('R') || status.includes('C')) {
106
+ i += 1;
107
+ const renamedPath = normalizeRelativePath(parts[i] || '');
108
+ if (renamedPath) paths.push(renamedPath);
109
+ }
110
+ }
111
+ return [...new Set(paths)];
112
+ }
113
+
114
+ async function readWorktreeSnapshot(root, relativePath) {
115
+ const abs = path.resolve(root, relativePath);
116
+ if (!abs.startsWith(`${root}${path.sep}`) && abs !== root) {
117
+ return { exists: false, content: Buffer.alloc(0), hash: '' };
118
+ }
119
+ try {
120
+ const content = await fs.readFile(abs);
121
+ return { exists: true, content, hash: hashBuffer(content) };
122
+ } catch (error) {
123
+ if (error?.code === 'ENOENT') return { exists: false, content: Buffer.alloc(0), hash: '' };
124
+ throw error;
125
+ }
126
+ }
127
+
128
+ async function readHeadSnapshot(root, relativePath) {
129
+ try {
130
+ const result = await runGit(['show', `HEAD:${relativePath}`], {
131
+ cwd: root,
132
+ allowFailure: true,
133
+ timeoutMs: 60_000
134
+ });
135
+ if (result.code !== 0) return { exists: false, content: Buffer.alloc(0), hash: '' };
136
+ return { exists: true, content: result.stdoutBuffer, hash: hashBuffer(result.stdoutBuffer) };
137
+ } catch {
138
+ return { exists: false, content: Buffer.alloc(0), hash: '' };
139
+ }
140
+ }
141
+
142
+ function hashBuffer(buffer) {
143
+ let hash = 5381;
144
+ for (const byte of buffer) hash = ((hash << 5) + hash + byte) >>> 0;
145
+ return hash.toString(16);
146
+ }
147
+
148
+ function countPatchLines(patch) {
149
+ let added = 0;
150
+ let removed = 0;
151
+ for (const line of String(patch || '').split(/\r?\n/)) {
152
+ if (line.startsWith('+++') || line.startsWith('---')) continue;
153
+ if (line.startsWith('+')) added += 1;
154
+ else if (line.startsWith('-')) removed += 1;
155
+ }
156
+ return { added, removed };
157
+ }
158
+
159
+ function firstChangedLineFromPatch(patch) {
160
+ const match = String(patch || '').match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/m);
161
+ return match ? Math.max(1, Number(match[1]) || 1) : 0;
162
+ }
163
+
164
+ function actionFromSnapshots(before, after) {
165
+ if (!before.exists && after.exists) return 'create';
166
+ if (before.exists && !after.exists) return 'delete';
167
+ return 'edit';
168
+ }
169
+
170
+ async function writeTempSnapshot(dir, name, snapshot) {
171
+ const filePath = path.join(dir, name);
172
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
173
+ await fs.writeFile(filePath, snapshot.content);
174
+ return filePath;
175
+ }
176
+
177
+ async function buildPatchForFile(root, relativePath, before, after) {
178
+ if (before.exists === after.exists && before.hash === after.hash) return '';
179
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'codemini-oplog-'));
180
+ try {
181
+ const oldPath = normalizePath(path.posix.join('old', relativePath));
182
+ const newPath = normalizePath(path.posix.join('new', relativePath));
183
+ const beforePath = await writeTempSnapshot(tmp, oldPath, before.exists ? before : { content: Buffer.alloc(0) });
184
+ const afterPath = await writeTempSnapshot(tmp, newPath, after.exists ? after : { content: Buffer.alloc(0) });
185
+ const result = await runGit([
186
+ 'diff',
187
+ '--no-index',
188
+ '--no-color',
189
+ '--binary',
190
+ oldPath,
191
+ newPath
192
+ ], { cwd: tmp, allowFailure: true, timeoutMs: 120_000 });
193
+ if (result.code !== 1) return '';
194
+ const escaped = relativePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
195
+ let patch = result.stdout
196
+ .replace(new RegExp(`diff --git a/old/${escaped} b/new/${escaped}`), `diff --git a/${relativePath} b/${relativePath}`)
197
+ .replace(new RegExp(`--- a/old/${escaped}`), before.exists ? `--- a/${relativePath}` : '--- /dev/null')
198
+ .replace(new RegExp(`\\+\\+\\+ b/new/${escaped}`), after.exists ? `+++ b/${relativePath}` : '+++ /dev/null');
199
+ if (!before.exists && after.exists && !/^new file mode /m.test(patch)) {
200
+ patch = patch.replace(
201
+ new RegExp(`^(diff --git a/${escaped} b/${escaped})$`, 'm'),
202
+ `$1\nnew file mode 100644`
203
+ );
204
+ }
205
+ if (before.exists && !after.exists && !/^deleted file mode /m.test(patch)) {
206
+ patch = patch.replace(
207
+ new RegExp(`^(diff --git a/${escaped} b/${escaped})$`, 'm'),
208
+ `$1\ndeleted file mode 100644`
209
+ );
210
+ }
211
+ return patch;
212
+ } finally {
213
+ await fs.rm(tmp, { recursive: true, force: true }).catch(() => {});
214
+ }
215
+ }
216
+
217
+ async function writeJson(filePath, value) {
218
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
219
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
220
+ }
221
+
222
+ async function readJson(filePath) {
223
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
224
+ }
225
+
226
+ export async function createGitOplogChangeTracker({ workspaceRoot = process.cwd(), sessionId } = {}) {
227
+ const startRoot = path.resolve(workspaceRoot);
228
+ const id = String(sessionId || '').trim();
229
+ if (!id) return { enabled: false, reason: 'missing-session' };
230
+
231
+ try {
232
+ const inside = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd: startRoot, allowFailure: true });
233
+ if (inside.code !== 0 || inside.stdout.trim() !== 'true') {
234
+ return { enabled: false, reason: 'not-git-repo', workspaceRoot: startRoot };
235
+ }
236
+ const root = path.resolve((await runGit(['rev-parse', '--show-toplevel'], { cwd: startRoot })).stdout.trim());
237
+ const head = await runGit(['rev-parse', '--verify', 'HEAD'], { cwd: root, allowFailure: true });
238
+ const hasHead = head.code === 0;
239
+ const gitPath = (await runGit(['rev-parse', '--git-path', `codemini/sessions/${id}`], { cwd: root })).stdout.trim();
240
+ const oplogDir = path.resolve(root, gitPath);
241
+ const patchesDir = path.join(oplogDir, 'patches');
242
+ const opsDir = path.join(oplogDir, 'ops');
243
+ await fs.mkdir(patchesDir, { recursive: true });
244
+ await fs.mkdir(opsDir, { recursive: true });
245
+ if (hasHead) {
246
+ await runGit(['update-ref', `refs/codemini/sessions/${id}`, 'HEAD'], { cwd: root, allowFailure: true });
247
+ }
248
+ return {
249
+ enabled: true,
250
+ mode: 'git-oplog',
251
+ workspaceRoot: root,
252
+ sessionId: id,
253
+ baseCommit: hasHead ? head.stdout.trim() : '',
254
+ oplogDir,
255
+ patchesDir,
256
+ opsDir
257
+ };
258
+ } catch (error) {
259
+ return { enabled: false, reason: error instanceof Error ? error.message : String(error), workspaceRoot: startRoot };
260
+ }
261
+ }
262
+
263
+ export function isGitOplogChangeTrackerAvailable(tracker) {
264
+ return Boolean(tracker?.enabled && tracker.mode === 'git-oplog');
265
+ }
266
+
267
+ export async function beginGitOplogCapture(tracker, { toolName = '', args = {} } = {}) {
268
+ if (!isGitOplogChangeTrackerAvailable(tracker)) return null;
269
+ const explicitPaths = FILE_TOOLS.has(String(toolName || '')) ? extractPathCandidates(args, [], { root: tracker.workspaceRoot }) : [];
270
+ const status = await runGit(['status', '--porcelain=v1', '-z'], { cwd: tracker.workspaceRoot, allowFailure: true });
271
+ const dirtyPaths = parseStatusPaths(status.stdout);
272
+ const paths = explicitPaths.length ? explicitPaths : dirtyPaths;
273
+ const snapshots = new Map();
274
+ for (const filePath of paths) {
275
+ snapshots.set(filePath, await readWorktreeSnapshot(tracker.workspaceRoot, filePath));
276
+ }
277
+ return {
278
+ toolName,
279
+ explicit: explicitPaths.length > 0,
280
+ beforeDirtyPaths: dirtyPaths,
281
+ paths,
282
+ snapshots
283
+ };
284
+ }
285
+
286
+ export async function captureGitOplogChanges(tracker, capture, { toolName = '', toolCallId = '', summary = '', args = {}, declaredFileChanges = [] } = {}) {
287
+ if (!isGitOplogChangeTrackerAvailable(tracker) || !capture) return null;
288
+ const afterStatus = await runGit(['status', '--porcelain=v1', '-z'], { cwd: tracker.workspaceRoot, allowFailure: true });
289
+ const afterDirtyPaths = parseStatusPaths(afterStatus.stdout);
290
+ const declaredPaths = extractPathCandidates(args, declaredFileChanges, { root: tracker.workspaceRoot });
291
+ const candidates = new Set([
292
+ ...capture.paths,
293
+ ...declaredPaths,
294
+ ...(capture.explicit ? [] : capture.beforeDirtyPaths),
295
+ ...(capture.explicit ? [] : afterDirtyPaths)
296
+ ]);
297
+
298
+ const files = [];
299
+ const patches = [];
300
+ for (const filePath of candidates) {
301
+ const normalized = normalizeRelativePath(filePath, { root: tracker.workspaceRoot });
302
+ if (!normalized) continue;
303
+ let before = capture.snapshots.get(normalized);
304
+ if (!before) before = await readHeadSnapshot(tracker.workspaceRoot, normalized);
305
+ const after = await readWorktreeSnapshot(tracker.workspaceRoot, normalized);
306
+ const patch = await buildPatchForFile(tracker.workspaceRoot, normalized, before, after);
307
+ if (!patch.trim()) continue;
308
+ const stats = countPatchLines(patch);
309
+ files.push({
310
+ path: normalized,
311
+ action: actionFromSnapshots(before, after),
312
+ linesAdded: stats.added,
313
+ linesRemoved: stats.removed,
314
+ changedLine: firstChangedLineFromPatch(patch),
315
+ diffPreview: patch
316
+ });
317
+ patches.push(patch);
318
+ }
319
+ if (!files.length) return null;
320
+
321
+ const opId = changeId('op');
322
+ const patch = patches.join('\n');
323
+ const patchPath = path.join(tracker.patchesDir, `${opId}.patch`);
324
+ const opPath = path.join(tracker.opsDir, `${opId}.json`);
325
+ await fs.writeFile(patchPath, patch, 'utf8');
326
+ const op = {
327
+ version: CHANGE_OPLOG_VERSION,
328
+ id: opId,
329
+ sessionId: tracker.sessionId,
330
+ createdAt: new Date().toISOString(),
331
+ toolName,
332
+ toolCallId,
333
+ summary,
334
+ patchPath,
335
+ files: files.map(({ diffPreview, ...file }) => file),
336
+ revertedAt: null
337
+ };
338
+ await writeJson(opPath, op);
339
+ await fs.appendFile(path.join(tracker.oplogDir, 'ops.jsonl'), `${JSON.stringify({ ...op, patchPath: undefined })}\n`, 'utf8');
340
+ return files.map((file) => ({
341
+ ...file,
342
+ changeSetId: opId,
343
+ files: [{ path: file.path, action: file.action, linesAdded: file.linesAdded, linesRemoved: file.linesRemoved }]
344
+ }));
345
+ }
346
+
347
+ export async function listGitOplogChanges(tracker) {
348
+ if (!isGitOplogChangeTrackerAvailable(tracker)) return [];
349
+ let entries = [];
350
+ try {
351
+ entries = await fs.readdir(tracker.opsDir, { withFileTypes: true });
352
+ } catch {
353
+ return [];
354
+ }
355
+ const out = [];
356
+ for (const entry of entries) {
357
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
358
+ try { out.push(await readJson(path.join(tracker.opsDir, entry.name))); } catch {}
359
+ }
360
+ out.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
361
+ return out;
362
+ }
363
+
364
+ export async function readGitOplogChange(tracker, opId) {
365
+ if (!isGitOplogChangeTrackerAvailable(tracker)) throw new Error('Git change oplog is not available for this session');
366
+ const id = String(opId || '').trim();
367
+ if (!id) throw new Error('Missing change id');
368
+ return readJson(path.join(tracker.opsDir, `${id}.json`));
369
+ }
370
+
371
+ export async function readGitOplogPatch(tracker, opId) {
372
+ const op = await readGitOplogChange(tracker, opId);
373
+ return fs.readFile(op.patchPath, 'utf8');
374
+ }
375
+
376
+ export async function undoGitOplogChange(tracker, opId) {
377
+ if (!isGitOplogChangeTrackerAvailable(tracker)) throw new Error('Git change oplog is not available for this session');
378
+ const op = await readGitOplogChange(tracker, opId);
379
+ if (op.revertedAt) {
380
+ return { ok: false, alreadyReverted: true, changeSetId: op.id, message: 'Change already reverted' };
381
+ }
382
+ const patch = await fs.readFile(op.patchPath, 'utf8');
383
+ try {
384
+ await runGit(['apply', '-R', '--check', '--whitespace=nowarn'], {
385
+ cwd: tracker.workspaceRoot,
386
+ input: patch,
387
+ timeoutMs: 120_000
388
+ });
389
+ } catch (error) {
390
+ throw new Error(`Cannot undo this change cleanly because newer edits conflict with it. Undo newer changes first, or revert it manually. ${error?.message || ''}`.trim());
391
+ }
392
+ await runGit(['apply', '-R', '--whitespace=nowarn'], {
393
+ cwd: tracker.workspaceRoot,
394
+ input: patch,
395
+ timeoutMs: 120_000
396
+ });
397
+ op.revertedAt = new Date().toISOString();
398
+ await writeJson(path.join(tracker.opsDir, `${op.id}.json`), op);
399
+ return { ok: true, changeSetId: op.id };
400
+ }
401
+
402
+ export async function undoGitOplogChanges(tracker, opIds = []) {
403
+ if (!isGitOplogChangeTrackerAvailable(tracker)) throw new Error('Git change oplog is not available for this session');
404
+ const ids = [];
405
+ const seen = new Set();
406
+ for (const rawId of Array.isArray(opIds) ? opIds : [opIds]) {
407
+ const id = String(rawId || '').trim();
408
+ if (!id || seen.has(id)) continue;
409
+ seen.add(id);
410
+ ids.push(id);
411
+ }
412
+ if (!ids.length) throw new Error('Missing change ids');
413
+ if (ids.length === 1) return undoGitOplogChange(tracker, ids[0]);
414
+
415
+ const ops = await Promise.all(ids.map((id) => readGitOplogChange(tracker, id)));
416
+ const reverted = ops.find((op) => op.revertedAt);
417
+ if (reverted) {
418
+ return { ok: false, alreadyReverted: true, changeSetId: reverted.id, message: 'Change already reverted' };
419
+ }
420
+
421
+ const order = new Map(ids.map((id, index) => [id, index]));
422
+ ops.sort((a, b) => {
423
+ const byTime = String(b.createdAt || '').localeCompare(String(a.createdAt || ''));
424
+ if (byTime) return byTime;
425
+ return (order.get(b.id) ?? 0) - (order.get(a.id) ?? 0);
426
+ });
427
+
428
+ const patches = await Promise.all(ops.map(async (op) => ({
429
+ op,
430
+ patch: await fs.readFile(op.patchPath, 'utf8')
431
+ })));
432
+ if (!patches.some((item) => String(item.patch || '').trim())) throw new Error('Missing change patch');
433
+
434
+ const applied = [];
435
+ try {
436
+ for (const item of patches) {
437
+ if (!String(item.patch || '').trim()) continue;
438
+ await runGit(['apply', '-R', '--check', '--whitespace=nowarn'], {
439
+ cwd: tracker.workspaceRoot,
440
+ input: item.patch,
441
+ timeoutMs: 120_000
442
+ });
443
+ await runGit(['apply', '-R', '--whitespace=nowarn'], {
444
+ cwd: tracker.workspaceRoot,
445
+ input: item.patch,
446
+ timeoutMs: 120_000
447
+ });
448
+ applied.push(item);
449
+ }
450
+ } catch (error) {
451
+ for (const item of applied.reverse()) {
452
+ await runGit(['apply', '--whitespace=nowarn'], {
453
+ cwd: tracker.workspaceRoot,
454
+ input: item.patch,
455
+ allowFailure: true,
456
+ timeoutMs: 120_000
457
+ });
458
+ }
459
+ throw new Error(`Cannot undo this change cleanly because newer edits conflict with it. Undo newer changes first, or revert it manually. ${error?.message || ''}`.trim());
460
+ }
461
+
462
+ const revertedAt = new Date().toISOString();
463
+ for (const op of ops) {
464
+ op.revertedAt = revertedAt;
465
+ await writeJson(path.join(tracker.opsDir, `${op.id}.json`), op);
466
+ }
467
+ return { ok: true, changeSetIds: ops.map((op) => op.id) };
468
+ }
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { normalizePath } from './string-utils.js';
4
+
5
+ function normalizeRelativePath(value) {
6
+ const text = normalizePath(String(value || '').trim()).replace(/:\d+(?:-\d+)?$/, '');
7
+ if (!text || text === '.' || path.isAbsolute(text) || text.startsWith('../')) return '';
8
+ return text;
9
+ }
10
+
11
+ function safeBackupRelativePath(relativePath) {
12
+ return normalizeRelativePath(relativePath)
13
+ .split('/')
14
+ .map((part) => part.replace(/[<>:"\\|?*\x00-\x1F]/g, '_'))
15
+ .join('/');
16
+ }
17
+
18
+ async function readJson(filePath, fallback) {
19
+ try {
20
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
21
+ } catch {
22
+ return fallback;
23
+ }
24
+ }
25
+
26
+ async function writeJson(filePath, value) {
27
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
28
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
29
+ }
30
+
31
+ export async function createNonGitBackupManager({ workspaceRoot = process.cwd(), sessionId } = {}) {
32
+ const root = path.resolve(workspaceRoot);
33
+ const id = String(sessionId || '').trim();
34
+ if (!id) return null;
35
+ const backupRoot = path.join(root, '.codemini', 'backups', 'sessions', id);
36
+ const filesRoot = path.join(backupRoot, 'files');
37
+ const manifestPath = path.join(backupRoot, 'manifest.json');
38
+ await fs.mkdir(filesRoot, { recursive: true });
39
+ const manifest = await readJson(manifestPath, {
40
+ version: 1,
41
+ sessionId: id,
42
+ createdAt: new Date().toISOString(),
43
+ files: {}
44
+ });
45
+
46
+ async function backupOnce(relativePath) {
47
+ const normalized = normalizeRelativePath(relativePath);
48
+ if (!normalized) return { ok: false, reason: 'invalid-path' };
49
+ const existing = manifest.files?.[normalized];
50
+ if (existing) {
51
+ return { ...existing, ok: true, reused: true, created: false };
52
+ }
53
+
54
+ const target = path.resolve(root, normalized);
55
+ const rel = path.relative(root, target);
56
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
57
+ return { ok: false, path: normalized, reason: 'outside-workspace' };
58
+ }
59
+
60
+ let stat;
61
+ try {
62
+ stat = await fs.stat(target);
63
+ } catch (error) {
64
+ if (error?.code !== 'ENOENT') throw error;
65
+ const entry = {
66
+ path: normalized,
67
+ existed: false,
68
+ backupPath: '',
69
+ backupRelativePath: '',
70
+ createdAt: new Date().toISOString()
71
+ };
72
+ manifest.files[normalized] = entry;
73
+ await writeJson(manifestPath, manifest);
74
+ return { ...entry, ok: true, reused: false, created: false };
75
+ }
76
+
77
+ if (!stat.isFile()) {
78
+ const entry = {
79
+ path: normalized,
80
+ existed: true,
81
+ backupPath: '',
82
+ backupRelativePath: '',
83
+ skipped: true,
84
+ reason: stat.isDirectory() ? 'directory' : 'not-file',
85
+ createdAt: new Date().toISOString()
86
+ };
87
+ manifest.files[normalized] = entry;
88
+ await writeJson(manifestPath, manifest);
89
+ return { ...entry, ok: true, reused: false, created: false };
90
+ }
91
+
92
+ const safeRelative = safeBackupRelativePath(normalized);
93
+ const backupPath = path.join(filesRoot, safeRelative);
94
+ await fs.mkdir(path.dirname(backupPath), { recursive: true });
95
+ await fs.copyFile(target, backupPath);
96
+ const entry = {
97
+ path: normalized,
98
+ existed: true,
99
+ backupPath,
100
+ backupRelativePath: normalizePath(path.relative(root, backupPath)),
101
+ createdAt: new Date().toISOString()
102
+ };
103
+ manifest.files[normalized] = entry;
104
+ await writeJson(manifestPath, manifest);
105
+ return { ...entry, ok: true, reused: false, created: true };
106
+ }
107
+
108
+ return {
109
+ mode: 'non-git-backup',
110
+ workspaceRoot: root,
111
+ sessionId: id,
112
+ backupRoot,
113
+ manifestPath,
114
+ backupOnce
115
+ };
116
+ }