codemini-cli 0.6.3 → 0.6.4
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/codemini-web/dist/assets/{AboutDialog-jgqGjQgl.js → AboutDialog-MRopwNIL.js} +2 -2
- package/codemini-web/dist/assets/CodeWikiPanel-UpK5xGE3.js +1 -0
- package/codemini-web/dist/assets/ConfigDialog-CNl28wsj.js +1 -0
- package/codemini-web/dist/assets/GitDiffDialog-gSysUg2J.js +3 -0
- package/codemini-web/dist/assets/{MemoryDialog-BhxQgG0I.js → MemoryDialog-DFUmo3Kl.js} +3 -3
- package/codemini-web/dist/assets/MessageBubble-CGnnViv0.js +12 -0
- package/codemini-web/dist/assets/PatchDiff-B8rwvEg5.js +230 -0
- package/codemini-web/dist/assets/ProjectSelector-BF59M1zb.js +1 -0
- package/codemini-web/dist/assets/{SkillDialog-DxS43NpR.js → SkillDialog-CQTjbSiw.js} +4 -4
- package/codemini-web/dist/assets/SoulDialog-BLjUGqqB.js +1 -0
- package/codemini-web/dist/assets/chevron-right--85xg7qk.js +1 -0
- package/codemini-web/dist/assets/{chunk-BO2N2NFS-Budy_hfO.js → chunk-BO2N2NFS-6uELoidu.js} +6 -6
- package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-CQS1PAvD.js → highlighted-body-OFNGDK62-gb1UMBZ5.js} +1 -1
- package/codemini-web/dist/assets/index-1xqD0R5t.css +2 -0
- package/codemini-web/dist/assets/index-CDXQGwPs.js +65 -0
- package/codemini-web/dist/assets/{input-CNQgbKe6.js → input-Ca8O_061.js} +1 -1
- package/codemini-web/dist/assets/lib-BXWizt13.js +1 -0
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-ROliF8Yd.js +1 -0
- package/codemini-web/dist/assets/{pencil-Ce_LFiEh.js → pencil-BhT11Ztp.js} +1 -1
- package/codemini-web/dist/assets/{refresh-cw-BKL-AZu5.js → refresh-cw-D7R5Lth6.js} +1 -1
- package/codemini-web/dist/assets/select-DBvcHBzs.js +1 -0
- package/codemini-web/dist/assets/{trash-2-KmAlCwXd.js → trash-2-BfNZcWfX.js} +1 -1
- package/codemini-web/dist/index.html +2 -2
- package/codemini-web/lib/runtime-bridge.js +325 -296
- package/codemini-web/server.js +310 -243
- package/package.json +1 -1
- package/src/core/agent-loop.js +188 -97
- package/src/core/chat-runtime.js +674 -571
- package/src/core/config-store.js +11 -3
- package/src/core/git-oplog-change-tracker.js +387 -0
- package/src/core/non-git-backup.js +116 -0
- package/src/core/paths.js +123 -123
- package/src/core/session-store.js +148 -99
- package/src/core/tools.js +499 -456
- package/src/tui/chat-app.js +196 -56
- package/codemini-web/dist/assets/CodeWikiPanel-EPuoerNv.js +0 -1
- package/codemini-web/dist/assets/ConfigDialog-B5IGZCc9.js +0 -1
- package/codemini-web/dist/assets/GitDiffDialog-Bb_Tw5ZK.js +0 -222
- package/codemini-web/dist/assets/MessageBubble-wUff4GP4.js +0 -6
- package/codemini-web/dist/assets/ProjectSelector-C0leTf6f.js +0 -1
- package/codemini-web/dist/assets/SoulDialog-XDTEGWvH.js +0 -1
- package/codemini-web/dist/assets/chevron-right-Dbzw7YzA.js +0 -1
- package/codemini-web/dist/assets/index-D0EGtNPr.js +0 -65
- package/codemini-web/dist/assets/index-wOUf3WkN.css +0 -2
- package/codemini-web/dist/assets/lib-BOngVP_M.js +0 -11
- package/codemini-web/dist/assets/lib-DrOTTm_N.js +0 -1
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-DrBu5KyC.js +0 -1
- package/codemini-web/dist/assets/select-BZXfigic.js +0 -1
package/src/core/config-store.js
CHANGED
|
@@ -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,16 @@ 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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
const rawExecutionMode = String(next.execution.mode || '').toLowerCase();
|
|
153
|
+
const rawApprovalMode = String(next.execution.approval_mode || next.execution.approvalMode || '').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
|
+
: rawExecutionMode === 'auto'
|
|
160
|
+
? 'auto'
|
|
161
|
+
: 'review';
|
|
154
162
|
const rawTools = Array.isArray(next.execution.always_allow_tools)
|
|
155
163
|
? next.execution.always_allow_tools
|
|
156
164
|
: [];
|
|
@@ -0,0 +1,387 @@
|
|
|
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
|
+
return 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
|
+
} finally {
|
|
200
|
+
await fs.rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function writeJson(filePath, value) {
|
|
205
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
206
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function readJson(filePath) {
|
|
210
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function createGitOplogChangeTracker({ workspaceRoot = process.cwd(), sessionId } = {}) {
|
|
214
|
+
const startRoot = path.resolve(workspaceRoot);
|
|
215
|
+
const id = String(sessionId || '').trim();
|
|
216
|
+
if (!id) return { enabled: false, reason: 'missing-session' };
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const inside = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd: startRoot, allowFailure: true });
|
|
220
|
+
if (inside.code !== 0 || inside.stdout.trim() !== 'true') {
|
|
221
|
+
return { enabled: false, reason: 'not-git-repo', workspaceRoot: startRoot };
|
|
222
|
+
}
|
|
223
|
+
const root = path.resolve((await runGit(['rev-parse', '--show-toplevel'], { cwd: startRoot })).stdout.trim());
|
|
224
|
+
const head = await runGit(['rev-parse', '--verify', 'HEAD'], { cwd: root, allowFailure: true });
|
|
225
|
+
const hasHead = head.code === 0;
|
|
226
|
+
const gitPath = (await runGit(['rev-parse', '--git-path', `codemini/sessions/${id}`], { cwd: root })).stdout.trim();
|
|
227
|
+
const oplogDir = path.resolve(root, gitPath);
|
|
228
|
+
const patchesDir = path.join(oplogDir, 'patches');
|
|
229
|
+
const opsDir = path.join(oplogDir, 'ops');
|
|
230
|
+
await fs.mkdir(patchesDir, { recursive: true });
|
|
231
|
+
await fs.mkdir(opsDir, { recursive: true });
|
|
232
|
+
if (hasHead) {
|
|
233
|
+
await runGit(['update-ref', `refs/codemini/sessions/${id}`, 'HEAD'], { cwd: root, allowFailure: true });
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
enabled: true,
|
|
237
|
+
mode: 'git-oplog',
|
|
238
|
+
workspaceRoot: root,
|
|
239
|
+
sessionId: id,
|
|
240
|
+
baseCommit: hasHead ? head.stdout.trim() : '',
|
|
241
|
+
oplogDir,
|
|
242
|
+
patchesDir,
|
|
243
|
+
opsDir
|
|
244
|
+
};
|
|
245
|
+
} catch (error) {
|
|
246
|
+
return { enabled: false, reason: error instanceof Error ? error.message : String(error), workspaceRoot: startRoot };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function isGitOplogChangeTrackerAvailable(tracker) {
|
|
251
|
+
return Boolean(tracker?.enabled && tracker.mode === 'git-oplog');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function beginGitOplogCapture(tracker, { toolName = '', args = {} } = {}) {
|
|
255
|
+
if (!isGitOplogChangeTrackerAvailable(tracker)) return null;
|
|
256
|
+
const explicitPaths = FILE_TOOLS.has(String(toolName || '')) ? extractPathCandidates(args, [], { root: tracker.workspaceRoot }) : [];
|
|
257
|
+
const status = await runGit(['status', '--porcelain=v1', '-z'], { cwd: tracker.workspaceRoot, allowFailure: true });
|
|
258
|
+
const dirtyPaths = parseStatusPaths(status.stdout);
|
|
259
|
+
const paths = explicitPaths.length ? explicitPaths : dirtyPaths;
|
|
260
|
+
const snapshots = new Map();
|
|
261
|
+
for (const filePath of paths) {
|
|
262
|
+
snapshots.set(filePath, await readWorktreeSnapshot(tracker.workspaceRoot, filePath));
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
toolName,
|
|
266
|
+
explicit: explicitPaths.length > 0,
|
|
267
|
+
beforeDirtyPaths: dirtyPaths,
|
|
268
|
+
paths,
|
|
269
|
+
snapshots
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function captureGitOplogChanges(tracker, capture, { toolName = '', toolCallId = '', summary = '', args = {}, declaredFileChanges = [] } = {}) {
|
|
274
|
+
if (!isGitOplogChangeTrackerAvailable(tracker) || !capture) return null;
|
|
275
|
+
const afterStatus = await runGit(['status', '--porcelain=v1', '-z'], { cwd: tracker.workspaceRoot, allowFailure: true });
|
|
276
|
+
const afterDirtyPaths = parseStatusPaths(afterStatus.stdout);
|
|
277
|
+
const declaredPaths = extractPathCandidates(args, declaredFileChanges, { root: tracker.workspaceRoot });
|
|
278
|
+
const candidates = new Set([
|
|
279
|
+
...capture.paths,
|
|
280
|
+
...declaredPaths,
|
|
281
|
+
...(capture.explicit ? [] : capture.beforeDirtyPaths),
|
|
282
|
+
...(capture.explicit ? [] : afterDirtyPaths)
|
|
283
|
+
]);
|
|
284
|
+
|
|
285
|
+
const files = [];
|
|
286
|
+
const patches = [];
|
|
287
|
+
for (const filePath of candidates) {
|
|
288
|
+
const normalized = normalizeRelativePath(filePath, { root: tracker.workspaceRoot });
|
|
289
|
+
if (!normalized) continue;
|
|
290
|
+
let before = capture.snapshots.get(normalized);
|
|
291
|
+
if (!before) before = await readHeadSnapshot(tracker.workspaceRoot, normalized);
|
|
292
|
+
const after = await readWorktreeSnapshot(tracker.workspaceRoot, normalized);
|
|
293
|
+
const patch = await buildPatchForFile(tracker.workspaceRoot, normalized, before, after);
|
|
294
|
+
if (!patch.trim()) continue;
|
|
295
|
+
const stats = countPatchLines(patch);
|
|
296
|
+
files.push({
|
|
297
|
+
path: normalized,
|
|
298
|
+
action: actionFromSnapshots(before, after),
|
|
299
|
+
linesAdded: stats.added,
|
|
300
|
+
linesRemoved: stats.removed,
|
|
301
|
+
changedLine: firstChangedLineFromPatch(patch),
|
|
302
|
+
diffPreview: patch
|
|
303
|
+
});
|
|
304
|
+
patches.push(patch);
|
|
305
|
+
}
|
|
306
|
+
if (!files.length) return null;
|
|
307
|
+
|
|
308
|
+
const opId = changeId('op');
|
|
309
|
+
const patch = patches.join('\n');
|
|
310
|
+
const patchPath = path.join(tracker.patchesDir, `${opId}.patch`);
|
|
311
|
+
const opPath = path.join(tracker.opsDir, `${opId}.json`);
|
|
312
|
+
await fs.writeFile(patchPath, patch, 'utf8');
|
|
313
|
+
const op = {
|
|
314
|
+
version: CHANGE_OPLOG_VERSION,
|
|
315
|
+
id: opId,
|
|
316
|
+
sessionId: tracker.sessionId,
|
|
317
|
+
createdAt: new Date().toISOString(),
|
|
318
|
+
toolName,
|
|
319
|
+
toolCallId,
|
|
320
|
+
summary,
|
|
321
|
+
patchPath,
|
|
322
|
+
files: files.map(({ diffPreview, ...file }) => file),
|
|
323
|
+
revertedAt: null
|
|
324
|
+
};
|
|
325
|
+
await writeJson(opPath, op);
|
|
326
|
+
await fs.appendFile(path.join(tracker.oplogDir, 'ops.jsonl'), `${JSON.stringify({ ...op, patchPath: undefined })}\n`, 'utf8');
|
|
327
|
+
return files.map((file) => ({
|
|
328
|
+
...file,
|
|
329
|
+
changeSetId: opId,
|
|
330
|
+
files: [{ path: file.path, action: file.action, linesAdded: file.linesAdded, linesRemoved: file.linesRemoved }]
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function listGitOplogChanges(tracker) {
|
|
335
|
+
if (!isGitOplogChangeTrackerAvailable(tracker)) return [];
|
|
336
|
+
let entries = [];
|
|
337
|
+
try {
|
|
338
|
+
entries = await fs.readdir(tracker.opsDir, { withFileTypes: true });
|
|
339
|
+
} catch {
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
const out = [];
|
|
343
|
+
for (const entry of entries) {
|
|
344
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
345
|
+
try { out.push(await readJson(path.join(tracker.opsDir, entry.name))); } catch {}
|
|
346
|
+
}
|
|
347
|
+
out.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function readGitOplogChange(tracker, opId) {
|
|
352
|
+
if (!isGitOplogChangeTrackerAvailable(tracker)) throw new Error('Git change oplog is not available for this session');
|
|
353
|
+
const id = String(opId || '').trim();
|
|
354
|
+
if (!id) throw new Error('Missing change id');
|
|
355
|
+
return readJson(path.join(tracker.opsDir, `${id}.json`));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function readGitOplogPatch(tracker, opId) {
|
|
359
|
+
const op = await readGitOplogChange(tracker, opId);
|
|
360
|
+
return fs.readFile(op.patchPath, 'utf8');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function undoGitOplogChange(tracker, opId) {
|
|
364
|
+
if (!isGitOplogChangeTrackerAvailable(tracker)) throw new Error('Git change oplog is not available for this session');
|
|
365
|
+
const op = await readGitOplogChange(tracker, opId);
|
|
366
|
+
if (op.revertedAt) {
|
|
367
|
+
return { ok: false, alreadyReverted: true, changeSetId: op.id, message: 'Change already reverted' };
|
|
368
|
+
}
|
|
369
|
+
const patch = await fs.readFile(op.patchPath, 'utf8');
|
|
370
|
+
try {
|
|
371
|
+
await runGit(['apply', '-R', '--check', '--whitespace=nowarn'], {
|
|
372
|
+
cwd: tracker.workspaceRoot,
|
|
373
|
+
input: patch,
|
|
374
|
+
timeoutMs: 120_000
|
|
375
|
+
});
|
|
376
|
+
} catch (error) {
|
|
377
|
+
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());
|
|
378
|
+
}
|
|
379
|
+
await runGit(['apply', '-R', '--whitespace=nowarn'], {
|
|
380
|
+
cwd: tracker.workspaceRoot,
|
|
381
|
+
input: patch,
|
|
382
|
+
timeoutMs: 120_000
|
|
383
|
+
});
|
|
384
|
+
op.revertedAt = new Date().toISOString();
|
|
385
|
+
await writeJson(path.join(tracker.opsDir, `${op.id}.json`), op);
|
|
386
|
+
return { ok: true, changeSetId: op.id };
|
|
387
|
+
}
|
|
@@ -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
|
+
}
|