xab 1.0.0 → 2.0.0
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 +209 -0
- package/dist/index.js +2638 -0
- package/package.json +30 -8
- package/.idea/misc.xml +0 -6
- package/.idea/modules.xml +0 -8
- package/.idea/xb.iml +0 -12
- package/xb.js +0 -39
- package/xbcli.js +0 -39
package/dist/index.js
ADDED
|
@@ -0,0 +1,2638 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
|
+
|
|
19
|
+
// src/config.ts
|
|
20
|
+
import { existsSync, readFileSync } from "fs";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
function loadConfig(repoPath, explicitPath) {
|
|
23
|
+
const paths = explicitPath ? [explicitPath] : CONFIG_FILENAMES.map((f) => join(repoPath, f));
|
|
24
|
+
for (const p of paths) {
|
|
25
|
+
if (existsSync(p)) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = readFileSync(p, "utf-8");
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
return { ...DEFAULT_CONFIG, ...parsed };
|
|
30
|
+
} catch (e) {
|
|
31
|
+
throw new Error(`Failed to parse config at ${p}: ${e.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { ...DEFAULT_CONFIG };
|
|
36
|
+
}
|
|
37
|
+
var CONFIG_FILENAMES, DEFAULT_CONFIG;
|
|
38
|
+
var init_config = __esm(() => {
|
|
39
|
+
CONFIG_FILENAMES = [".backmerge.json", ".backmerge/config.json", "backmerge.config.json"];
|
|
40
|
+
DEFAULT_CONFIG = {
|
|
41
|
+
instructionFiles: [],
|
|
42
|
+
docRoutes: [],
|
|
43
|
+
promptHints: [],
|
|
44
|
+
pathRemaps: [],
|
|
45
|
+
reviewStrictness: "normal",
|
|
46
|
+
maxAttempts: 2,
|
|
47
|
+
commitPrefix: "backmerge:"
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// src/context.ts
|
|
52
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync } from "fs";
|
|
53
|
+
import { join as join2, relative } from "path";
|
|
54
|
+
function minimatch(path, pattern) {
|
|
55
|
+
let re = pattern;
|
|
56
|
+
re = re.replace(/\*\*\//g, "\x00GS\x00");
|
|
57
|
+
re = re.replace(/\*\*/g, "\x00GA\x00");
|
|
58
|
+
re = re.replace(/\*/g, "\x00S\x00");
|
|
59
|
+
re = re.replace(/\?/g, "\x00Q\x00");
|
|
60
|
+
re = re.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
61
|
+
re = re.replace(/\0GS\0/g, "(?:.*/)?");
|
|
62
|
+
re = re.replace(/\0GA\0/g, ".*");
|
|
63
|
+
re = re.replace(/\0S\0/g, "[^/]*");
|
|
64
|
+
re = re.replace(/\0Q\0/g, "[^/]");
|
|
65
|
+
return new RegExp(`^${re}$`).test(path);
|
|
66
|
+
}
|
|
67
|
+
function inferRepoStructure(repoPath) {
|
|
68
|
+
const entries = readdirSync(repoPath, { withFileTypes: true });
|
|
69
|
+
const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
|
|
70
|
+
const files = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
71
|
+
const configFiles = files.filter((f) => [
|
|
72
|
+
"package.json",
|
|
73
|
+
"tsconfig.json",
|
|
74
|
+
"Cargo.toml",
|
|
75
|
+
"go.mod",
|
|
76
|
+
"pyproject.toml",
|
|
77
|
+
"pnpm-workspace.yaml",
|
|
78
|
+
"turbo.json",
|
|
79
|
+
"nx.json",
|
|
80
|
+
"lerna.json",
|
|
81
|
+
"bun.lockb",
|
|
82
|
+
"yarn.lock",
|
|
83
|
+
"pnpm-lock.yaml",
|
|
84
|
+
"Makefile",
|
|
85
|
+
"foundry.toml",
|
|
86
|
+
"hardhat.config.ts",
|
|
87
|
+
"hardhat.config.js"
|
|
88
|
+
].includes(f));
|
|
89
|
+
const monoSignals = ["apps", "packages", "libs", "modules", "services", "crates", "workspace"];
|
|
90
|
+
const hasMonoDir = dirs.some((d) => monoSignals.includes(d));
|
|
91
|
+
const hasWorkspaceConfig = ["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json"].some((f) => files.includes(f));
|
|
92
|
+
let hasWorkspacesField = false;
|
|
93
|
+
let packageManager;
|
|
94
|
+
const pkgPath = join2(repoPath, "package.json");
|
|
95
|
+
if (existsSync2(pkgPath)) {
|
|
96
|
+
try {
|
|
97
|
+
const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
98
|
+
hasWorkspacesField = Array.isArray(pkg.workspaces) || !!pkg.workspaces?.packages;
|
|
99
|
+
if (pkg.packageManager)
|
|
100
|
+
packageManager = pkg.packageManager;
|
|
101
|
+
else if (existsSync2(join2(repoPath, "bun.lockb")))
|
|
102
|
+
packageManager = "bun";
|
|
103
|
+
else if (existsSync2(join2(repoPath, "pnpm-lock.yaml")))
|
|
104
|
+
packageManager = "pnpm";
|
|
105
|
+
else if (existsSync2(join2(repoPath, "yarn.lock")))
|
|
106
|
+
packageManager = "yarn";
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
const componentDirs = dirs.filter((d) => {
|
|
110
|
+
const sub = join2(repoPath, d);
|
|
111
|
+
return existsSync2(join2(sub, "package.json")) || existsSync2(join2(sub, "go.mod")) || existsSync2(join2(sub, "Cargo.toml"));
|
|
112
|
+
});
|
|
113
|
+
const isComponentMonorepo = componentDirs.length >= 2;
|
|
114
|
+
const isMonorepo = hasMonoDir || hasWorkspaceConfig || hasWorkspacesField || isComponentMonorepo;
|
|
115
|
+
let apps;
|
|
116
|
+
let packages;
|
|
117
|
+
if (isMonorepo) {
|
|
118
|
+
for (const subdir of ["apps", "services"]) {
|
|
119
|
+
const p = join2(repoPath, subdir);
|
|
120
|
+
if (existsSync2(p)) {
|
|
121
|
+
apps = readdirSync(p, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => `${subdir}/${e.name}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const subdir of ["packages", "libs", "modules"]) {
|
|
125
|
+
const p = join2(repoPath, subdir);
|
|
126
|
+
if (existsSync2(p)) {
|
|
127
|
+
packages = readdirSync(p, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => `${subdir}/${e.name}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!apps && componentDirs.length >= 2) {
|
|
131
|
+
apps = componentDirs;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
type: isMonorepo ? "monorepo" : "single-project",
|
|
136
|
+
topLevelDirs: dirs,
|
|
137
|
+
apps,
|
|
138
|
+
packages,
|
|
139
|
+
configFiles,
|
|
140
|
+
packageManager
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function discoverInstructionFiles(repoPath, config) {
|
|
144
|
+
const found = new Map;
|
|
145
|
+
for (const candidate of INSTRUCTION_FILE_CANDIDATES) {
|
|
146
|
+
const full = join2(repoPath, candidate);
|
|
147
|
+
if (existsSync2(full)) {
|
|
148
|
+
try {
|
|
149
|
+
const content = readFileSync2(full, "utf-8");
|
|
150
|
+
found.set(candidate, content.slice(0, 8000));
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for (const f of config.instructionFiles ?? []) {
|
|
155
|
+
if (found.has(f))
|
|
156
|
+
continue;
|
|
157
|
+
const full = join2(repoPath, f);
|
|
158
|
+
if (existsSync2(full)) {
|
|
159
|
+
try {
|
|
160
|
+
found.set(f, readFileSync2(full, "utf-8").slice(0, 8000));
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return found;
|
|
165
|
+
}
|
|
166
|
+
function discoverDocPaths(repoPath) {
|
|
167
|
+
const paths = [];
|
|
168
|
+
function walk(dir, globs) {
|
|
169
|
+
if (!existsSync2(dir))
|
|
170
|
+
return;
|
|
171
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
const full = join2(dir, entry.name);
|
|
174
|
+
const rel = relative(repoPath, full);
|
|
175
|
+
if (entry.isDirectory()) {
|
|
176
|
+
walk(full, globs);
|
|
177
|
+
} else if (entry.isFile() && globs.some((g) => minimatch(rel, g))) {
|
|
178
|
+
paths.push(rel);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const glob of DOC_DIR_GLOBS) {
|
|
183
|
+
const baseDir = glob.split("/")[0];
|
|
184
|
+
walk(join2(repoPath, baseDir), [glob]);
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
for (const entry of readdirSync(repoPath, { withFileTypes: true })) {
|
|
188
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
189
|
+
const readme = join2(repoPath, entry.name, "README.md");
|
|
190
|
+
if (existsSync2(readme)) {
|
|
191
|
+
paths.push(`${entry.name}/README.md`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch {}
|
|
196
|
+
return [...new Set(paths)].sort();
|
|
197
|
+
}
|
|
198
|
+
function buildRepoContext(repoPath, config) {
|
|
199
|
+
const structure = inferRepoStructure(repoPath);
|
|
200
|
+
const instructions = discoverInstructionFiles(repoPath, config);
|
|
201
|
+
const docPaths = discoverDocPaths(repoPath);
|
|
202
|
+
return { structure, instructions, docPaths };
|
|
203
|
+
}
|
|
204
|
+
function buildCommitContext(repoPath, repoCtx, config, touchedPaths, commitMessage) {
|
|
205
|
+
const includedFiles = [];
|
|
206
|
+
const sections = [];
|
|
207
|
+
const rs = repoCtx.structure;
|
|
208
|
+
const structLines = [`Repository: ${rs.type}`];
|
|
209
|
+
if (rs.packageManager)
|
|
210
|
+
structLines.push(`Package manager: ${rs.packageManager}`);
|
|
211
|
+
if (rs.apps?.length)
|
|
212
|
+
structLines.push(`Components: ${rs.apps.join(", ")}`);
|
|
213
|
+
if (rs.packages?.length)
|
|
214
|
+
structLines.push(`Packages: ${rs.packages.join(", ")}`);
|
|
215
|
+
sections.push(structLines.join(`
|
|
216
|
+
`));
|
|
217
|
+
for (const [name, content] of repoCtx.instructions) {
|
|
218
|
+
sections.push(`--- ${name} ---
|
|
219
|
+
${content}`);
|
|
220
|
+
includedFiles.push(name);
|
|
221
|
+
}
|
|
222
|
+
if (config.promptHints?.length) {
|
|
223
|
+
sections.push(`--- Merge hints ---
|
|
224
|
+
${config.promptHints.join(`
|
|
225
|
+
`)}`);
|
|
226
|
+
}
|
|
227
|
+
if (config.pathRemaps?.length) {
|
|
228
|
+
const lines = config.pathRemaps.map((r) => ` ${r.source} \u2192 ${r.target}${r.note ? ` (${r.note})` : ""}`);
|
|
229
|
+
sections.push(`--- Path remaps ---
|
|
230
|
+
${lines.join(`
|
|
231
|
+
`)}`);
|
|
232
|
+
}
|
|
233
|
+
const matchedDocs = new Set;
|
|
234
|
+
for (const route of config.docRoutes ?? []) {
|
|
235
|
+
if (routeMatches(route, touchedPaths, commitMessage)) {
|
|
236
|
+
for (const docGlob of route.docs) {
|
|
237
|
+
for (const docPath of repoCtx.docPaths) {
|
|
238
|
+
if (minimatch(docPath, docGlob)) {
|
|
239
|
+
matchedDocs.add(docPath);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const touchedTopDirs = new Set(touchedPaths.map((p) => p.split("/")[0]).filter(Boolean));
|
|
246
|
+
for (const docPath of repoCtx.docPaths) {
|
|
247
|
+
const docName = docPath.toLowerCase();
|
|
248
|
+
for (const dir of touchedTopDirs) {
|
|
249
|
+
if (docName.includes(dir.toLowerCase())) {
|
|
250
|
+
matchedDocs.add(docPath);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const msgLower = commitMessage.toLowerCase();
|
|
254
|
+
const docBasename = docPath.split("/").pop()?.replace(/\.md$/, "").toLowerCase() ?? "";
|
|
255
|
+
if (docBasename && msgLower.includes(docBasename)) {
|
|
256
|
+
matchedDocs.add(docPath);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
let docBudget = 4000;
|
|
260
|
+
for (const docPath of matchedDocs) {
|
|
261
|
+
if (docBudget <= 0)
|
|
262
|
+
break;
|
|
263
|
+
const full = join2(repoPath, docPath);
|
|
264
|
+
if (existsSync2(full)) {
|
|
265
|
+
try {
|
|
266
|
+
const content = readFileSync2(full, "utf-8");
|
|
267
|
+
const chunk = content.slice(0, Math.min(2000, docBudget));
|
|
268
|
+
sections.push(`--- ${docPath} ---
|
|
269
|
+
${chunk}`);
|
|
270
|
+
includedFiles.push(docPath);
|
|
271
|
+
docBudget -= chunk.length;
|
|
272
|
+
} catch {}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
promptBlock: sections.join(`
|
|
277
|
+
|
|
278
|
+
`),
|
|
279
|
+
includedFiles
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function routeMatches(route, touchedPaths, commitMessage) {
|
|
283
|
+
for (const glob of route.pathGlobs) {
|
|
284
|
+
for (const path of touchedPaths) {
|
|
285
|
+
if (minimatch(path, glob))
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (route.keywords) {
|
|
290
|
+
const msgLower = commitMessage.toLowerCase();
|
|
291
|
+
for (const kw of route.keywords) {
|
|
292
|
+
if (msgLower.includes(kw.toLowerCase()))
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
var INSTRUCTION_FILE_CANDIDATES, DOC_DIR_GLOBS;
|
|
299
|
+
var init_context = __esm(() => {
|
|
300
|
+
INSTRUCTION_FILE_CANDIDATES = [
|
|
301
|
+
"AGENTS.md",
|
|
302
|
+
"CLAUDE.md",
|
|
303
|
+
"CONTRIBUTING.md",
|
|
304
|
+
".cursorrules",
|
|
305
|
+
".github/copilot-instructions.md"
|
|
306
|
+
];
|
|
307
|
+
DOC_DIR_GLOBS = ["ai-docs/**/*.md", "docs/**/*.md", ".backmerge/docs/**/*.md"];
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// src/decisions.ts
|
|
311
|
+
function validateDecision(d, headBefore, headAfter, isDryRun) {
|
|
312
|
+
const errors = [];
|
|
313
|
+
const headMoved = headBefore !== headAfter;
|
|
314
|
+
if (isDryRun) {
|
|
315
|
+
if (d.kind === "applied") {
|
|
316
|
+
errors.push("Dry-run produced 'applied' decision \u2014 must use 'would_apply'");
|
|
317
|
+
}
|
|
318
|
+
if (headMoved) {
|
|
319
|
+
errors.push(`Dry-run moved HEAD from ${headBefore.slice(0, 8)} to ${headAfter.slice(0, 8)}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
switch (d.kind) {
|
|
323
|
+
case "applied":
|
|
324
|
+
if (!headMoved)
|
|
325
|
+
errors.push("Decision is 'applied' but HEAD did not move");
|
|
326
|
+
if (!d.newCommitHash)
|
|
327
|
+
errors.push("Decision is 'applied' but no newCommitHash");
|
|
328
|
+
break;
|
|
329
|
+
case "would_apply":
|
|
330
|
+
if (headMoved)
|
|
331
|
+
errors.push("Decision is 'would_apply' but HEAD moved");
|
|
332
|
+
if (!isDryRun)
|
|
333
|
+
errors.push("'would_apply' only valid in dry-run mode");
|
|
334
|
+
break;
|
|
335
|
+
case "already_applied":
|
|
336
|
+
case "skip":
|
|
337
|
+
if (headMoved)
|
|
338
|
+
errors.push(`Decision is '${d.kind}' but HEAD moved`);
|
|
339
|
+
break;
|
|
340
|
+
case "failed":
|
|
341
|
+
if (headMoved)
|
|
342
|
+
errors.push("Decision is 'failed' but HEAD moved (should have been reset)");
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
return errors;
|
|
346
|
+
}
|
|
347
|
+
function emptyRunSummary() {
|
|
348
|
+
return { applied: 0, wouldApply: 0, alreadyApplied: 0, skipped: 0, failed: 0, total: 0, cherrySkipped: 0 };
|
|
349
|
+
}
|
|
350
|
+
function updateSummary(s, d) {
|
|
351
|
+
switch (d.kind) {
|
|
352
|
+
case "applied":
|
|
353
|
+
s.applied++;
|
|
354
|
+
break;
|
|
355
|
+
case "would_apply":
|
|
356
|
+
s.wouldApply++;
|
|
357
|
+
break;
|
|
358
|
+
case "already_applied":
|
|
359
|
+
s.alreadyApplied++;
|
|
360
|
+
break;
|
|
361
|
+
case "skip":
|
|
362
|
+
s.skipped++;
|
|
363
|
+
break;
|
|
364
|
+
case "failed":
|
|
365
|
+
s.failed++;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// src/codex.ts
|
|
371
|
+
import { Codex } from "@openai/codex-sdk";
|
|
372
|
+
import { execSync } from "child_process";
|
|
373
|
+
function checkCodexInstalled() {
|
|
374
|
+
try {
|
|
375
|
+
execSync("codex --version", { stdio: "pipe" });
|
|
376
|
+
return true;
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function splitDiff(diff) {
|
|
382
|
+
if (diff.length <= CHUNK_SIZE)
|
|
383
|
+
return [diff];
|
|
384
|
+
const chunks = [];
|
|
385
|
+
const statEnd = diff.indexOf(`
|
|
386
|
+
|
|
387
|
+
`);
|
|
388
|
+
const firstEnd = statEnd > 0 ? Math.max(statEnd, CHUNK_SIZE) : CHUNK_SIZE;
|
|
389
|
+
chunks.push(diff.slice(0, Math.min(firstEnd, diff.length)));
|
|
390
|
+
let pos = chunks[0].length;
|
|
391
|
+
while (pos < diff.length) {
|
|
392
|
+
let end = pos + CHUNK_SIZE;
|
|
393
|
+
if (end < diff.length) {
|
|
394
|
+
const boundary = diff.lastIndexOf(`
|
|
395
|
+
diff --git `, end);
|
|
396
|
+
if (boundary > pos)
|
|
397
|
+
end = boundary;
|
|
398
|
+
}
|
|
399
|
+
chunks.push(diff.slice(pos, Math.min(end, diff.length)));
|
|
400
|
+
pos = end;
|
|
401
|
+
}
|
|
402
|
+
return chunks;
|
|
403
|
+
}
|
|
404
|
+
function truncateDiff(diff, maxLen) {
|
|
405
|
+
if (diff.length <= maxLen)
|
|
406
|
+
return diff;
|
|
407
|
+
const statEnd = diff.indexOf(`
|
|
408
|
+
|
|
409
|
+
`);
|
|
410
|
+
if (statEnd === -1 || statEnd > maxLen)
|
|
411
|
+
return diff.slice(0, maxLen) + `
|
|
412
|
+
|
|
413
|
+
... [truncated]`;
|
|
414
|
+
const stat = diff.slice(0, statEnd);
|
|
415
|
+
const remaining = maxLen - stat.length - 50;
|
|
416
|
+
if (remaining <= 0)
|
|
417
|
+
return stat + `
|
|
418
|
+
|
|
419
|
+
... [patch truncated]`;
|
|
420
|
+
return stat + diff.slice(statEnd, statEnd + remaining) + `
|
|
421
|
+
|
|
422
|
+
... [patch truncated]`;
|
|
423
|
+
}
|
|
424
|
+
function parseJson(raw, fallback) {
|
|
425
|
+
try {
|
|
426
|
+
return JSON.parse(raw);
|
|
427
|
+
} catch {}
|
|
428
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
429
|
+
if (m) {
|
|
430
|
+
try {
|
|
431
|
+
return JSON.parse(m[0]);
|
|
432
|
+
} catch {}
|
|
433
|
+
}
|
|
434
|
+
return fallback;
|
|
435
|
+
}
|
|
436
|
+
async function analyzeCommit(opts) {
|
|
437
|
+
const codex = new Codex;
|
|
438
|
+
const thread = codex.startThread({
|
|
439
|
+
workingDirectory: opts.worktreePath,
|
|
440
|
+
sandboxMode: "read-only",
|
|
441
|
+
model: "gpt-5.4",
|
|
442
|
+
modelReasoningEffort: "high"
|
|
443
|
+
});
|
|
444
|
+
const diffChunks = splitDiff(opts.commitDiff);
|
|
445
|
+
const firstPrompt = `${MERGE_PREAMBLE}
|
|
446
|
+
${opts.repoContext ? `## Repository context
|
|
447
|
+
${opts.repoContext}
|
|
448
|
+
` : ""}
|
|
449
|
+
## Source commit (from "${opts.sourceBranch}")
|
|
450
|
+
Hash: ${opts.commitHash}
|
|
451
|
+
Message: ${opts.commitMessage}
|
|
452
|
+
|
|
453
|
+
### Diff${diffChunks.length > 1 ? ` (part 1/${diffChunks.length})` : ""}:
|
|
454
|
+
\`\`\`diff
|
|
455
|
+
${diffChunks[0]}
|
|
456
|
+
\`\`\`
|
|
457
|
+
|
|
458
|
+
## Latest state of source branch (for context):
|
|
459
|
+
\`\`\`diff
|
|
460
|
+
${truncateDiff(opts.sourceLatestDiff, 1e4)}
|
|
461
|
+
\`\`\`
|
|
462
|
+
|
|
463
|
+
${diffChunks.length > 1 ? "I will send the remaining diff parts next. Read them all before analyzing." : ""}
|
|
464
|
+
|
|
465
|
+
## Your task
|
|
466
|
+
You are looking at a worktree based on the TARGET branch "${opts.targetBranch}".
|
|
467
|
+
|
|
468
|
+
1. Read the target worktree files affected by this commit
|
|
469
|
+
2. Summarize what the source commit does
|
|
470
|
+
3. Determine if the target already has this functionality:
|
|
471
|
+
- "yes" = exact same functionality exists (check every affected file)
|
|
472
|
+
- "partial" = some parts exist
|
|
473
|
+
- "no" = missing
|
|
474
|
+
4. If not fully present, describe a step-by-step strategy for applying cleanly
|
|
475
|
+
5. List which top-level components/directories are affected
|
|
476
|
+
6. Consider: does this change make sense for the target? Is it useful?`;
|
|
477
|
+
let turn;
|
|
478
|
+
if (diffChunks.length > 1) {
|
|
479
|
+
await thread.run(firstPrompt);
|
|
480
|
+
for (let i = 1;i < diffChunks.length - 1; i++) {
|
|
481
|
+
await thread.run(`### Diff (part ${i + 1}/${diffChunks.length}):
|
|
482
|
+
\`\`\`diff
|
|
483
|
+
${diffChunks[i]}
|
|
484
|
+
\`\`\`
|
|
485
|
+
|
|
486
|
+
Continue reading. More parts coming.`);
|
|
487
|
+
}
|
|
488
|
+
const lastIdx = diffChunks.length - 1;
|
|
489
|
+
turn = await thread.run(`### Diff (part ${lastIdx + 1}/${diffChunks.length} \u2014 final):
|
|
490
|
+
\`\`\`diff
|
|
491
|
+
${diffChunks[lastIdx]}
|
|
492
|
+
\`\`\`
|
|
493
|
+
|
|
494
|
+
You now have the complete diff. Analyze and produce your structured response.`, { outputSchema: analysisSchema });
|
|
495
|
+
} else {
|
|
496
|
+
turn = await thread.run(firstPrompt, { outputSchema: analysisSchema });
|
|
497
|
+
}
|
|
498
|
+
return parseJson(turn.finalResponse, {
|
|
499
|
+
summary: turn.finalResponse.slice(0, 500),
|
|
500
|
+
alreadyInTarget: "no",
|
|
501
|
+
reasoning: "Could not parse structured output",
|
|
502
|
+
applicationStrategy: "Manual review recommended",
|
|
503
|
+
affectedComponents: []
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
async function applyCommit(opts) {
|
|
507
|
+
const codex = new Codex;
|
|
508
|
+
const thread = codex.startThread({
|
|
509
|
+
workingDirectory: opts.worktreePath,
|
|
510
|
+
sandboxMode: "workspace-write",
|
|
511
|
+
model: "gpt-5.4",
|
|
512
|
+
modelReasoningEffort: "high"
|
|
513
|
+
});
|
|
514
|
+
const commitMsg = `${opts.commitPrefix} ${opts.commitMessage} (from ${opts.commitHash.slice(0, 8)})`;
|
|
515
|
+
const diffChunks = splitDiff(opts.commitDiff);
|
|
516
|
+
const instructions = `## Application strategy:
|
|
517
|
+
${opts.applicationStrategy}
|
|
518
|
+
|
|
519
|
+
## Instructions \u2014 clean curated merge
|
|
520
|
+
- Read the affected files in the worktree FIRST to understand the target's current state
|
|
521
|
+
- Apply changes so the result is clean, compiling, and conflict-free
|
|
522
|
+
- Adapt to the target's code style, imports, and architecture
|
|
523
|
+
- If the target already has a different version of the same logic, merge both intents
|
|
524
|
+
- Preserve the target's existing improvements \u2014 do not regress
|
|
525
|
+
- Create or modify files as needed; delete files if the source commit deleted them
|
|
526
|
+
- After applying, run exactly: git add -A && git commit -m "${commitMsg.replace(/"/g, "\\\"")}"
|
|
527
|
+
- You MUST create exactly ONE commit. Not zero, not two.
|
|
528
|
+
- No conflict markers, dead code, or TODO placeholders
|
|
529
|
+
- If impossible to apply cleanly, explain why in notes
|
|
530
|
+
|
|
531
|
+
Report what you did.`;
|
|
532
|
+
const firstPrompt = `${MERGE_PREAMBLE}
|
|
533
|
+
${opts.repoContext ? `## Repository context
|
|
534
|
+
${opts.repoContext}
|
|
535
|
+
` : ""}
|
|
536
|
+
## Source commit (from "${opts.sourceBranch}")
|
|
537
|
+
Hash: ${opts.commitHash}
|
|
538
|
+
Message: ${opts.commitMessage}
|
|
539
|
+
|
|
540
|
+
### Diff${diffChunks.length > 1 ? ` (part 1/${diffChunks.length})` : ""}:
|
|
541
|
+
\`\`\`diff
|
|
542
|
+
${diffChunks[0]}
|
|
543
|
+
\`\`\`
|
|
544
|
+
|
|
545
|
+
${diffChunks.length > 1 ? "I will send the remaining diff parts next. Read them all before applying." : instructions}`;
|
|
546
|
+
let turn;
|
|
547
|
+
if (diffChunks.length > 1) {
|
|
548
|
+
await thread.run(firstPrompt);
|
|
549
|
+
for (let i = 1;i < diffChunks.length - 1; i++) {
|
|
550
|
+
await thread.run(`### Diff (part ${i + 1}/${diffChunks.length}):
|
|
551
|
+
\`\`\`diff
|
|
552
|
+
${diffChunks[i]}
|
|
553
|
+
\`\`\`
|
|
554
|
+
|
|
555
|
+
Continue reading. More parts coming.`);
|
|
556
|
+
}
|
|
557
|
+
const lastIdx = diffChunks.length - 1;
|
|
558
|
+
turn = await thread.run(`### Diff (part ${lastIdx + 1}/${diffChunks.length} \u2014 final):
|
|
559
|
+
\`\`\`diff
|
|
560
|
+
${diffChunks[lastIdx]}
|
|
561
|
+
\`\`\`
|
|
562
|
+
|
|
563
|
+
You now have the complete diff.
|
|
564
|
+
|
|
565
|
+
${instructions}`, { outputSchema: applyResultSchema });
|
|
566
|
+
} else {
|
|
567
|
+
turn = await thread.run(firstPrompt, { outputSchema: applyResultSchema });
|
|
568
|
+
}
|
|
569
|
+
return parseJson(turn.finalResponse, {
|
|
570
|
+
applied: false,
|
|
571
|
+
filesChanged: [],
|
|
572
|
+
commitMessage: commitMsg,
|
|
573
|
+
notes: turn.finalResponse.slice(0, 1000),
|
|
574
|
+
adaptations: ""
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
async function fixFromReview(opts) {
|
|
578
|
+
const codex = new Codex;
|
|
579
|
+
const thread = codex.startThread({
|
|
580
|
+
workingDirectory: opts.worktreePath,
|
|
581
|
+
sandboxMode: "workspace-write",
|
|
582
|
+
model: "gpt-5.4",
|
|
583
|
+
modelReasoningEffort: "high"
|
|
584
|
+
});
|
|
585
|
+
const commitMsg = `${opts.commitPrefix} ${opts.commitMessage} (from ${opts.commitHash.slice(0, 8)})`;
|
|
586
|
+
const prompt = `A code reviewer found issues with your previous apply of commit ${opts.commitHash.slice(0, 8)} ("${opts.commitMessage}").
|
|
587
|
+
|
|
588
|
+
${opts.repoContext ? `## Repository context
|
|
589
|
+
${opts.repoContext}
|
|
590
|
+
` : ""}
|
|
591
|
+
|
|
592
|
+
## Review objections \u2014 you MUST fix ALL of these:
|
|
593
|
+
${opts.reviewIssues.map((issue, i) => `${i + 1}. ${issue}`).join(`
|
|
594
|
+
`)}
|
|
595
|
+
|
|
596
|
+
## Instructions
|
|
597
|
+
- Read the affected files to understand the current state
|
|
598
|
+
- Fix every issue the reviewer raised
|
|
599
|
+
- Do NOT introduce new problems while fixing
|
|
600
|
+
- After fixing, amend the commit: git add -A && git commit --amend -m "${commitMsg.replace(/"/g, "\\\"")}"
|
|
601
|
+
- The result must be a single clean commit with no issues
|
|
602
|
+
|
|
603
|
+
Report what you fixed.`;
|
|
604
|
+
const turn = await thread.run(prompt, { outputSchema: applyResultSchema });
|
|
605
|
+
return parseJson(turn.finalResponse, {
|
|
606
|
+
applied: false,
|
|
607
|
+
filesChanged: [],
|
|
608
|
+
commitMessage: commitMsg,
|
|
609
|
+
notes: turn.finalResponse.slice(0, 1000),
|
|
610
|
+
adaptations: ""
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
var analysisSchema, applyResultSchema, CHUNK_SIZE = 24000, MERGE_PREAMBLE = `## Important: this is a CURATED MERGE, not a blind cherry-pick
|
|
614
|
+
|
|
615
|
+
You are helping merge changes from a source branch into a target branch that may have diverged significantly.
|
|
616
|
+
|
|
617
|
+
Rules:
|
|
618
|
+
- Preserve the target branch's architecture, conventions, and improvements
|
|
619
|
+
- Merge the INTENT of the source commit, not its literal paths/code
|
|
620
|
+
- The target may have different file locations, naming, or implementations
|
|
621
|
+
- Only apply changes that are genuinely useful to the target
|
|
622
|
+
- Do not regress target-specific improvements or features
|
|
623
|
+
- Prefer adapting behavior into the target's existing code structure
|
|
624
|
+
- If the source commit doesn't make sense for the target, explain why
|
|
625
|
+
`;
|
|
626
|
+
var init_codex = __esm(() => {
|
|
627
|
+
analysisSchema = {
|
|
628
|
+
type: "object",
|
|
629
|
+
properties: {
|
|
630
|
+
summary: { type: "string", description: "Concise summary of what this commit does" },
|
|
631
|
+
alreadyInTarget: {
|
|
632
|
+
type: "string",
|
|
633
|
+
enum: ["yes", "no", "partial"],
|
|
634
|
+
description: "Whether the target branch already has this change. 'yes' = exact same functionality exists. 'partial' = some parts exist. 'no' = missing."
|
|
635
|
+
},
|
|
636
|
+
reasoning: { type: "string", description: "Evidence \u2014 list which files you checked and what you found" },
|
|
637
|
+
applicationStrategy: {
|
|
638
|
+
type: "string",
|
|
639
|
+
description: "Step-by-step strategy for applying. If already present, say 'skip'."
|
|
640
|
+
},
|
|
641
|
+
affectedComponents: {
|
|
642
|
+
type: "array",
|
|
643
|
+
items: { type: "string" },
|
|
644
|
+
description: "Top-level directories/components affected (e.g. 'api', 'frontend', 'contracts')"
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
required: ["summary", "alreadyInTarget", "reasoning", "applicationStrategy", "affectedComponents"],
|
|
648
|
+
additionalProperties: false
|
|
649
|
+
};
|
|
650
|
+
applyResultSchema = {
|
|
651
|
+
type: "object",
|
|
652
|
+
properties: {
|
|
653
|
+
applied: { type: "boolean", description: "Whether changes were applied and committed" },
|
|
654
|
+
filesChanged: { type: "array", items: { type: "string" }, description: "File paths created/modified/deleted" },
|
|
655
|
+
commitMessage: { type: "string", description: "The commit message used" },
|
|
656
|
+
notes: { type: "string", description: "Issues encountered or adaptations made" },
|
|
657
|
+
adaptations: { type: "string", description: "How the changes were adapted to fit the target codebase" }
|
|
658
|
+
},
|
|
659
|
+
required: ["applied", "filesChanged", "commitMessage", "notes", "adaptations"],
|
|
660
|
+
additionalProperties: false
|
|
661
|
+
};
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// src/review.ts
|
|
665
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
666
|
+
function writeReviewPacket(audit, packet, attempt) {
|
|
667
|
+
const { commitHash } = packet;
|
|
668
|
+
audit.writeReviewContext(commitHash, attempt, {
|
|
669
|
+
commitHash: packet.commitHash,
|
|
670
|
+
commitMessage: packet.commitMessage,
|
|
671
|
+
sourceBranch: packet.sourceBranch,
|
|
672
|
+
targetBranch: packet.targetBranch,
|
|
673
|
+
analysis: packet.analysis,
|
|
674
|
+
reviewStrictness: packet.reviewStrictness
|
|
675
|
+
});
|
|
676
|
+
if (packet.relevantDocs) {
|
|
677
|
+
audit.writeRelevantDocs(commitHash, attempt, packet.relevantDocs);
|
|
678
|
+
}
|
|
679
|
+
if (packet.appliedDiffStat) {
|
|
680
|
+
audit.writeDiffStat(commitHash, attempt, packet.appliedDiffStat);
|
|
681
|
+
}
|
|
682
|
+
if (packet.appliedDiff) {
|
|
683
|
+
audit.writeAppliedPatch(commitHash, attempt, packet.appliedDiff);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
async function reviewAppliedDiff(worktreePath, packet) {
|
|
687
|
+
const strictnessInstructions = {
|
|
688
|
+
strict: "Be very strict. Any questionable change should be rejected. Err on the side of caution.",
|
|
689
|
+
normal: "Be thorough but reasonable. Reject clear issues, accept minor style differences.",
|
|
690
|
+
lenient: "Focus on correctness and safety. Accept reasonable adaptations even if imperfect."
|
|
691
|
+
};
|
|
692
|
+
const prompt = `You are reviewing a curated merge. A commit from "${packet.sourceBranch}" was applied to a branch based on "${packet.targetBranch}".
|
|
693
|
+
|
|
694
|
+
## Codex analysis of the source commit
|
|
695
|
+
Summary: ${packet.analysis.summary}
|
|
696
|
+
Decision: alreadyInTarget=${packet.analysis.alreadyInTarget}
|
|
697
|
+
Strategy: ${packet.analysis.applicationStrategy}
|
|
698
|
+
Affected components: ${packet.analysis.affectedComponents.join(", ")}
|
|
699
|
+
|
|
700
|
+
## Source commit
|
|
701
|
+
Hash: ${packet.commitHash}
|
|
702
|
+
Message: ${packet.commitMessage}
|
|
703
|
+
|
|
704
|
+
## Applied diff (what was actually committed):
|
|
705
|
+
\`\`\`diff
|
|
706
|
+
${packet.appliedDiff.slice(0, 30000)}
|
|
707
|
+
\`\`\`
|
|
708
|
+
|
|
709
|
+
## Diff stat:
|
|
710
|
+
${packet.appliedDiffStat}
|
|
711
|
+
|
|
712
|
+
${packet.repoContext ? `## Repository context
|
|
713
|
+
${packet.repoContext}
|
|
714
|
+
` : ""}
|
|
715
|
+
${packet.relevantDocs ? `## Relevant documentation
|
|
716
|
+
${packet.relevantDocs.slice(0, 5000)}
|
|
717
|
+
` : ""}
|
|
718
|
+
|
|
719
|
+
## Full code review \u2014 think critically
|
|
720
|
+
|
|
721
|
+
You are not just verifying the diff was applied. You are doing a FULL CODE REVIEW.
|
|
722
|
+
|
|
723
|
+
### Correctness
|
|
724
|
+
1. Did Codex make the RIGHT decision? Should this commit have been applied at all?
|
|
725
|
+
2. Does the applied change actually achieve the intent of the source commit?
|
|
726
|
+
3. Are there logic errors introduced by adapting the code?
|
|
727
|
+
4. Does the change break any existing target functionality?
|
|
728
|
+
5. Are edge cases handled correctly?
|
|
729
|
+
|
|
730
|
+
### Cleanliness
|
|
731
|
+
6. NO conflict markers (<<<<<<<, =======, >>>>>>>) anywhere
|
|
732
|
+
7. NO dead code, commented-out old code, or TODO/FIXME placeholders
|
|
733
|
+
8. Imports are correct \u2014 no broken references
|
|
734
|
+
9. Code style matches the target branch's conventions
|
|
735
|
+
10. No duplicate definitions or redundant code
|
|
736
|
+
|
|
737
|
+
### Architecture
|
|
738
|
+
11. Does the change respect the target's architecture?
|
|
739
|
+
12. Were target-specific improvements preserved (not regressed)?
|
|
740
|
+
13. If the target had a better implementation, was it kept?
|
|
741
|
+
14. Are file paths and module boundaries correct for the target?
|
|
742
|
+
|
|
743
|
+
### Completeness
|
|
744
|
+
15. The applied changes actually match the stated strategy
|
|
745
|
+
16. Nothing important was missed from the source commit
|
|
746
|
+
17. Files created/deleted as needed
|
|
747
|
+
|
|
748
|
+
Scope your review to the diff introduced by this commit. Read the affected files in the worktree to understand the full context around the changed lines, but focus your judgment on what this commit changed.
|
|
749
|
+
|
|
750
|
+
## Strictness: ${packet.reviewStrictness}
|
|
751
|
+
${strictnessInstructions[packet.reviewStrictness]}
|
|
752
|
+
|
|
753
|
+
If you have ANY objections, be specific about what's wrong and how to fix it. Your issues will be sent back to the apply agent for correction.`;
|
|
754
|
+
const q = query({
|
|
755
|
+
prompt,
|
|
756
|
+
options: {
|
|
757
|
+
cwd: worktreePath,
|
|
758
|
+
model: "claude-opus-4-6",
|
|
759
|
+
maxTurns: 30,
|
|
760
|
+
permissionMode: "default",
|
|
761
|
+
outputFormat: reviewSchema,
|
|
762
|
+
tools: ["Read", "Glob", "Grep", "Bash"],
|
|
763
|
+
disallowedTools: ["Edit", "Write", "NotebookEdit"],
|
|
764
|
+
systemPrompt: `You are a senior code reviewer for curated merge operations. Do a full code review \u2014 think about correctness, architecture, and whether the change was the right call.
|
|
765
|
+
|
|
766
|
+
You can:
|
|
767
|
+
- Read files for full context
|
|
768
|
+
- Search with Glob/Grep
|
|
769
|
+
- Run tests, linters, type-checkers, and build commands via Bash to verify correctness
|
|
770
|
+
- Run any read-only shell command (cat, ls, git diff, git log, etc.)
|
|
771
|
+
|
|
772
|
+
You MUST NOT modify the worktree in any way. No file writes, no git commits, no destructive commands.
|
|
773
|
+
|
|
774
|
+
Run relevant tests if you can determine the test command from the repo. Your objections will be sent back to the apply agent for fixing, so be specific and actionable.`
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
let resultText = "";
|
|
778
|
+
for await (const message of q) {
|
|
779
|
+
if (message.type === "result") {
|
|
780
|
+
if ("result" in message) {
|
|
781
|
+
resultText = message.result;
|
|
782
|
+
}
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (!resultText) {
|
|
787
|
+
return { approved: false, issues: ["Review produced no output"], summary: "Review failed", confidence: "low" };
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
return JSON.parse(resultText);
|
|
791
|
+
} catch {
|
|
792
|
+
const m = resultText.match(/\{[\s\S]*\}/);
|
|
793
|
+
if (m) {
|
|
794
|
+
try {
|
|
795
|
+
return JSON.parse(m[0]);
|
|
796
|
+
} catch {}
|
|
797
|
+
}
|
|
798
|
+
return {
|
|
799
|
+
approved: false,
|
|
800
|
+
issues: [`Unparseable review output: ${resultText.slice(0, 200)}`],
|
|
801
|
+
summary: "Parse error",
|
|
802
|
+
confidence: "low"
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
async function verifyReviewIntegrity(wtGit, expectedHead) {
|
|
807
|
+
const currentHead = (await wtGit.raw(["rev-parse", "HEAD"])).trim();
|
|
808
|
+
if (currentHead !== expectedHead) {
|
|
809
|
+
return `Review mutated HEAD: expected ${expectedHead.slice(0, 8)}, got ${currentHead.slice(0, 8)}`;
|
|
810
|
+
}
|
|
811
|
+
const status = await wtGit.status();
|
|
812
|
+
const dirty = status.modified.length + status.created.length + status.deleted.length + status.not_added.length + status.conflicted.length;
|
|
813
|
+
if (dirty > 0) {
|
|
814
|
+
const parts = [];
|
|
815
|
+
if (status.modified.length)
|
|
816
|
+
parts.push(`${status.modified.length} modified`);
|
|
817
|
+
if (status.created.length)
|
|
818
|
+
parts.push(`${status.created.length} staged`);
|
|
819
|
+
if (status.not_added.length)
|
|
820
|
+
parts.push(`${status.not_added.length} untracked`);
|
|
821
|
+
if (status.deleted.length)
|
|
822
|
+
parts.push(`${status.deleted.length} deleted`);
|
|
823
|
+
return `Review left dirty worktree: ${parts.join(", ")}`;
|
|
824
|
+
}
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
var reviewSchema;
|
|
828
|
+
var init_review = __esm(() => {
|
|
829
|
+
reviewSchema = {
|
|
830
|
+
type: "json_schema",
|
|
831
|
+
schema: {
|
|
832
|
+
type: "object",
|
|
833
|
+
properties: {
|
|
834
|
+
approved: {
|
|
835
|
+
type: "boolean",
|
|
836
|
+
description: "true if changes are clean, correct, and safe. false if issues need fixing."
|
|
837
|
+
},
|
|
838
|
+
issues: {
|
|
839
|
+
type: "array",
|
|
840
|
+
items: { type: "string" },
|
|
841
|
+
description: "Specific issues found. Empty if approved."
|
|
842
|
+
},
|
|
843
|
+
summary: {
|
|
844
|
+
type: "string",
|
|
845
|
+
description: "Brief summary of what was reviewed and the verdict"
|
|
846
|
+
},
|
|
847
|
+
confidence: {
|
|
848
|
+
type: "string",
|
|
849
|
+
enum: ["high", "medium", "low"],
|
|
850
|
+
description: "Confidence in the review assessment"
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
required: ["approved", "issues", "summary", "confidence"],
|
|
854
|
+
additionalProperties: false
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// src/audit.ts
|
|
860
|
+
import { mkdirSync, writeFileSync, appendFileSync, existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
|
|
861
|
+
import { join as join3 } from "path";
|
|
862
|
+
|
|
863
|
+
class AuditLog {
|
|
864
|
+
runDir;
|
|
865
|
+
commitsDir;
|
|
866
|
+
resultsFile;
|
|
867
|
+
constructor(baseDir, runId) {
|
|
868
|
+
this.runDir = join3(baseDir, ".backmerge", "runs", runId);
|
|
869
|
+
this.commitsDir = join3(this.runDir, "commits");
|
|
870
|
+
this.resultsFile = join3(this.runDir, "results.jsonl");
|
|
871
|
+
mkdirSync(this.commitsDir, { recursive: true });
|
|
872
|
+
}
|
|
873
|
+
get resultsPath() {
|
|
874
|
+
return this.resultsFile;
|
|
875
|
+
}
|
|
876
|
+
emit(event) {
|
|
877
|
+
appendFileSync(this.resultsFile, JSON.stringify(event) + `
|
|
878
|
+
`);
|
|
879
|
+
}
|
|
880
|
+
commitDir(hash) {
|
|
881
|
+
const dir = join3(this.commitsDir, hash.slice(0, 8));
|
|
882
|
+
mkdirSync(dir, { recursive: true });
|
|
883
|
+
return dir;
|
|
884
|
+
}
|
|
885
|
+
attemptDir(hash, attempt) {
|
|
886
|
+
const dir = join3(this.commitDir(hash), `attempt-${attempt}`);
|
|
887
|
+
mkdirSync(dir, { recursive: true });
|
|
888
|
+
return dir;
|
|
889
|
+
}
|
|
890
|
+
writeSourcePatch(hash, patch) {
|
|
891
|
+
const p = join3(this.commitDir(hash), "source.patch");
|
|
892
|
+
writeFileSync(p, patch);
|
|
893
|
+
return p;
|
|
894
|
+
}
|
|
895
|
+
writeAnalysis(hash, attempt, analysis) {
|
|
896
|
+
const p = join3(this.attemptDir(hash, attempt), "analysis.json");
|
|
897
|
+
writeFileSync(p, JSON.stringify(analysis, null, 2));
|
|
898
|
+
return p;
|
|
899
|
+
}
|
|
900
|
+
writeAppliedPatch(hash, attempt, patch) {
|
|
901
|
+
const p = join3(this.attemptDir(hash, attempt), "applied.patch");
|
|
902
|
+
writeFileSync(p, patch);
|
|
903
|
+
return p;
|
|
904
|
+
}
|
|
905
|
+
writeReviewContext(hash, attempt, context) {
|
|
906
|
+
const p = join3(this.attemptDir(hash, attempt), "review-context.json");
|
|
907
|
+
writeFileSync(p, JSON.stringify(context, null, 2));
|
|
908
|
+
return p;
|
|
909
|
+
}
|
|
910
|
+
writeReviewResult(hash, attempt, result) {
|
|
911
|
+
const p = join3(this.attemptDir(hash, attempt), "review-result.json");
|
|
912
|
+
writeFileSync(p, JSON.stringify(result, null, 2));
|
|
913
|
+
return p;
|
|
914
|
+
}
|
|
915
|
+
writeRelevantDocs(hash, attempt, docs) {
|
|
916
|
+
const p = join3(this.attemptDir(hash, attempt), "relevant-docs.txt");
|
|
917
|
+
writeFileSync(p, docs);
|
|
918
|
+
return p;
|
|
919
|
+
}
|
|
920
|
+
writeDiffStat(hash, attempt, stat) {
|
|
921
|
+
const p = join3(this.attemptDir(hash, attempt), "target-diff.stat.txt");
|
|
922
|
+
writeFileSync(p, stat);
|
|
923
|
+
return p;
|
|
924
|
+
}
|
|
925
|
+
runStart(meta) {
|
|
926
|
+
this.emit({ ts: new Date().toISOString(), event: "run_start", ...meta });
|
|
927
|
+
writeFileSync(join3(this.runDir, "metadata.json"), JSON.stringify(meta, null, 2));
|
|
928
|
+
}
|
|
929
|
+
runEnd(summary, decisions) {
|
|
930
|
+
this.emit({ ts: new Date().toISOString(), event: "run_end", summary });
|
|
931
|
+
writeFileSync(join3(this.runDir, "summary.json"), JSON.stringify({ summary, decisions }, null, 2));
|
|
932
|
+
}
|
|
933
|
+
commitStart(hash, message, index, total) {
|
|
934
|
+
this.emit({ ts: new Date().toISOString(), event: "commit_start", hash, message, index, total });
|
|
935
|
+
}
|
|
936
|
+
commitDecision(d) {
|
|
937
|
+
this.emit({ ts: new Date().toISOString(), event: "commit_decision", ...d });
|
|
938
|
+
}
|
|
939
|
+
cherrySkip(hash, message, reason) {
|
|
940
|
+
this.emit({ ts: new Date().toISOString(), event: "cherry_skip", hash, message, reason });
|
|
941
|
+
}
|
|
942
|
+
fetchReset(logs) {
|
|
943
|
+
this.emit({ ts: new Date().toISOString(), event: "fetch_reset", logs });
|
|
944
|
+
}
|
|
945
|
+
error(phase, hash, message, error) {
|
|
946
|
+
this.emit({ ts: new Date().toISOString(), event: "error", phase, hash, message, error });
|
|
947
|
+
}
|
|
948
|
+
get progressFile() {
|
|
949
|
+
return join3(this.runDir, "progress.json");
|
|
950
|
+
}
|
|
951
|
+
saveProgress(decisions, commitIndex) {
|
|
952
|
+
writeFileSync(this.progressFile, JSON.stringify({ decisions, lastCommitIndex: commitIndex, savedAt: new Date().toISOString() }, null, 2));
|
|
953
|
+
}
|
|
954
|
+
loadProgress() {
|
|
955
|
+
if (!existsSync3(this.progressFile))
|
|
956
|
+
return null;
|
|
957
|
+
try {
|
|
958
|
+
const raw = JSON.parse(readFileSync3(this.progressFile, "utf-8"));
|
|
959
|
+
return { decisions: raw.decisions ?? [], lastCommitIndex: raw.lastCommitIndex ?? -1 };
|
|
960
|
+
} catch {
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
function findResumableRun(baseDir, workBranch) {
|
|
966
|
+
const runsDir = join3(baseDir, ".backmerge", "runs");
|
|
967
|
+
if (!existsSync3(runsDir))
|
|
968
|
+
return null;
|
|
969
|
+
const runDirs = readdirSync2(runsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name.startsWith("run-")).map((e) => e.name).sort().reverse();
|
|
970
|
+
for (const runId of runDirs) {
|
|
971
|
+
const runDir = join3(runsDir, runId);
|
|
972
|
+
const metaFile = join3(runDir, "metadata.json");
|
|
973
|
+
const progressFile = join3(runDir, "progress.json");
|
|
974
|
+
const summaryFile = join3(runDir, "summary.json");
|
|
975
|
+
if (existsSync3(summaryFile))
|
|
976
|
+
continue;
|
|
977
|
+
if (!existsSync3(progressFile) || !existsSync3(metaFile))
|
|
978
|
+
continue;
|
|
979
|
+
try {
|
|
980
|
+
const meta = JSON.parse(readFileSync3(metaFile, "utf-8"));
|
|
981
|
+
if (meta.workBranch !== workBranch)
|
|
982
|
+
continue;
|
|
983
|
+
const progress = JSON.parse(readFileSync3(progressFile, "utf-8"));
|
|
984
|
+
return {
|
|
985
|
+
runId,
|
|
986
|
+
runDir,
|
|
987
|
+
decisions: progress.decisions ?? [],
|
|
988
|
+
lastCommitIndex: progress.lastCommitIndex ?? -1
|
|
989
|
+
};
|
|
990
|
+
} catch {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
var init_audit = () => {};
|
|
997
|
+
|
|
998
|
+
// src/git.ts
|
|
999
|
+
import simpleGit from "simple-git";
|
|
1000
|
+
import { join as join4 } from "path";
|
|
1001
|
+
import { tmpdir } from "os";
|
|
1002
|
+
function createGit(cwd) {
|
|
1003
|
+
return simpleGit(cwd);
|
|
1004
|
+
}
|
|
1005
|
+
async function isGitRepo(git) {
|
|
1006
|
+
try {
|
|
1007
|
+
await git.raw(["rev-parse", "--git-dir"]);
|
|
1008
|
+
return true;
|
|
1009
|
+
} catch {
|
|
1010
|
+
return false;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
async function resolveRef(git, ref) {
|
|
1014
|
+
const result = await git.raw(["rev-parse", "--verify", ref]);
|
|
1015
|
+
return result.trim();
|
|
1016
|
+
}
|
|
1017
|
+
async function getHead(git) {
|
|
1018
|
+
const result = await git.raw(["rev-parse", "HEAD"]);
|
|
1019
|
+
return result.trim();
|
|
1020
|
+
}
|
|
1021
|
+
async function getBranches(git) {
|
|
1022
|
+
const summary = await git.branch();
|
|
1023
|
+
const local = summary.all.filter((b) => !b.startsWith("remotes/"));
|
|
1024
|
+
const remote = summary.all.filter((b) => b.startsWith("remotes/") && !b.endsWith("/HEAD")).map((b) => b.replace(/^remotes\//, ""));
|
|
1025
|
+
const localSet = new Set(local);
|
|
1026
|
+
const combined = [...local];
|
|
1027
|
+
for (const r of remote) {
|
|
1028
|
+
const shortName = r.replace(/^[^/]+\//, "");
|
|
1029
|
+
if (!localSet.has(shortName) && !localSet.has(r)) {
|
|
1030
|
+
combined.push(r);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return combined;
|
|
1034
|
+
}
|
|
1035
|
+
async function getMergeBase(git, ref1, ref2) {
|
|
1036
|
+
const result = await git.raw(["merge-base", ref1, ref2]);
|
|
1037
|
+
return result.trim();
|
|
1038
|
+
}
|
|
1039
|
+
async function getCommitsSince(git, since, branch) {
|
|
1040
|
+
const log = await git.log({ from: since, to: branch });
|
|
1041
|
+
return log.all.map((c) => ({ hash: c.hash, message: c.message, date: c.date, author: c.author_name })).reverse();
|
|
1042
|
+
}
|
|
1043
|
+
async function getCommitDiff(git, hash) {
|
|
1044
|
+
return git.show([hash, "--stat", "--patch"]);
|
|
1045
|
+
}
|
|
1046
|
+
async function getCommitFiles(git, hash) {
|
|
1047
|
+
const raw = await git.raw(["diff-tree", "--no-commit-id", "--name-only", "-r", hash]);
|
|
1048
|
+
return raw.trim().split(`
|
|
1049
|
+
`).filter(Boolean);
|
|
1050
|
+
}
|
|
1051
|
+
async function getLatestCommitDiff(git, ref) {
|
|
1052
|
+
return git.show([ref, "--stat", "--patch"]);
|
|
1053
|
+
}
|
|
1054
|
+
async function getDescendantCommitsSince(git, since, ref) {
|
|
1055
|
+
const log = await git.log({ from: since, to: ref });
|
|
1056
|
+
return log.all.map((c) => ({ hash: c.hash, message: c.message, date: c.date, author: c.author_name }));
|
|
1057
|
+
}
|
|
1058
|
+
function generateWorktreePath(repoName) {
|
|
1059
|
+
const id = Math.random().toString(36).slice(2, 8);
|
|
1060
|
+
return join4(tmpdir(), `backmerge-${repoName}-${id}`);
|
|
1061
|
+
}
|
|
1062
|
+
async function createDetachedWorktree(git, path, ref) {
|
|
1063
|
+
await git.raw(["worktree", "add", "--detach", path, ref]);
|
|
1064
|
+
}
|
|
1065
|
+
async function advanceBranch(git, branch, newRef, expectedOld) {
|
|
1066
|
+
await git.raw(["update-ref", `refs/heads/${branch}`, newRef, expectedOld]);
|
|
1067
|
+
}
|
|
1068
|
+
async function branchExists(git, name) {
|
|
1069
|
+
try {
|
|
1070
|
+
await git.raw(["rev-parse", "--verify", `refs/heads/${name}`]);
|
|
1071
|
+
return true;
|
|
1072
|
+
} catch {
|
|
1073
|
+
return false;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async function createBranch(git, name, startPoint) {
|
|
1077
|
+
await git.raw(["branch", name, startPoint]);
|
|
1078
|
+
}
|
|
1079
|
+
async function resetBranch(git, name, ref) {
|
|
1080
|
+
await git.raw(["update-ref", `refs/heads/${name}`, ref]);
|
|
1081
|
+
}
|
|
1082
|
+
async function ensureWorkBranch(git, name, targetRef, resetWorkBranch) {
|
|
1083
|
+
const exists = await branchExists(git, name);
|
|
1084
|
+
if (exists && resetWorkBranch) {
|
|
1085
|
+
const resolved2 = await resolveRef(git, targetRef);
|
|
1086
|
+
await resetBranch(git, name, resolved2);
|
|
1087
|
+
return { created: false, reset: true };
|
|
1088
|
+
}
|
|
1089
|
+
if (exists) {
|
|
1090
|
+
return { created: false, reset: false };
|
|
1091
|
+
}
|
|
1092
|
+
const resolved = await resolveRef(git, targetRef);
|
|
1093
|
+
await createBranch(git, name, resolved);
|
|
1094
|
+
return { created: true, reset: false };
|
|
1095
|
+
}
|
|
1096
|
+
async function findAlreadyCherryPicked(git, targetRef, sourceRef, mergeBase) {
|
|
1097
|
+
const needed = new Set;
|
|
1098
|
+
const skipped = new Map;
|
|
1099
|
+
try {
|
|
1100
|
+
const result = await git.raw(["cherry", targetRef, sourceRef, mergeBase]);
|
|
1101
|
+
for (const line of result.trim().split(`
|
|
1102
|
+
`)) {
|
|
1103
|
+
if (!line.trim())
|
|
1104
|
+
continue;
|
|
1105
|
+
const prefix = line[0];
|
|
1106
|
+
const hash = line.slice(2).trim();
|
|
1107
|
+
if (prefix === "-") {
|
|
1108
|
+
skipped.set(hash, "patch-id match (already cherry-picked)");
|
|
1109
|
+
} else {
|
|
1110
|
+
needed.add(hash);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
} catch {
|
|
1114
|
+
const commits = await getCommitsSince(git, mergeBase, sourceRef);
|
|
1115
|
+
for (const c of commits)
|
|
1116
|
+
needed.add(c.hash);
|
|
1117
|
+
}
|
|
1118
|
+
return { needed, skipped };
|
|
1119
|
+
}
|
|
1120
|
+
async function validateApply(worktreeGit, beforeHash) {
|
|
1121
|
+
const errors = [];
|
|
1122
|
+
let newCommitHash = null;
|
|
1123
|
+
let newCommitCount = 0;
|
|
1124
|
+
try {
|
|
1125
|
+
const log = await worktreeGit.raw(["log", "--format=%H", `${beforeHash}..HEAD`]);
|
|
1126
|
+
const newHashes = log.trim().split(`
|
|
1127
|
+
`).filter(Boolean);
|
|
1128
|
+
newCommitCount = newHashes.length;
|
|
1129
|
+
if (newCommitCount === 0) {
|
|
1130
|
+
errors.push("No new commit created");
|
|
1131
|
+
} else if (newCommitCount === 1) {
|
|
1132
|
+
newCommitHash = newHashes[0];
|
|
1133
|
+
} else {
|
|
1134
|
+
errors.push(`Expected 1 new commit, found ${newCommitCount}`);
|
|
1135
|
+
newCommitHash = newHashes[0];
|
|
1136
|
+
}
|
|
1137
|
+
} catch (e) {
|
|
1138
|
+
errors.push(`Failed to check commits: ${e.message}`);
|
|
1139
|
+
}
|
|
1140
|
+
const status = await worktreeGit.status();
|
|
1141
|
+
const worktreeClean = status.modified.length === 0 && status.created.length === 0 && status.deleted.length === 0 && status.conflicted.length === 0 && status.not_added.length === 0;
|
|
1142
|
+
if (!worktreeClean) {
|
|
1143
|
+
const parts = [];
|
|
1144
|
+
if (status.modified.length)
|
|
1145
|
+
parts.push(`${status.modified.length} modified`);
|
|
1146
|
+
if (status.not_added.length)
|
|
1147
|
+
parts.push(`${status.not_added.length} untracked`);
|
|
1148
|
+
if (status.deleted.length)
|
|
1149
|
+
parts.push(`${status.deleted.length} deleted`);
|
|
1150
|
+
if (status.conflicted.length)
|
|
1151
|
+
parts.push(`${status.conflicted.length} conflicted`);
|
|
1152
|
+
errors.push(`Working tree not clean: ${parts.join(", ")}`);
|
|
1153
|
+
}
|
|
1154
|
+
const conflictMarkers = [];
|
|
1155
|
+
if (newCommitHash) {
|
|
1156
|
+
try {
|
|
1157
|
+
const diffFiles = await worktreeGit.raw(["diff-tree", "--no-commit-id", "--name-only", "-r", newCommitHash]);
|
|
1158
|
+
for (const file of diffFiles.trim().split(`
|
|
1159
|
+
`).filter(Boolean)) {
|
|
1160
|
+
try {
|
|
1161
|
+
const content = await worktreeGit.raw(["show", `HEAD:${file}`]);
|
|
1162
|
+
if (/^<{7}\s|^>{7}\s|^={7}$/m.test(content)) {
|
|
1163
|
+
conflictMarkers.push(file);
|
|
1164
|
+
}
|
|
1165
|
+
} catch {}
|
|
1166
|
+
}
|
|
1167
|
+
} catch {}
|
|
1168
|
+
}
|
|
1169
|
+
if (conflictMarkers.length > 0) {
|
|
1170
|
+
errors.push(`Conflict markers in: ${conflictMarkers.join(", ")}`);
|
|
1171
|
+
}
|
|
1172
|
+
return { valid: errors.length === 0, newCommitHash, newCommitCount, worktreeClean, conflictMarkers, errors };
|
|
1173
|
+
}
|
|
1174
|
+
async function getAppliedDiff(worktreeGit, beforeHash) {
|
|
1175
|
+
return worktreeGit.raw(["diff", beforeHash, "HEAD"]);
|
|
1176
|
+
}
|
|
1177
|
+
async function getAppliedDiffStat(worktreeGit, beforeHash) {
|
|
1178
|
+
return worktreeGit.raw(["diff", "--stat", beforeHash, "HEAD"]);
|
|
1179
|
+
}
|
|
1180
|
+
async function resetHard(git, ref) {
|
|
1181
|
+
await git.raw(["reset", "--hard", ref]);
|
|
1182
|
+
await git.raw(["clean", "-fd"]);
|
|
1183
|
+
}
|
|
1184
|
+
async function fetchOrigin(git) {
|
|
1185
|
+
await git.fetch(["--all", "--prune"]);
|
|
1186
|
+
return "Fetched all remotes";
|
|
1187
|
+
}
|
|
1188
|
+
async function resetBranchToRemote(git, branch) {
|
|
1189
|
+
try {
|
|
1190
|
+
const remote = (await git.raw(["config", `branch.${branch}.remote`])).trim();
|
|
1191
|
+
const remoteBranch = `${remote}/${branch}`;
|
|
1192
|
+
await git.raw(["rev-parse", "--verify", remoteBranch]);
|
|
1193
|
+
await git.raw(["update-ref", `refs/heads/${branch}`, remoteBranch]);
|
|
1194
|
+
return `Reset ${branch} \u2192 ${remoteBranch}`;
|
|
1195
|
+
} catch {
|
|
1196
|
+
return `No remote tracking for ${branch}, skipped`;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
async function fetchAndReset(git, branches) {
|
|
1200
|
+
const logs = [];
|
|
1201
|
+
logs.push(await fetchOrigin(git));
|
|
1202
|
+
for (const branch of branches) {
|
|
1203
|
+
logs.push(await resetBranchToRemote(git, branch));
|
|
1204
|
+
}
|
|
1205
|
+
return logs;
|
|
1206
|
+
}
|
|
1207
|
+
var init_git = () => {};
|
|
1208
|
+
|
|
1209
|
+
// src/engine.ts
|
|
1210
|
+
async function runEngine(opts, cb) {
|
|
1211
|
+
const {
|
|
1212
|
+
repoPath,
|
|
1213
|
+
sourceRef,
|
|
1214
|
+
targetRef,
|
|
1215
|
+
dryRun = false,
|
|
1216
|
+
listOnly = false,
|
|
1217
|
+
fetch = false,
|
|
1218
|
+
review = true,
|
|
1219
|
+
autoSkip = true
|
|
1220
|
+
} = opts;
|
|
1221
|
+
const config = loadConfig(repoPath, opts.configPath);
|
|
1222
|
+
const effectiveMaxAttempts = opts.maxAttempts ?? config.maxAttempts ?? 2;
|
|
1223
|
+
const effectiveWorkBranch = opts.workBranch ?? config.workBranch;
|
|
1224
|
+
const commitPrefix = config.commitPrefix ?? "backmerge:";
|
|
1225
|
+
if (!checkCodexInstalled())
|
|
1226
|
+
throw new Error("codex CLI not found. Install: npm install -g @openai/codex");
|
|
1227
|
+
const git = createGit(repoPath);
|
|
1228
|
+
if (!await isGitRepo(git))
|
|
1229
|
+
throw new Error(`${repoPath} is not a git repository`);
|
|
1230
|
+
cb.onLog("codex CLI found", "green");
|
|
1231
|
+
if (fetch) {
|
|
1232
|
+
cb.onStatus("Fetching remotes...");
|
|
1233
|
+
const logs = await fetchAndReset(git, []);
|
|
1234
|
+
for (const l of logs)
|
|
1235
|
+
cb.onLog(` ${l}`, "gray");
|
|
1236
|
+
}
|
|
1237
|
+
cb.onStatus("Discovering repo structure & docs...");
|
|
1238
|
+
const repoCtx = buildRepoContext(repoPath, config);
|
|
1239
|
+
const instrCount = repoCtx.instructions.size;
|
|
1240
|
+
const docCount = repoCtx.docPaths.length;
|
|
1241
|
+
cb.onLog(`Repo: ${repoCtx.structure.type}, ${instrCount} instruction files, ${docCount} doc files`, "gray");
|
|
1242
|
+
if (instrCount > 0) {
|
|
1243
|
+
cb.onLog(` Instructions: ${[...repoCtx.instructions.keys()].join(", ")}`, "gray");
|
|
1244
|
+
}
|
|
1245
|
+
cb.onStatus("Resolving refs...");
|
|
1246
|
+
const resolvedSource = await resolveRef(git, sourceRef);
|
|
1247
|
+
const resolvedTarget = await resolveRef(git, targetRef);
|
|
1248
|
+
cb.onLog(`Source: ${sourceRef} \u2192 ${resolvedSource.slice(0, 8)}`, "gray");
|
|
1249
|
+
cb.onLog(`Target: ${targetRef} \u2192 ${resolvedTarget.slice(0, 8)}`, "gray");
|
|
1250
|
+
const mergeBase = await getMergeBase(git, resolvedTarget, resolvedSource);
|
|
1251
|
+
cb.onLog(`Merge base: ${mergeBase.slice(0, 8)}`, "gray");
|
|
1252
|
+
const allSourceCommits = await getCommitsSince(git, mergeBase, resolvedSource);
|
|
1253
|
+
const targetCommits = await getDescendantCommitsSince(git, mergeBase, resolvedTarget);
|
|
1254
|
+
cb.onLog(`${allSourceCommits.length} source commits, ${targetCommits.length} target commits since divergence`, "blue");
|
|
1255
|
+
if (allSourceCommits.length === 0) {
|
|
1256
|
+
cb.onLog("No source commits to process!", "green");
|
|
1257
|
+
return {
|
|
1258
|
+
summary: emptyRunSummary(),
|
|
1259
|
+
decisions: [],
|
|
1260
|
+
worktreePath: "",
|
|
1261
|
+
workBranch: "",
|
|
1262
|
+
auditDir: "",
|
|
1263
|
+
commits: []
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
cb.onStatus("Detecting already cherry-picked commits...");
|
|
1267
|
+
const { needed, skipped: cherrySkipped } = await findAlreadyCherryPicked(git, resolvedTarget, resolvedSource, mergeBase);
|
|
1268
|
+
const summary = emptyRunSummary();
|
|
1269
|
+
const decisions = [];
|
|
1270
|
+
summary.cherrySkipped = cherrySkipped.size;
|
|
1271
|
+
let commitsToProcess = allSourceCommits.filter((c) => needed.has(c.hash));
|
|
1272
|
+
cb.onLog(`${cherrySkipped.size} cherry-picked (skipped), ${commitsToProcess.length} to process`, "blue");
|
|
1273
|
+
if (opts.startAfter) {
|
|
1274
|
+
const idx = commitsToProcess.findIndex((c) => c.hash.startsWith(opts.startAfter));
|
|
1275
|
+
if (idx >= 0) {
|
|
1276
|
+
commitsToProcess = commitsToProcess.slice(idx + 1);
|
|
1277
|
+
cb.onLog(`Starting after ${opts.startAfter}, ${commitsToProcess.length} remaining`, "gray");
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
if (opts.limit && opts.limit < commitsToProcess.length) {
|
|
1281
|
+
commitsToProcess = commitsToProcess.slice(0, opts.limit);
|
|
1282
|
+
cb.onLog(`Limited to first ${opts.limit} commits`, "gray");
|
|
1283
|
+
}
|
|
1284
|
+
summary.total = commitsToProcess.length;
|
|
1285
|
+
if (listOnly) {
|
|
1286
|
+
for (let i = 0;i < commitsToProcess.length; i++) {
|
|
1287
|
+
const c = commitsToProcess[i];
|
|
1288
|
+
cb.onLog(` ${i + 1}. ${c.hash.slice(0, 8)} ${c.message}`, "white");
|
|
1289
|
+
}
|
|
1290
|
+
return {
|
|
1291
|
+
summary,
|
|
1292
|
+
decisions: [],
|
|
1293
|
+
worktreePath: "",
|
|
1294
|
+
workBranch: "",
|
|
1295
|
+
auditDir: "",
|
|
1296
|
+
commits: commitsToProcess
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
const repoName = repoPath.split("/").pop() ?? "repo";
|
|
1300
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
1301
|
+
let wbName;
|
|
1302
|
+
if (effectiveWorkBranch) {
|
|
1303
|
+
wbName = effectiveWorkBranch;
|
|
1304
|
+
const { created, reset } = await ensureWorkBranch(git, wbName, targetRef, opts.resetWorkBranch ?? false);
|
|
1305
|
+
if (created)
|
|
1306
|
+
cb.onLog(`Created work branch: ${wbName} from ${targetRef}`, "green");
|
|
1307
|
+
else if (reset)
|
|
1308
|
+
cb.onLog(`Reset work branch: ${wbName} to ${targetRef}`, "yellow");
|
|
1309
|
+
else
|
|
1310
|
+
cb.onLog(`Resuming work branch: ${wbName}`, "green");
|
|
1311
|
+
} else {
|
|
1312
|
+
wbName = `backmerge/${ts}`;
|
|
1313
|
+
await ensureWorkBranch(git, wbName, targetRef, false);
|
|
1314
|
+
cb.onLog(`New branch: ${wbName}`, "green");
|
|
1315
|
+
}
|
|
1316
|
+
const wbHead = await resolveRef(git, wbName);
|
|
1317
|
+
cb.onLog(`Work branch HEAD: ${wbHead.slice(0, 8)}`, "gray");
|
|
1318
|
+
const wtPath = generateWorktreePath(repoName);
|
|
1319
|
+
await createDetachedWorktree(git, wtPath, wbHead);
|
|
1320
|
+
cb.onLog(`Eval worktree (detached): ${wtPath}`, "green");
|
|
1321
|
+
const wtGit = createGit(wtPath);
|
|
1322
|
+
const runId = `run-${ts}`;
|
|
1323
|
+
const audit = new AuditLog(wtPath, runId);
|
|
1324
|
+
const runMeta = {
|
|
1325
|
+
runId,
|
|
1326
|
+
startedAt: new Date().toISOString(),
|
|
1327
|
+
sourceRef,
|
|
1328
|
+
targetRef,
|
|
1329
|
+
workBranch: wbName,
|
|
1330
|
+
mergeBase,
|
|
1331
|
+
worktreePath: wtPath,
|
|
1332
|
+
totalCandidates: commitsToProcess.length,
|
|
1333
|
+
cherrySkipped: cherrySkipped.size,
|
|
1334
|
+
dryRun,
|
|
1335
|
+
repoPath
|
|
1336
|
+
};
|
|
1337
|
+
audit.runStart(runMeta);
|
|
1338
|
+
for (const [hash, reason] of cherrySkipped) {
|
|
1339
|
+
const c = allSourceCommits.find((x) => x.hash === hash);
|
|
1340
|
+
if (c)
|
|
1341
|
+
audit.cherrySkip(hash, c.message, reason);
|
|
1342
|
+
}
|
|
1343
|
+
let resumeFromIndex = 0;
|
|
1344
|
+
if (opts.resume && effectiveWorkBranch) {
|
|
1345
|
+
const resumeInfo = findResumableRun(repoPath, effectiveWorkBranch);
|
|
1346
|
+
if (resumeInfo && resumeInfo.lastCommitIndex >= 0) {
|
|
1347
|
+
const prevHashes = new Set(resumeInfo.decisions.map((d) => d.commitHash));
|
|
1348
|
+
for (const d of resumeInfo.decisions) {
|
|
1349
|
+
decisions.push(d);
|
|
1350
|
+
updateSummary(summary, d);
|
|
1351
|
+
}
|
|
1352
|
+
resumeFromIndex = commitsToProcess.findIndex((c) => !prevHashes.has(c.hash));
|
|
1353
|
+
if (resumeFromIndex < 0)
|
|
1354
|
+
resumeFromIndex = commitsToProcess.length;
|
|
1355
|
+
cb.onLog(`Resuming from commit ${resumeFromIndex + 1}/${commitsToProcess.length} (${resumeInfo.decisions.length} already decided)`, "green");
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
const sourceLatestDiff = await getLatestCommitDiff(git, resolvedSource);
|
|
1359
|
+
let currentBranchHead = wbHead;
|
|
1360
|
+
for (let i = resumeFromIndex;i < commitsToProcess.length; i++) {
|
|
1361
|
+
const commit = commitsToProcess[i];
|
|
1362
|
+
audit.commitStart(commit.hash, commit.message, i, commitsToProcess.length);
|
|
1363
|
+
cb.onCommitStart(commit, i, commitsToProcess.length);
|
|
1364
|
+
const decision = await processOneCommit({
|
|
1365
|
+
git,
|
|
1366
|
+
wtGit,
|
|
1367
|
+
wtPath,
|
|
1368
|
+
repoPath,
|
|
1369
|
+
commit,
|
|
1370
|
+
index: i,
|
|
1371
|
+
total: commitsToProcess.length,
|
|
1372
|
+
sourceRef,
|
|
1373
|
+
targetRef,
|
|
1374
|
+
sourceLatestDiff,
|
|
1375
|
+
config,
|
|
1376
|
+
repoCtx,
|
|
1377
|
+
audit,
|
|
1378
|
+
cb,
|
|
1379
|
+
dryRun,
|
|
1380
|
+
review,
|
|
1381
|
+
autoSkip,
|
|
1382
|
+
maxAttempts: effectiveMaxAttempts,
|
|
1383
|
+
commitPrefix,
|
|
1384
|
+
workBranch: wbName,
|
|
1385
|
+
workBranchHead: currentBranchHead
|
|
1386
|
+
});
|
|
1387
|
+
if (decision.kind === "applied" && decision.newCommitHash) {
|
|
1388
|
+
currentBranchHead = decision.newCommitHash;
|
|
1389
|
+
}
|
|
1390
|
+
decisions.push(decision);
|
|
1391
|
+
updateSummary(summary, decision);
|
|
1392
|
+
audit.commitDecision(decision);
|
|
1393
|
+
audit.saveProgress(decisions, i);
|
|
1394
|
+
cb.onDecision(commit, decision);
|
|
1395
|
+
}
|
|
1396
|
+
audit.runEnd(summary, decisions);
|
|
1397
|
+
return {
|
|
1398
|
+
summary,
|
|
1399
|
+
decisions,
|
|
1400
|
+
worktreePath: wtPath,
|
|
1401
|
+
workBranch: wbName,
|
|
1402
|
+
auditDir: audit.runDir,
|
|
1403
|
+
commits: commitsToProcess
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
async function processOneCommit(o) {
|
|
1407
|
+
const start = Date.now();
|
|
1408
|
+
const { commit, audit, cb } = o;
|
|
1409
|
+
let touchedPaths = [];
|
|
1410
|
+
try {
|
|
1411
|
+
touchedPaths = await getCommitFiles(o.git, commit.hash);
|
|
1412
|
+
} catch {}
|
|
1413
|
+
const commitCtx = buildCommitContext(o.repoPath, o.repoCtx, o.config, touchedPaths, commit.message);
|
|
1414
|
+
if (commitCtx.includedFiles.length > 0) {
|
|
1415
|
+
audit.writeRelevantDocs(commit.hash, 0, commitCtx.includedFiles.join(`
|
|
1416
|
+
`));
|
|
1417
|
+
}
|
|
1418
|
+
let diff;
|
|
1419
|
+
try {
|
|
1420
|
+
diff = await getCommitDiff(o.git, commit.hash);
|
|
1421
|
+
audit.writeSourcePatch(commit.hash, diff);
|
|
1422
|
+
} catch (e) {
|
|
1423
|
+
return mkFailed(commit, "analysis", e.message, start);
|
|
1424
|
+
}
|
|
1425
|
+
cb.onStatus(`Analyzing ${commit.hash.slice(0, 8)}: ${commit.message.slice(0, 50)}`);
|
|
1426
|
+
let analysis;
|
|
1427
|
+
try {
|
|
1428
|
+
analysis = await analyzeCommit({
|
|
1429
|
+
worktreePath: o.wtPath,
|
|
1430
|
+
commitDiff: diff,
|
|
1431
|
+
commitMessage: commit.message,
|
|
1432
|
+
commitHash: commit.hash,
|
|
1433
|
+
sourceBranch: o.sourceRef,
|
|
1434
|
+
targetBranch: o.targetRef,
|
|
1435
|
+
sourceLatestDiff: o.sourceLatestDiff,
|
|
1436
|
+
repoContext: commitCtx.promptBlock
|
|
1437
|
+
});
|
|
1438
|
+
audit.writeAnalysis(commit.hash, 1, analysis);
|
|
1439
|
+
cb.onAnalysis(commit, analysis);
|
|
1440
|
+
} catch (e) {
|
|
1441
|
+
audit.error("analysis", commit.hash, commit.message, e.message);
|
|
1442
|
+
return mkFailed(commit, "analysis", e.message, start);
|
|
1443
|
+
}
|
|
1444
|
+
if (o.autoSkip && analysis.alreadyInTarget === "yes") {
|
|
1445
|
+
cb.onLog(`Auto-skip ${commit.hash.slice(0, 8)}: ${commit.message} (already present)`, "blue");
|
|
1446
|
+
return {
|
|
1447
|
+
kind: "already_applied",
|
|
1448
|
+
commitHash: commit.hash,
|
|
1449
|
+
commitMessage: commit.message,
|
|
1450
|
+
reason: `AI: ${analysis.reasoning.slice(0, 200)}`,
|
|
1451
|
+
durationMs: Date.now() - start
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
if (o.dryRun) {
|
|
1455
|
+
const kind = analysis.alreadyInTarget === "partial" ? "would_apply" : "would_apply";
|
|
1456
|
+
return {
|
|
1457
|
+
kind,
|
|
1458
|
+
commitHash: commit.hash,
|
|
1459
|
+
commitMessage: commit.message,
|
|
1460
|
+
reason: analysis.applicationStrategy.slice(0, 300),
|
|
1461
|
+
durationMs: Date.now() - start
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
if (o.cb.onAskApply) {
|
|
1465
|
+
const answer = await o.cb.onAskApply(commit, analysis);
|
|
1466
|
+
if (answer === "skip") {
|
|
1467
|
+
return {
|
|
1468
|
+
kind: "skip",
|
|
1469
|
+
commitHash: commit.hash,
|
|
1470
|
+
commitMessage: commit.message,
|
|
1471
|
+
reason: "user skipped",
|
|
1472
|
+
durationMs: Date.now() - start
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
if (answer === "quit") {
|
|
1476
|
+
return {
|
|
1477
|
+
kind: "skip",
|
|
1478
|
+
commitHash: commit.hash,
|
|
1479
|
+
commitMessage: commit.message,
|
|
1480
|
+
reason: "user quit",
|
|
1481
|
+
durationMs: Date.now() - start
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
for (let attempt = 1;attempt <= o.maxAttempts; attempt++) {
|
|
1486
|
+
const headBefore = await getHead(o.wtGit);
|
|
1487
|
+
cb.onStatus(`Applying ${commit.hash.slice(0, 8)} (attempt ${attempt}/${o.maxAttempts})...`);
|
|
1488
|
+
let applyResult;
|
|
1489
|
+
try {
|
|
1490
|
+
applyResult = await applyCommit({
|
|
1491
|
+
worktreePath: o.wtPath,
|
|
1492
|
+
commitDiff: diff,
|
|
1493
|
+
commitMessage: commit.message,
|
|
1494
|
+
commitHash: commit.hash,
|
|
1495
|
+
applicationStrategy: analysis.applicationStrategy,
|
|
1496
|
+
sourceBranch: o.sourceRef,
|
|
1497
|
+
targetBranch: o.targetRef,
|
|
1498
|
+
repoContext: commitCtx.promptBlock,
|
|
1499
|
+
commitPrefix: o.commitPrefix
|
|
1500
|
+
});
|
|
1501
|
+
audit.writeAnalysis(commit.hash, attempt, { ...analysis, applyResult });
|
|
1502
|
+
} catch (e) {
|
|
1503
|
+
audit.error("apply", commit.hash, commit.message, e.message);
|
|
1504
|
+
await resetHard(o.wtGit, headBefore);
|
|
1505
|
+
if (attempt === o.maxAttempts)
|
|
1506
|
+
return mkFailed(commit, "apply", e.message, start);
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
cb.onStatus(`Validating ${commit.hash.slice(0, 8)}...`);
|
|
1510
|
+
const validation = await validateApply(o.wtGit, headBefore);
|
|
1511
|
+
if (!validation.valid) {
|
|
1512
|
+
cb.onLog(`Validation failed: ${validation.errors.join("; ")}`, "red");
|
|
1513
|
+
await resetHard(o.wtGit, headBefore);
|
|
1514
|
+
if (attempt === o.maxAttempts)
|
|
1515
|
+
return mkFailed(commit, "validation", validation.errors.join("; "), start);
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
if (o.review) {
|
|
1519
|
+
cb.onStatus(`Claude reviewing ${commit.hash.slice(0, 8)}...`);
|
|
1520
|
+
let reviewResult;
|
|
1521
|
+
let packet;
|
|
1522
|
+
try {
|
|
1523
|
+
const appliedDiff = await getAppliedDiff(o.wtGit, headBefore);
|
|
1524
|
+
const appliedDiffStat = await getAppliedDiffStat(o.wtGit, headBefore);
|
|
1525
|
+
packet = {
|
|
1526
|
+
commitHash: commit.hash,
|
|
1527
|
+
commitMessage: commit.message,
|
|
1528
|
+
sourceBranch: o.sourceRef,
|
|
1529
|
+
targetBranch: o.targetRef,
|
|
1530
|
+
analysis,
|
|
1531
|
+
appliedDiff,
|
|
1532
|
+
appliedDiffStat,
|
|
1533
|
+
sourcePatch: diff,
|
|
1534
|
+
relevantDocs: commitCtx.includedFiles.join(`
|
|
1535
|
+
`),
|
|
1536
|
+
repoContext: commitCtx.promptBlock,
|
|
1537
|
+
reviewStrictness: o.config.reviewStrictness ?? "normal"
|
|
1538
|
+
};
|
|
1539
|
+
writeReviewPacket(audit, packet, attempt);
|
|
1540
|
+
const headBeforeReview = await getHead(o.wtGit);
|
|
1541
|
+
reviewResult = await reviewAppliedDiff(o.wtPath, packet);
|
|
1542
|
+
audit.writeReviewResult(commit.hash, attempt, reviewResult);
|
|
1543
|
+
const mutation = await verifyReviewIntegrity(o.wtGit, headBeforeReview);
|
|
1544
|
+
if (mutation) {
|
|
1545
|
+
cb.onLog(`Review integrity violation: ${mutation} \u2014 resetting`, "red");
|
|
1546
|
+
audit.error("review_integrity", commit.hash, commit.message, mutation);
|
|
1547
|
+
await resetHard(o.wtGit, headBeforeReview);
|
|
1548
|
+
}
|
|
1549
|
+
cb.onReview?.(commit, reviewResult);
|
|
1550
|
+
} catch (e) {
|
|
1551
|
+
audit.error("review", commit.hash, commit.message, e.message);
|
|
1552
|
+
await resetHard(o.wtGit, headBefore);
|
|
1553
|
+
if (attempt === o.maxAttempts)
|
|
1554
|
+
return mkFailed(commit, "review", e.message, start);
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
if (!reviewResult.approved) {
|
|
1558
|
+
cb.onLog(`Review rejected: ${reviewResult.issues.join("; ")}`, "red");
|
|
1559
|
+
const maxFixRounds = 2;
|
|
1560
|
+
let fixed = false;
|
|
1561
|
+
for (let fixRound = 1;fixRound <= maxFixRounds; fixRound++) {
|
|
1562
|
+
cb.onStatus(`Codex fixing review issues (round ${fixRound}/${maxFixRounds})...`);
|
|
1563
|
+
try {
|
|
1564
|
+
await fixFromReview({
|
|
1565
|
+
worktreePath: o.wtPath,
|
|
1566
|
+
commitHash: commit.hash,
|
|
1567
|
+
commitMessage: commit.message,
|
|
1568
|
+
reviewIssues: reviewResult.issues,
|
|
1569
|
+
sourceBranch: o.sourceRef,
|
|
1570
|
+
targetBranch: o.targetRef,
|
|
1571
|
+
repoContext: commitCtx.promptBlock,
|
|
1572
|
+
commitPrefix: o.commitPrefix
|
|
1573
|
+
});
|
|
1574
|
+
} catch (e) {
|
|
1575
|
+
cb.onLog(`Fix failed: ${e.message}`, "red");
|
|
1576
|
+
break;
|
|
1577
|
+
}
|
|
1578
|
+
const postFixValidation = await validateApply(o.wtGit, headBefore);
|
|
1579
|
+
if (!postFixValidation.valid) {
|
|
1580
|
+
cb.onLog(`Post-fix validation failed: ${postFixValidation.errors.join("; ")}`, "red");
|
|
1581
|
+
break;
|
|
1582
|
+
}
|
|
1583
|
+
cb.onStatus(`Claude re-reviewing after fix (round ${fixRound})...`);
|
|
1584
|
+
try {
|
|
1585
|
+
const fixedDiff = await getAppliedDiff(o.wtGit, headBefore);
|
|
1586
|
+
const fixedStat = await getAppliedDiffStat(o.wtGit, headBefore);
|
|
1587
|
+
const fixPacket = {
|
|
1588
|
+
...packet,
|
|
1589
|
+
appliedDiff: fixedDiff,
|
|
1590
|
+
appliedDiffStat: fixedStat
|
|
1591
|
+
};
|
|
1592
|
+
const headBeforeReReview = await getHead(o.wtGit);
|
|
1593
|
+
reviewResult = await reviewAppliedDiff(o.wtPath, fixPacket);
|
|
1594
|
+
audit.writeReviewResult(commit.hash, attempt, {
|
|
1595
|
+
...reviewResult,
|
|
1596
|
+
fixRound
|
|
1597
|
+
});
|
|
1598
|
+
const reReviewMutation = await verifyReviewIntegrity(o.wtGit, headBeforeReReview);
|
|
1599
|
+
if (reReviewMutation) {
|
|
1600
|
+
cb.onLog(`Re-review integrity violation: ${reReviewMutation} \u2014 resetting`, "red");
|
|
1601
|
+
await resetHard(o.wtGit, headBeforeReReview);
|
|
1602
|
+
}
|
|
1603
|
+
cb.onReview?.(commit, reviewResult);
|
|
1604
|
+
if (reviewResult.approved) {
|
|
1605
|
+
cb.onLog(`Review approved after fix round ${fixRound}`, "green");
|
|
1606
|
+
fixed = true;
|
|
1607
|
+
break;
|
|
1608
|
+
}
|
|
1609
|
+
cb.onLog(`Still rejected after fix round ${fixRound}: ${reviewResult.issues.join("; ")}`, "yellow");
|
|
1610
|
+
} catch (e) {
|
|
1611
|
+
cb.onLog(`Re-review failed: ${e.message}`, "red");
|
|
1612
|
+
break;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
if (!fixed) {
|
|
1616
|
+
await resetHard(o.wtGit, headBefore);
|
|
1617
|
+
if (o.cb.onReviewRejected) {
|
|
1618
|
+
const answer = await o.cb.onReviewRejected(commit, reviewResult);
|
|
1619
|
+
if (answer === "skip" || answer === "quit") {
|
|
1620
|
+
return {
|
|
1621
|
+
kind: "failed",
|
|
1622
|
+
commitHash: commit.hash,
|
|
1623
|
+
commitMessage: commit.message,
|
|
1624
|
+
reason: `Review rejected after ${maxFixRounds} fix rounds: ${reviewResult.issues.join("; ")}`,
|
|
1625
|
+
failedPhase: "review",
|
|
1626
|
+
reviewApproved: false,
|
|
1627
|
+
reviewIssues: reviewResult.issues,
|
|
1628
|
+
durationMs: Date.now() - start
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
if (attempt === o.maxAttempts) {
|
|
1634
|
+
return {
|
|
1635
|
+
kind: "failed",
|
|
1636
|
+
commitHash: commit.hash,
|
|
1637
|
+
commitMessage: commit.message,
|
|
1638
|
+
reason: `Review rejected after ${maxFixRounds} fix rounds: ${reviewResult.issues.join("; ")}`,
|
|
1639
|
+
failedPhase: "review",
|
|
1640
|
+
reviewApproved: false,
|
|
1641
|
+
reviewIssues: reviewResult.issues,
|
|
1642
|
+
durationMs: Date.now() - start
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
continue;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
cb.onLog(`Review approved (${reviewResult.confidence})`, "green");
|
|
1649
|
+
}
|
|
1650
|
+
const headAfter = await getHead(o.wtGit);
|
|
1651
|
+
const d = {
|
|
1652
|
+
kind: "applied",
|
|
1653
|
+
commitHash: commit.hash,
|
|
1654
|
+
commitMessage: commit.message,
|
|
1655
|
+
reason: applyResult.notes || "Applied successfully",
|
|
1656
|
+
newCommitHash: validation.newCommitHash ?? undefined,
|
|
1657
|
+
filesChanged: applyResult.filesChanged,
|
|
1658
|
+
reviewApproved: o.review ? true : undefined,
|
|
1659
|
+
durationMs: Date.now() - start
|
|
1660
|
+
};
|
|
1661
|
+
const invariantErrors = validateDecision(d, headBefore, headAfter, o.dryRun);
|
|
1662
|
+
if (invariantErrors.length > 0) {
|
|
1663
|
+
cb.onLog(`Decision invariant violated: ${invariantErrors.join("; ")}`, "red");
|
|
1664
|
+
audit.error("invariant", commit.hash, commit.message, invariantErrors.join("; "));
|
|
1665
|
+
await resetHard(o.wtGit, headBefore);
|
|
1666
|
+
return mkFailed(commit, "invariant", `Decision invariant violated: ${invariantErrors.join("; ")}`, start);
|
|
1667
|
+
}
|
|
1668
|
+
try {
|
|
1669
|
+
await advanceBranch(o.git, o.workBranch, headAfter, o.workBranchHead);
|
|
1670
|
+
cb.onLog(`Advanced ${o.workBranch} \u2192 ${headAfter.slice(0, 8)}`, "green");
|
|
1671
|
+
} catch (e) {
|
|
1672
|
+
cb.onLog(`Branch advance failed (concurrent move?): ${e.message}`, "red");
|
|
1673
|
+
audit.error("branch_advance", commit.hash, commit.message, e.message);
|
|
1674
|
+
await resetHard(o.wtGit, headBefore);
|
|
1675
|
+
return mkFailed(commit, "branch_advance", `CAS failed: ${e.message}`, start);
|
|
1676
|
+
}
|
|
1677
|
+
cb.onLog(`Applied ${commit.hash.slice(0, 8)}: ${commit.message}`, "green");
|
|
1678
|
+
if (applyResult.filesChanged.length > 0) {
|
|
1679
|
+
cb.onLog(` files: ${applyResult.filesChanged.join(", ")}`, "gray");
|
|
1680
|
+
}
|
|
1681
|
+
return d;
|
|
1682
|
+
}
|
|
1683
|
+
return mkFailed(commit, "apply", "Exhausted all attempts", start);
|
|
1684
|
+
}
|
|
1685
|
+
function mkFailed(commit, phase, error, start) {
|
|
1686
|
+
return {
|
|
1687
|
+
kind: "failed",
|
|
1688
|
+
commitHash: commit.hash,
|
|
1689
|
+
commitMessage: commit.message,
|
|
1690
|
+
reason: error,
|
|
1691
|
+
error,
|
|
1692
|
+
failedPhase: phase,
|
|
1693
|
+
durationMs: Date.now() - start
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
var init_engine = __esm(() => {
|
|
1697
|
+
init_config();
|
|
1698
|
+
init_context();
|
|
1699
|
+
init_codex();
|
|
1700
|
+
init_review();
|
|
1701
|
+
init_audit();
|
|
1702
|
+
init_git();
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
// src/batch.ts
|
|
1706
|
+
var exports_batch = {};
|
|
1707
|
+
__export(exports_batch, {
|
|
1708
|
+
runBatch: () => runBatch
|
|
1709
|
+
});
|
|
1710
|
+
function emit(obj) {
|
|
1711
|
+
process.stdout.write(JSON.stringify({ ...obj, ts: new Date().toISOString() }) + `
|
|
1712
|
+
`);
|
|
1713
|
+
}
|
|
1714
|
+
async function runBatch(opts) {
|
|
1715
|
+
const cb = {
|
|
1716
|
+
onLog(msg, color) {
|
|
1717
|
+
emit({ event: "log", msg, color });
|
|
1718
|
+
},
|
|
1719
|
+
onStatus(msg) {
|
|
1720
|
+
emit({ event: "status", msg });
|
|
1721
|
+
},
|
|
1722
|
+
onCommitStart(commit, index, total) {
|
|
1723
|
+
emit({ event: "commit_start", hash: commit.hash, message: commit.message, index, total });
|
|
1724
|
+
},
|
|
1725
|
+
onAnalysis(commit, analysis) {
|
|
1726
|
+
emit({ event: "analysis", hash: commit.hash, result: analysis });
|
|
1727
|
+
},
|
|
1728
|
+
onDecision(commit, decision) {
|
|
1729
|
+
emit({ event: "decision", hash: commit.hash, kind: decision.kind, reason: decision.reason });
|
|
1730
|
+
},
|
|
1731
|
+
onReview(commit, review) {
|
|
1732
|
+
emit({ event: "review", hash: commit.hash, approved: review.approved, issues: review.issues });
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
try {
|
|
1736
|
+
const result = await runEngine(opts, cb);
|
|
1737
|
+
emit({
|
|
1738
|
+
event: "done",
|
|
1739
|
+
summary: result.summary,
|
|
1740
|
+
worktree: result.worktreePath,
|
|
1741
|
+
branch: result.workBranch,
|
|
1742
|
+
auditDir: result.auditDir
|
|
1743
|
+
});
|
|
1744
|
+
const { summary } = result;
|
|
1745
|
+
if (summary.failed > 0)
|
|
1746
|
+
return 2;
|
|
1747
|
+
return 0;
|
|
1748
|
+
} catch (e) {
|
|
1749
|
+
emit({ event: "fatal", error: e.message });
|
|
1750
|
+
return 1;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
var init_batch = __esm(() => {
|
|
1754
|
+
init_engine();
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
// index.ts
|
|
1758
|
+
import React2 from "react";
|
|
1759
|
+
import { render } from "ink";
|
|
1760
|
+
import { resolve } from "path";
|
|
1761
|
+
|
|
1762
|
+
// src/app.tsx
|
|
1763
|
+
init_engine();
|
|
1764
|
+
init_git();
|
|
1765
|
+
init_codex();
|
|
1766
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
1767
|
+
import { Box, Text, useInput, useApp, Static, Newline } from "ink";
|
|
1768
|
+
import SelectInput from "ink-select-input";
|
|
1769
|
+
import Spinner from "ink-spinner";
|
|
1770
|
+
import { jsxDEV, Fragment } from "react/jsx-dev-runtime";
|
|
1771
|
+
function shortHash(h) {
|
|
1772
|
+
return h.slice(0, 8);
|
|
1773
|
+
}
|
|
1774
|
+
function progressBar(current, total, width = 30) {
|
|
1775
|
+
const ratio = Math.min(current / total, 1);
|
|
1776
|
+
const filled = Math.round(ratio * width);
|
|
1777
|
+
return `[${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}] ${current}/${total}`;
|
|
1778
|
+
}
|
|
1779
|
+
function Header({
|
|
1780
|
+
source,
|
|
1781
|
+
target,
|
|
1782
|
+
current,
|
|
1783
|
+
total,
|
|
1784
|
+
worktree
|
|
1785
|
+
}) {
|
|
1786
|
+
return /* @__PURE__ */ jsxDEV(Box, {
|
|
1787
|
+
flexDirection: "column",
|
|
1788
|
+
marginBottom: 1,
|
|
1789
|
+
children: [
|
|
1790
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
1791
|
+
children: [
|
|
1792
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1793
|
+
bold: true,
|
|
1794
|
+
color: "cyan",
|
|
1795
|
+
children: "\u256D\u2500 backmerge"
|
|
1796
|
+
}, undefined, false, undefined, this),
|
|
1797
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1798
|
+
color: "gray",
|
|
1799
|
+
children: " \u2014 curated branch reconciliation"
|
|
1800
|
+
}, undefined, false, undefined, this)
|
|
1801
|
+
]
|
|
1802
|
+
}, undefined, true, undefined, this),
|
|
1803
|
+
target && source && /* @__PURE__ */ jsxDEV(Box, {
|
|
1804
|
+
children: [
|
|
1805
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1806
|
+
color: "cyan",
|
|
1807
|
+
children: [
|
|
1808
|
+
"\u2502",
|
|
1809
|
+
" "
|
|
1810
|
+
]
|
|
1811
|
+
}, undefined, true, undefined, this),
|
|
1812
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1813
|
+
color: "green",
|
|
1814
|
+
children: target
|
|
1815
|
+
}, undefined, false, undefined, this),
|
|
1816
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1817
|
+
color: "gray",
|
|
1818
|
+
children: " \u2190 "
|
|
1819
|
+
}, undefined, false, undefined, this),
|
|
1820
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1821
|
+
color: "magenta",
|
|
1822
|
+
children: source
|
|
1823
|
+
}, undefined, false, undefined, this),
|
|
1824
|
+
current !== undefined && total !== undefined && /* @__PURE__ */ jsxDEV(Fragment, {
|
|
1825
|
+
children: [
|
|
1826
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1827
|
+
color: "gray",
|
|
1828
|
+
children: " "
|
|
1829
|
+
}, undefined, false, undefined, this),
|
|
1830
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1831
|
+
children: progressBar(current, total)
|
|
1832
|
+
}, undefined, false, undefined, this)
|
|
1833
|
+
]
|
|
1834
|
+
}, undefined, true, undefined, this)
|
|
1835
|
+
]
|
|
1836
|
+
}, undefined, true, undefined, this),
|
|
1837
|
+
worktree && /* @__PURE__ */ jsxDEV(Box, {
|
|
1838
|
+
children: [
|
|
1839
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1840
|
+
color: "cyan",
|
|
1841
|
+
children: [
|
|
1842
|
+
"\u2502",
|
|
1843
|
+
" "
|
|
1844
|
+
]
|
|
1845
|
+
}, undefined, true, undefined, this),
|
|
1846
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1847
|
+
dimColor: true,
|
|
1848
|
+
children: [
|
|
1849
|
+
"worktree: ",
|
|
1850
|
+
worktree
|
|
1851
|
+
]
|
|
1852
|
+
}, undefined, true, undefined, this)
|
|
1853
|
+
]
|
|
1854
|
+
}, undefined, true, undefined, this),
|
|
1855
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1856
|
+
color: "cyan",
|
|
1857
|
+
children: [
|
|
1858
|
+
"\u2570",
|
|
1859
|
+
"\u2500".repeat(60)
|
|
1860
|
+
]
|
|
1861
|
+
}, undefined, true, undefined, this)
|
|
1862
|
+
]
|
|
1863
|
+
}, undefined, true, undefined, this);
|
|
1864
|
+
}
|
|
1865
|
+
function ActionBar({ actions }) {
|
|
1866
|
+
return /* @__PURE__ */ jsxDEV(Box, {
|
|
1867
|
+
gap: 2,
|
|
1868
|
+
marginTop: 1,
|
|
1869
|
+
children: actions.map((a) => /* @__PURE__ */ jsxDEV(Box, {
|
|
1870
|
+
children: [
|
|
1871
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1872
|
+
color: "gray",
|
|
1873
|
+
children: "["
|
|
1874
|
+
}, undefined, false, undefined, this),
|
|
1875
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1876
|
+
bold: true,
|
|
1877
|
+
color: a.color ?? "cyan",
|
|
1878
|
+
children: a.key
|
|
1879
|
+
}, undefined, false, undefined, this),
|
|
1880
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1881
|
+
color: "gray",
|
|
1882
|
+
children: "] "
|
|
1883
|
+
}, undefined, false, undefined, this),
|
|
1884
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
1885
|
+
children: a.label
|
|
1886
|
+
}, undefined, false, undefined, this)
|
|
1887
|
+
]
|
|
1888
|
+
}, a.key, true, undefined, this))
|
|
1889
|
+
}, undefined, false, undefined, this);
|
|
1890
|
+
}
|
|
1891
|
+
function App({ repoPath, engineOpts }) {
|
|
1892
|
+
const { exit } = useApp();
|
|
1893
|
+
const [phase, setPhase] = useState("checking-prereqs");
|
|
1894
|
+
const [branches, setBranches] = useState([]);
|
|
1895
|
+
const [source, setSource] = useState(engineOpts.sourceRef ?? "");
|
|
1896
|
+
const [target, setTarget] = useState(engineOpts.targetRef ?? "");
|
|
1897
|
+
const [logs, setLogs] = useState([]);
|
|
1898
|
+
const [statusText, setStatusText] = useState("");
|
|
1899
|
+
const [error, setError] = useState("");
|
|
1900
|
+
const [currentCommit, setCurrentCommit] = useState(null);
|
|
1901
|
+
const [currentAnalysis, setCurrentAnalysis] = useState(null);
|
|
1902
|
+
const [currentReview, setCurrentReview] = useState(null);
|
|
1903
|
+
const [currentIdx, setCurrentIdx] = useState(0);
|
|
1904
|
+
const [totalCommits, setTotalCommits] = useState(0);
|
|
1905
|
+
const [worktreePath, setWorktreePath] = useState("");
|
|
1906
|
+
const [result, setResult] = useState(null);
|
|
1907
|
+
const askResolverRef = useRef(null);
|
|
1908
|
+
const reviewResolverRef = useRef(null);
|
|
1909
|
+
const addLog = useCallback((text, color) => {
|
|
1910
|
+
setLogs((prev) => [...prev, { id: `${Date.now()}-${Math.random()}`, text, color }]);
|
|
1911
|
+
}, []);
|
|
1912
|
+
useEffect(() => {
|
|
1913
|
+
if (phase !== "checking-prereqs")
|
|
1914
|
+
return;
|
|
1915
|
+
(async () => {
|
|
1916
|
+
if (!checkCodexInstalled()) {
|
|
1917
|
+
setError("codex CLI not found");
|
|
1918
|
+
setPhase("error");
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
const git = createGit(repoPath);
|
|
1922
|
+
if (!await isGitRepo(git)) {
|
|
1923
|
+
setError(`${repoPath} is not a git repo`);
|
|
1924
|
+
setPhase("error");
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
const b = await getBranches(git);
|
|
1928
|
+
setBranches(b);
|
|
1929
|
+
addLog(`Found ${b.length} branches`, "gray");
|
|
1930
|
+
if (source && target)
|
|
1931
|
+
setPhase("confirm");
|
|
1932
|
+
else if (target)
|
|
1933
|
+
setPhase("select-source");
|
|
1934
|
+
else
|
|
1935
|
+
setPhase("select-target");
|
|
1936
|
+
})().catch((e) => {
|
|
1937
|
+
setError(e.message);
|
|
1938
|
+
setPhase("error");
|
|
1939
|
+
});
|
|
1940
|
+
}, [phase, repoPath, addLog, source, target]);
|
|
1941
|
+
const startEngine = useCallback(async () => {
|
|
1942
|
+
setPhase("running");
|
|
1943
|
+
const opts = { repoPath, sourceRef: source, targetRef: target, ...engineOpts };
|
|
1944
|
+
const cb = {
|
|
1945
|
+
onLog: addLog,
|
|
1946
|
+
onStatus: setStatusText,
|
|
1947
|
+
onCommitStart(commit, index, total) {
|
|
1948
|
+
setCurrentCommit(commit);
|
|
1949
|
+
setCurrentIdx(index);
|
|
1950
|
+
setTotalCommits(total);
|
|
1951
|
+
},
|
|
1952
|
+
onAnalysis(_commit, analysis) {
|
|
1953
|
+
setCurrentAnalysis(analysis);
|
|
1954
|
+
},
|
|
1955
|
+
onDecision() {},
|
|
1956
|
+
onReview(_commit, review) {
|
|
1957
|
+
setCurrentReview(review);
|
|
1958
|
+
},
|
|
1959
|
+
async onAskApply(commit, analysis) {
|
|
1960
|
+
setCurrentCommit(commit);
|
|
1961
|
+
setCurrentAnalysis(analysis);
|
|
1962
|
+
setPhase("show-analysis");
|
|
1963
|
+
return new Promise((resolve) => {
|
|
1964
|
+
askResolverRef.current = resolve;
|
|
1965
|
+
});
|
|
1966
|
+
},
|
|
1967
|
+
async onReviewRejected(commit, review) {
|
|
1968
|
+
setCurrentCommit(commit);
|
|
1969
|
+
setCurrentReview(review);
|
|
1970
|
+
setPhase("review-result");
|
|
1971
|
+
return new Promise((resolve) => {
|
|
1972
|
+
reviewResolverRef.current = resolve;
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
try {
|
|
1977
|
+
const r = await runEngine(opts, cb);
|
|
1978
|
+
setResult(r);
|
|
1979
|
+
setWorktreePath(r.worktreePath);
|
|
1980
|
+
setPhase("done");
|
|
1981
|
+
} catch (e) {
|
|
1982
|
+
setError(e.message);
|
|
1983
|
+
setPhase("error");
|
|
1984
|
+
}
|
|
1985
|
+
}, [repoPath, source, target, engineOpts, addLog]);
|
|
1986
|
+
useInput((input, key) => {
|
|
1987
|
+
if (phase === "show-analysis" && askResolverRef.current) {
|
|
1988
|
+
if (input === "a" || input === "A") {
|
|
1989
|
+
askResolverRef.current("apply");
|
|
1990
|
+
askResolverRef.current = null;
|
|
1991
|
+
setPhase("running");
|
|
1992
|
+
} else if (input === "s" || input === "S") {
|
|
1993
|
+
askResolverRef.current("skip");
|
|
1994
|
+
askResolverRef.current = null;
|
|
1995
|
+
setPhase("running");
|
|
1996
|
+
} else if (input === "q" || input === "Q") {
|
|
1997
|
+
askResolverRef.current("quit");
|
|
1998
|
+
askResolverRef.current = null;
|
|
1999
|
+
setPhase("done");
|
|
2000
|
+
}
|
|
2001
|
+
} else if (phase === "review-result" && reviewResolverRef.current) {
|
|
2002
|
+
if (input === "r" || input === "R") {
|
|
2003
|
+
reviewResolverRef.current("retry");
|
|
2004
|
+
reviewResolverRef.current = null;
|
|
2005
|
+
setPhase("running");
|
|
2006
|
+
} else if (input === "s" || input === "S") {
|
|
2007
|
+
reviewResolverRef.current("skip");
|
|
2008
|
+
reviewResolverRef.current = null;
|
|
2009
|
+
setPhase("running");
|
|
2010
|
+
} else if (input === "q" || input === "Q") {
|
|
2011
|
+
reviewResolverRef.current("quit");
|
|
2012
|
+
reviewResolverRef.current = null;
|
|
2013
|
+
setPhase("done");
|
|
2014
|
+
}
|
|
2015
|
+
} else if (phase === "done" || phase === "error") {
|
|
2016
|
+
if (input === "q" || input === "Q" || key.escape || key.return)
|
|
2017
|
+
exit();
|
|
2018
|
+
} else if (phase === "confirm") {
|
|
2019
|
+
if (input === "y" || input === "Y" || key.return)
|
|
2020
|
+
startEngine();
|
|
2021
|
+
else if (input === "n" || input === "N" || key.escape)
|
|
2022
|
+
exit();
|
|
2023
|
+
}
|
|
2024
|
+
}, { isActive: ["show-analysis", "review-result", "done", "error", "confirm"].includes(phase) });
|
|
2025
|
+
const branchItems = branches.map((b) => ({ label: b, value: b }));
|
|
2026
|
+
return /* @__PURE__ */ jsxDEV(Box, {
|
|
2027
|
+
flexDirection: "column",
|
|
2028
|
+
paddingX: 1,
|
|
2029
|
+
children: [
|
|
2030
|
+
/* @__PURE__ */ jsxDEV(Header, {
|
|
2031
|
+
source: source || undefined,
|
|
2032
|
+
target: target || undefined,
|
|
2033
|
+
current: totalCommits > 0 ? currentIdx + 1 : undefined,
|
|
2034
|
+
total: totalCommits || undefined,
|
|
2035
|
+
worktree: worktreePath || undefined
|
|
2036
|
+
}, undefined, false, undefined, this),
|
|
2037
|
+
/* @__PURE__ */ jsxDEV(Static, {
|
|
2038
|
+
items: logs,
|
|
2039
|
+
children: (entry) => /* @__PURE__ */ jsxDEV(Text, {
|
|
2040
|
+
color: entry.color ?? "white",
|
|
2041
|
+
children: entry.text
|
|
2042
|
+
}, entry.id, false, undefined, this)
|
|
2043
|
+
}, undefined, false, undefined, this),
|
|
2044
|
+
phase === "checking-prereqs" && /* @__PURE__ */ jsxDEV(Box, {
|
|
2045
|
+
children: [
|
|
2046
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2047
|
+
color: "cyan",
|
|
2048
|
+
children: /* @__PURE__ */ jsxDEV(Spinner, {
|
|
2049
|
+
type: "dots"
|
|
2050
|
+
}, undefined, false, undefined, this)
|
|
2051
|
+
}, undefined, false, undefined, this),
|
|
2052
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2053
|
+
children: " Checking prerequisites..."
|
|
2054
|
+
}, undefined, false, undefined, this)
|
|
2055
|
+
]
|
|
2056
|
+
}, undefined, true, undefined, this),
|
|
2057
|
+
phase === "select-target" && branches.length > 0 && /* @__PURE__ */ jsxDEV(Box, {
|
|
2058
|
+
flexDirection: "column",
|
|
2059
|
+
children: [
|
|
2060
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2061
|
+
bold: true,
|
|
2062
|
+
color: "green",
|
|
2063
|
+
children: "Select target branch (where changes merge INTO):"
|
|
2064
|
+
}, undefined, false, undefined, this),
|
|
2065
|
+
/* @__PURE__ */ jsxDEV(SelectInput, {
|
|
2066
|
+
items: branchItems,
|
|
2067
|
+
onSelect: (item) => {
|
|
2068
|
+
setTarget(item.value);
|
|
2069
|
+
setPhase("select-source");
|
|
2070
|
+
}
|
|
2071
|
+
}, undefined, false, undefined, this)
|
|
2072
|
+
]
|
|
2073
|
+
}, undefined, true, undefined, this),
|
|
2074
|
+
phase === "select-source" && /* @__PURE__ */ jsxDEV(Box, {
|
|
2075
|
+
flexDirection: "column",
|
|
2076
|
+
children: [
|
|
2077
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2078
|
+
bold: true,
|
|
2079
|
+
color: "magenta",
|
|
2080
|
+
children: "Select source branch (where changes come FROM):"
|
|
2081
|
+
}, undefined, false, undefined, this),
|
|
2082
|
+
/* @__PURE__ */ jsxDEV(SelectInput, {
|
|
2083
|
+
items: branchItems.filter((b) => b.value !== target),
|
|
2084
|
+
onSelect: (item) => {
|
|
2085
|
+
setSource(item.value);
|
|
2086
|
+
setPhase("confirm");
|
|
2087
|
+
}
|
|
2088
|
+
}, undefined, false, undefined, this)
|
|
2089
|
+
]
|
|
2090
|
+
}, undefined, true, undefined, this),
|
|
2091
|
+
phase === "confirm" && /* @__PURE__ */ jsxDEV(Box, {
|
|
2092
|
+
flexDirection: "column",
|
|
2093
|
+
children: [
|
|
2094
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2095
|
+
flexDirection: "column",
|
|
2096
|
+
borderStyle: "round",
|
|
2097
|
+
borderColor: "yellow",
|
|
2098
|
+
paddingX: 1,
|
|
2099
|
+
children: [
|
|
2100
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2101
|
+
bold: true,
|
|
2102
|
+
children: "Confirm backmerge"
|
|
2103
|
+
}, undefined, false, undefined, this),
|
|
2104
|
+
/* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
|
|
2105
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2106
|
+
children: [
|
|
2107
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2108
|
+
children: "Source: "
|
|
2109
|
+
}, undefined, false, undefined, this),
|
|
2110
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2111
|
+
bold: true,
|
|
2112
|
+
color: "magenta",
|
|
2113
|
+
children: source
|
|
2114
|
+
}, undefined, false, undefined, this)
|
|
2115
|
+
]
|
|
2116
|
+
}, undefined, true, undefined, this),
|
|
2117
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2118
|
+
children: [
|
|
2119
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2120
|
+
children: "Target: "
|
|
2121
|
+
}, undefined, false, undefined, this),
|
|
2122
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2123
|
+
bold: true,
|
|
2124
|
+
color: "green",
|
|
2125
|
+
children: target
|
|
2126
|
+
}, undefined, false, undefined, this)
|
|
2127
|
+
]
|
|
2128
|
+
}, undefined, true, undefined, this),
|
|
2129
|
+
engineOpts.workBranch && /* @__PURE__ */ jsxDEV(Box, {
|
|
2130
|
+
children: [
|
|
2131
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2132
|
+
children: "Work branch: "
|
|
2133
|
+
}, undefined, false, undefined, this),
|
|
2134
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2135
|
+
bold: true,
|
|
2136
|
+
color: "cyan",
|
|
2137
|
+
children: engineOpts.workBranch
|
|
2138
|
+
}, undefined, false, undefined, this)
|
|
2139
|
+
]
|
|
2140
|
+
}, undefined, true, undefined, this),
|
|
2141
|
+
engineOpts.dryRun && /* @__PURE__ */ jsxDEV(Text, {
|
|
2142
|
+
color: "yellow",
|
|
2143
|
+
children: "DRY RUN"
|
|
2144
|
+
}, undefined, false, undefined, this)
|
|
2145
|
+
]
|
|
2146
|
+
}, undefined, true, undefined, this),
|
|
2147
|
+
/* @__PURE__ */ jsxDEV(ActionBar, {
|
|
2148
|
+
actions: [
|
|
2149
|
+
{ key: "y", label: "Proceed", color: "green" },
|
|
2150
|
+
{ key: "n", label: "Cancel", color: "red" }
|
|
2151
|
+
]
|
|
2152
|
+
}, undefined, false, undefined, this)
|
|
2153
|
+
]
|
|
2154
|
+
}, undefined, true, undefined, this),
|
|
2155
|
+
phase === "running" && /* @__PURE__ */ jsxDEV(Box, {
|
|
2156
|
+
children: [
|
|
2157
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2158
|
+
color: "cyan",
|
|
2159
|
+
children: /* @__PURE__ */ jsxDEV(Spinner, {
|
|
2160
|
+
type: "dots"
|
|
2161
|
+
}, undefined, false, undefined, this)
|
|
2162
|
+
}, undefined, false, undefined, this),
|
|
2163
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2164
|
+
children: [
|
|
2165
|
+
" ",
|
|
2166
|
+
statusText
|
|
2167
|
+
]
|
|
2168
|
+
}, undefined, true, undefined, this)
|
|
2169
|
+
]
|
|
2170
|
+
}, undefined, true, undefined, this),
|
|
2171
|
+
phase === "show-analysis" && currentCommit && currentAnalysis && /* @__PURE__ */ jsxDEV(Box, {
|
|
2172
|
+
flexDirection: "column",
|
|
2173
|
+
children: [
|
|
2174
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2175
|
+
flexDirection: "column",
|
|
2176
|
+
borderStyle: "round",
|
|
2177
|
+
borderColor: "blue",
|
|
2178
|
+
paddingX: 1,
|
|
2179
|
+
marginBottom: 1,
|
|
2180
|
+
children: [
|
|
2181
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2182
|
+
children: [
|
|
2183
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2184
|
+
bold: true,
|
|
2185
|
+
color: "blue",
|
|
2186
|
+
children: [
|
|
2187
|
+
"Commit ",
|
|
2188
|
+
currentIdx + 1,
|
|
2189
|
+
"/",
|
|
2190
|
+
totalCommits
|
|
2191
|
+
]
|
|
2192
|
+
}, undefined, true, undefined, this),
|
|
2193
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2194
|
+
color: "gray",
|
|
2195
|
+
children: " \u2022 "
|
|
2196
|
+
}, undefined, false, undefined, this),
|
|
2197
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2198
|
+
color: "yellow",
|
|
2199
|
+
children: shortHash(currentCommit.hash)
|
|
2200
|
+
}, undefined, false, undefined, this)
|
|
2201
|
+
]
|
|
2202
|
+
}, undefined, true, undefined, this),
|
|
2203
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2204
|
+
bold: true,
|
|
2205
|
+
children: currentCommit.message
|
|
2206
|
+
}, undefined, false, undefined, this)
|
|
2207
|
+
]
|
|
2208
|
+
}, undefined, true, undefined, this),
|
|
2209
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2210
|
+
flexDirection: "column",
|
|
2211
|
+
borderStyle: "round",
|
|
2212
|
+
borderColor: "magenta",
|
|
2213
|
+
paddingX: 1,
|
|
2214
|
+
marginBottom: 1,
|
|
2215
|
+
children: [
|
|
2216
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2217
|
+
children: [
|
|
2218
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2219
|
+
bold: true,
|
|
2220
|
+
color: "magenta",
|
|
2221
|
+
children: [
|
|
2222
|
+
"Analysis \u2502",
|
|
2223
|
+
" "
|
|
2224
|
+
]
|
|
2225
|
+
}, undefined, true, undefined, this),
|
|
2226
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2227
|
+
bold: true,
|
|
2228
|
+
color: currentAnalysis.alreadyInTarget === "yes" ? "green" : currentAnalysis.alreadyInTarget === "partial" ? "yellow" : "red",
|
|
2229
|
+
children: currentAnalysis.alreadyInTarget === "yes" ? "ALREADY PRESENT" : currentAnalysis.alreadyInTarget === "partial" ? "PARTIAL" : "MISSING"
|
|
2230
|
+
}, undefined, false, undefined, this)
|
|
2231
|
+
]
|
|
2232
|
+
}, undefined, true, undefined, this),
|
|
2233
|
+
/* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
|
|
2234
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2235
|
+
bold: true,
|
|
2236
|
+
children: "Summary:"
|
|
2237
|
+
}, undefined, false, undefined, this),
|
|
2238
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2239
|
+
wrap: "wrap",
|
|
2240
|
+
children: currentAnalysis.summary
|
|
2241
|
+
}, undefined, false, undefined, this),
|
|
2242
|
+
/* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
|
|
2243
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2244
|
+
bold: true,
|
|
2245
|
+
children: "Reasoning:"
|
|
2246
|
+
}, undefined, false, undefined, this),
|
|
2247
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2248
|
+
wrap: "wrap",
|
|
2249
|
+
dimColor: true,
|
|
2250
|
+
children: currentAnalysis.reasoning
|
|
2251
|
+
}, undefined, false, undefined, this),
|
|
2252
|
+
currentAnalysis.alreadyInTarget !== "yes" && /* @__PURE__ */ jsxDEV(Fragment, {
|
|
2253
|
+
children: [
|
|
2254
|
+
/* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
|
|
2255
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2256
|
+
bold: true,
|
|
2257
|
+
color: "yellow",
|
|
2258
|
+
children: "Strategy:"
|
|
2259
|
+
}, undefined, false, undefined, this),
|
|
2260
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2261
|
+
wrap: "wrap",
|
|
2262
|
+
children: currentAnalysis.applicationStrategy
|
|
2263
|
+
}, undefined, false, undefined, this)
|
|
2264
|
+
]
|
|
2265
|
+
}, undefined, true, undefined, this)
|
|
2266
|
+
]
|
|
2267
|
+
}, undefined, true, undefined, this),
|
|
2268
|
+
/* @__PURE__ */ jsxDEV(ActionBar, {
|
|
2269
|
+
actions: [
|
|
2270
|
+
...currentAnalysis.alreadyInTarget !== "yes" ? [{ key: "a", label: "Apply", color: "green" }] : [],
|
|
2271
|
+
{ key: "s", label: "Skip", color: "yellow" },
|
|
2272
|
+
{ key: "q", label: "Quit", color: "red" }
|
|
2273
|
+
]
|
|
2274
|
+
}, undefined, false, undefined, this)
|
|
2275
|
+
]
|
|
2276
|
+
}, undefined, true, undefined, this),
|
|
2277
|
+
phase === "review-result" && currentCommit && currentReview && /* @__PURE__ */ jsxDEV(Box, {
|
|
2278
|
+
flexDirection: "column",
|
|
2279
|
+
children: [
|
|
2280
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2281
|
+
flexDirection: "column",
|
|
2282
|
+
borderStyle: "round",
|
|
2283
|
+
borderColor: "red",
|
|
2284
|
+
paddingX: 1,
|
|
2285
|
+
marginBottom: 1,
|
|
2286
|
+
children: [
|
|
2287
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2288
|
+
bold: true,
|
|
2289
|
+
color: "red",
|
|
2290
|
+
children: [
|
|
2291
|
+
"Review REJECTED \u2014 ",
|
|
2292
|
+
shortHash(currentCommit.hash)
|
|
2293
|
+
]
|
|
2294
|
+
}, undefined, true, undefined, this),
|
|
2295
|
+
/* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
|
|
2296
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2297
|
+
wrap: "wrap",
|
|
2298
|
+
children: currentReview.summary
|
|
2299
|
+
}, undefined, false, undefined, this),
|
|
2300
|
+
currentReview.issues.map((issue, i) => /* @__PURE__ */ jsxDEV(Text, {
|
|
2301
|
+
color: "red",
|
|
2302
|
+
children: [
|
|
2303
|
+
" ",
|
|
2304
|
+
"\u2022 ",
|
|
2305
|
+
issue
|
|
2306
|
+
]
|
|
2307
|
+
}, i, true, undefined, this))
|
|
2308
|
+
]
|
|
2309
|
+
}, undefined, true, undefined, this),
|
|
2310
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2311
|
+
color: "yellow",
|
|
2312
|
+
children: "Changes rolled back."
|
|
2313
|
+
}, undefined, false, undefined, this),
|
|
2314
|
+
/* @__PURE__ */ jsxDEV(ActionBar, {
|
|
2315
|
+
actions: [
|
|
2316
|
+
{ key: "r", label: "Retry", color: "cyan" },
|
|
2317
|
+
{ key: "s", label: "Skip", color: "yellow" },
|
|
2318
|
+
{ key: "q", label: "Quit", color: "red" }
|
|
2319
|
+
]
|
|
2320
|
+
}, undefined, false, undefined, this)
|
|
2321
|
+
]
|
|
2322
|
+
}, undefined, true, undefined, this),
|
|
2323
|
+
phase === "done" && result && /* @__PURE__ */ jsxDEV(Box, {
|
|
2324
|
+
flexDirection: "column",
|
|
2325
|
+
marginTop: 1,
|
|
2326
|
+
children: [
|
|
2327
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2328
|
+
flexDirection: "column",
|
|
2329
|
+
borderStyle: "double",
|
|
2330
|
+
borderColor: "green",
|
|
2331
|
+
paddingX: 2,
|
|
2332
|
+
paddingY: 1,
|
|
2333
|
+
children: [
|
|
2334
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2335
|
+
bold: true,
|
|
2336
|
+
color: "green",
|
|
2337
|
+
children: "Backmerge Complete"
|
|
2338
|
+
}, undefined, false, undefined, this),
|
|
2339
|
+
/* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
|
|
2340
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2341
|
+
children: [
|
|
2342
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2343
|
+
children: "Applied: "
|
|
2344
|
+
}, undefined, false, undefined, this),
|
|
2345
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2346
|
+
bold: true,
|
|
2347
|
+
color: "green",
|
|
2348
|
+
children: result.summary.applied
|
|
2349
|
+
}, undefined, false, undefined, this)
|
|
2350
|
+
]
|
|
2351
|
+
}, undefined, true, undefined, this),
|
|
2352
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2353
|
+
children: [
|
|
2354
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2355
|
+
children: "Would apply: "
|
|
2356
|
+
}, undefined, false, undefined, this),
|
|
2357
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2358
|
+
bold: true,
|
|
2359
|
+
color: "cyan",
|
|
2360
|
+
children: result.summary.wouldApply
|
|
2361
|
+
}, undefined, false, undefined, this)
|
|
2362
|
+
]
|
|
2363
|
+
}, undefined, true, undefined, this),
|
|
2364
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2365
|
+
children: [
|
|
2366
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2367
|
+
children: "Already applied: "
|
|
2368
|
+
}, undefined, false, undefined, this),
|
|
2369
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2370
|
+
bold: true,
|
|
2371
|
+
color: "blue",
|
|
2372
|
+
children: result.summary.alreadyApplied
|
|
2373
|
+
}, undefined, false, undefined, this)
|
|
2374
|
+
]
|
|
2375
|
+
}, undefined, true, undefined, this),
|
|
2376
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2377
|
+
children: [
|
|
2378
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2379
|
+
children: "Skipped: "
|
|
2380
|
+
}, undefined, false, undefined, this),
|
|
2381
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2382
|
+
bold: true,
|
|
2383
|
+
color: "yellow",
|
|
2384
|
+
children: result.summary.skipped
|
|
2385
|
+
}, undefined, false, undefined, this)
|
|
2386
|
+
]
|
|
2387
|
+
}, undefined, true, undefined, this),
|
|
2388
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2389
|
+
children: [
|
|
2390
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2391
|
+
children: "Cherry-skipped: "
|
|
2392
|
+
}, undefined, false, undefined, this),
|
|
2393
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2394
|
+
bold: true,
|
|
2395
|
+
color: "cyan",
|
|
2396
|
+
children: result.summary.cherrySkipped
|
|
2397
|
+
}, undefined, false, undefined, this)
|
|
2398
|
+
]
|
|
2399
|
+
}, undefined, true, undefined, this),
|
|
2400
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2401
|
+
children: [
|
|
2402
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2403
|
+
children: "Failed: "
|
|
2404
|
+
}, undefined, false, undefined, this),
|
|
2405
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
2406
|
+
bold: true,
|
|
2407
|
+
color: "red",
|
|
2408
|
+
children: result.summary.failed
|
|
2409
|
+
}, undefined, false, undefined, this)
|
|
2410
|
+
]
|
|
2411
|
+
}, undefined, true, undefined, this),
|
|
2412
|
+
/* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
|
|
2413
|
+
result.worktreePath && /* @__PURE__ */ jsxDEV(Text, {
|
|
2414
|
+
dimColor: true,
|
|
2415
|
+
children: [
|
|
2416
|
+
"Worktree: ",
|
|
2417
|
+
result.worktreePath
|
|
2418
|
+
]
|
|
2419
|
+
}, undefined, true, undefined, this),
|
|
2420
|
+
result.workBranch && /* @__PURE__ */ jsxDEV(Text, {
|
|
2421
|
+
dimColor: true,
|
|
2422
|
+
children: [
|
|
2423
|
+
"Branch: ",
|
|
2424
|
+
result.workBranch
|
|
2425
|
+
]
|
|
2426
|
+
}, undefined, true, undefined, this),
|
|
2427
|
+
result.auditDir && /* @__PURE__ */ jsxDEV(Text, {
|
|
2428
|
+
dimColor: true,
|
|
2429
|
+
children: [
|
|
2430
|
+
"Audit: ",
|
|
2431
|
+
result.auditDir
|
|
2432
|
+
]
|
|
2433
|
+
}, undefined, true, undefined, this)
|
|
2434
|
+
]
|
|
2435
|
+
}, undefined, true, undefined, this),
|
|
2436
|
+
/* @__PURE__ */ jsxDEV(ActionBar, {
|
|
2437
|
+
actions: [{ key: "q", label: "Exit", color: "gray" }]
|
|
2438
|
+
}, undefined, false, undefined, this)
|
|
2439
|
+
]
|
|
2440
|
+
}, undefined, true, undefined, this),
|
|
2441
|
+
phase === "error" && /* @__PURE__ */ jsxDEV(Box, {
|
|
2442
|
+
flexDirection: "column",
|
|
2443
|
+
marginTop: 1,
|
|
2444
|
+
children: [
|
|
2445
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
2446
|
+
borderStyle: "round",
|
|
2447
|
+
borderColor: "red",
|
|
2448
|
+
paddingX: 1,
|
|
2449
|
+
children: /* @__PURE__ */ jsxDEV(Text, {
|
|
2450
|
+
color: "red",
|
|
2451
|
+
bold: true,
|
|
2452
|
+
children: [
|
|
2453
|
+
"Error: ",
|
|
2454
|
+
error
|
|
2455
|
+
]
|
|
2456
|
+
}, undefined, true, undefined, this)
|
|
2457
|
+
}, undefined, false, undefined, this),
|
|
2458
|
+
/* @__PURE__ */ jsxDEV(ActionBar, {
|
|
2459
|
+
actions: [{ key: "q", label: "Exit", color: "gray" }]
|
|
2460
|
+
}, undefined, false, undefined, this)
|
|
2461
|
+
]
|
|
2462
|
+
}, undefined, true, undefined, this)
|
|
2463
|
+
]
|
|
2464
|
+
}, undefined, true, undefined, this);
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// index.ts
|
|
2468
|
+
init_config();
|
|
2469
|
+
var args = process.argv.slice(2);
|
|
2470
|
+
var repoPath = ".";
|
|
2471
|
+
var sourceRef = "";
|
|
2472
|
+
var targetRef = "";
|
|
2473
|
+
var workBranch = "";
|
|
2474
|
+
var resetWorkBranch = false;
|
|
2475
|
+
var dryRun = false;
|
|
2476
|
+
var listOnly = false;
|
|
2477
|
+
var doFetch = false;
|
|
2478
|
+
var review = true;
|
|
2479
|
+
var autoSkip = true;
|
|
2480
|
+
var maxAttempts = 2;
|
|
2481
|
+
var startAfter = "";
|
|
2482
|
+
var limit = 0;
|
|
2483
|
+
var configPath = "";
|
|
2484
|
+
var batch = false;
|
|
2485
|
+
var resume = true;
|
|
2486
|
+
var showHelp = false;
|
|
2487
|
+
for (let i = 0;i < args.length; i++) {
|
|
2488
|
+
const arg = args[i];
|
|
2489
|
+
if (arg === "--help" || arg === "-h")
|
|
2490
|
+
showHelp = true;
|
|
2491
|
+
else if (arg === "--batch" || arg === "-b")
|
|
2492
|
+
batch = true;
|
|
2493
|
+
else if (arg === "--dry-run")
|
|
2494
|
+
dryRun = true;
|
|
2495
|
+
else if (arg === "--list-only")
|
|
2496
|
+
listOnly = true;
|
|
2497
|
+
else if (arg === "--fetch" || arg === "-f")
|
|
2498
|
+
doFetch = true;
|
|
2499
|
+
else if (arg === "--no-fetch")
|
|
2500
|
+
doFetch = false;
|
|
2501
|
+
else if (arg === "--no-review")
|
|
2502
|
+
review = false;
|
|
2503
|
+
else if (arg === "--no-auto-skip")
|
|
2504
|
+
autoSkip = false;
|
|
2505
|
+
else if (arg === "--reset-work-branch") {
|
|
2506
|
+
resetWorkBranch = true;
|
|
2507
|
+
resume = false;
|
|
2508
|
+
} else if (arg === "--no-resume")
|
|
2509
|
+
resume = false;
|
|
2510
|
+
else if (arg === "--source-ref" && args[i + 1])
|
|
2511
|
+
sourceRef = args[++i];
|
|
2512
|
+
else if (arg === "--target-ref" && args[i + 1])
|
|
2513
|
+
targetRef = args[++i];
|
|
2514
|
+
else if (arg === "--work-branch" && args[i + 1])
|
|
2515
|
+
workBranch = args[++i];
|
|
2516
|
+
else if (arg === "--start-after" && args[i + 1])
|
|
2517
|
+
startAfter = args[++i];
|
|
2518
|
+
else if (arg === "--limit" && args[i + 1])
|
|
2519
|
+
limit = parseInt(args[++i], 10) || 0;
|
|
2520
|
+
else if (arg === "--max-attempts" && args[i + 1])
|
|
2521
|
+
maxAttempts = parseInt(args[++i], 10) || 2;
|
|
2522
|
+
else if (arg === "--config" && args[i + 1])
|
|
2523
|
+
configPath = args[++i];
|
|
2524
|
+
else if (!arg.startsWith("-"))
|
|
2525
|
+
repoPath = arg;
|
|
2526
|
+
}
|
|
2527
|
+
if (showHelp) {
|
|
2528
|
+
console.log(`
|
|
2529
|
+
xab \u2014 AI-powered curated branch reconciliation
|
|
2530
|
+
|
|
2531
|
+
Usage:
|
|
2532
|
+
xab [repo-path] [options]
|
|
2533
|
+
|
|
2534
|
+
Ref selection:
|
|
2535
|
+
--source-ref <ref> Source branch/ref (where changes come FROM)
|
|
2536
|
+
--target-ref <ref> Target branch/ref (where changes merge INTO)
|
|
2537
|
+
--work-branch <name> Persistent work branch (resumes if exists)
|
|
2538
|
+
--reset-work-branch Force-reset work branch to target ref
|
|
2539
|
+
|
|
2540
|
+
Modes:
|
|
2541
|
+
--batch, -b Unattended batch mode (JSONL to stdout)
|
|
2542
|
+
--dry-run Analyze only, never create commits
|
|
2543
|
+
--list-only List candidate commits and exit
|
|
2544
|
+
|
|
2545
|
+
Filtering:
|
|
2546
|
+
--start-after <sha> Skip commits up to and including this hash
|
|
2547
|
+
--limit <n> Process at most n commits
|
|
2548
|
+
|
|
2549
|
+
Behavior:
|
|
2550
|
+
--fetch, -f Fetch remotes before starting
|
|
2551
|
+
--no-fetch Skip fetch (default)
|
|
2552
|
+
--no-review Skip Claude review pass
|
|
2553
|
+
--no-auto-skip Don't auto-skip commits AI identifies as present
|
|
2554
|
+
--max-attempts <n> Max retries per commit (default: 2)
|
|
2555
|
+
--no-resume Don't resume from interrupted runs (default: auto-resume)
|
|
2556
|
+
--config <path> Path to config file (default: auto-discover)
|
|
2557
|
+
--help, -h Show this help
|
|
2558
|
+
|
|
2559
|
+
Config:
|
|
2560
|
+
Place .xab.json in the target repo for persistent settings:
|
|
2561
|
+
- sourceRef, targetRef, workBranch: default refs
|
|
2562
|
+
- instructionFiles: extra docs to include in AI context
|
|
2563
|
+
- docRoutes: map commit paths/keywords to relevant docs
|
|
2564
|
+
- promptHints: extra instructions for AI
|
|
2565
|
+
- pathRemaps: source\u2192target path mappings
|
|
2566
|
+
- reviewStrictness: "strict" | "normal" | "lenient"
|
|
2567
|
+
- maxAttempts, commitPrefix
|
|
2568
|
+
|
|
2569
|
+
Pipeline:
|
|
2570
|
+
1. Discover repo structure, instruction files, docs
|
|
2571
|
+
2. Resolve refs, compute merge base
|
|
2572
|
+
3. Detect already cherry-picked commits (git patch-id)
|
|
2573
|
+
4. For each remaining commit:
|
|
2574
|
+
a. Build per-commit context (relevant docs, repo hints)
|
|
2575
|
+
b. Codex (gpt-5.4, high) analyzes: present/missing/partial
|
|
2576
|
+
c. Auto-skip if already present
|
|
2577
|
+
d. Codex applies changes (curated merge, not cherry-pick)
|
|
2578
|
+
e. Validate: exactly 1 clean commit, no conflict markers
|
|
2579
|
+
f. Claude (opus 4.6, high) reviews applied diff
|
|
2580
|
+
g. Branch advances only after review approval
|
|
2581
|
+
5. Audit log + artifacts written to .xab/runs/
|
|
2582
|
+
|
|
2583
|
+
Models:
|
|
2584
|
+
- Analysis/Apply: gpt-5.4 (high reasoning effort) via Codex SDK
|
|
2585
|
+
- Review: claude-opus-4-6 (high reasoning effort) via Claude Agent SDK
|
|
2586
|
+
|
|
2587
|
+
Exit codes (batch): 0=success, 1=fatal, 2=partial failure
|
|
2588
|
+
|
|
2589
|
+
Prerequisites:
|
|
2590
|
+
- codex CLI: npm install -g @openai/codex
|
|
2591
|
+
- Claude Code CLI: npm install -g @anthropic-ai/claude-code
|
|
2592
|
+
`);
|
|
2593
|
+
process.exit(0);
|
|
2594
|
+
}
|
|
2595
|
+
var resolvedPath = resolve(repoPath);
|
|
2596
|
+
var repoConfig = loadConfig(resolvedPath, configPath || undefined);
|
|
2597
|
+
if (!sourceRef && repoConfig.sourceRef)
|
|
2598
|
+
sourceRef = repoConfig.sourceRef;
|
|
2599
|
+
if (!targetRef && repoConfig.targetRef)
|
|
2600
|
+
targetRef = repoConfig.targetRef;
|
|
2601
|
+
if (!workBranch && repoConfig.workBranch)
|
|
2602
|
+
workBranch = repoConfig.workBranch;
|
|
2603
|
+
if (repoConfig.maxAttempts && maxAttempts === 2)
|
|
2604
|
+
maxAttempts = repoConfig.maxAttempts;
|
|
2605
|
+
var engineOpts = {
|
|
2606
|
+
...sourceRef && { sourceRef },
|
|
2607
|
+
...targetRef && { targetRef },
|
|
2608
|
+
...workBranch && { workBranch },
|
|
2609
|
+
...resetWorkBranch && { resetWorkBranch },
|
|
2610
|
+
...dryRun && { dryRun },
|
|
2611
|
+
...listOnly && { listOnly },
|
|
2612
|
+
...doFetch && { fetch: doFetch },
|
|
2613
|
+
review,
|
|
2614
|
+
autoSkip,
|
|
2615
|
+
resume,
|
|
2616
|
+
maxAttempts,
|
|
2617
|
+
...startAfter && { startAfter },
|
|
2618
|
+
...limit > 0 && { limit },
|
|
2619
|
+
...configPath && { configPath }
|
|
2620
|
+
};
|
|
2621
|
+
if (batch || listOnly) {
|
|
2622
|
+
if (!sourceRef || !targetRef) {
|
|
2623
|
+
console.error("Error: --batch/--list-only requires --source-ref and --target-ref (via flags or .xab.json)");
|
|
2624
|
+
process.exit(1);
|
|
2625
|
+
}
|
|
2626
|
+
const { runBatch: runBatch2 } = await Promise.resolve().then(() => (init_batch(), exports_batch));
|
|
2627
|
+
const exitCode = await runBatch2({
|
|
2628
|
+
repoPath: resolvedPath,
|
|
2629
|
+
sourceRef,
|
|
2630
|
+
targetRef,
|
|
2631
|
+
...engineOpts
|
|
2632
|
+
});
|
|
2633
|
+
process.exit(exitCode);
|
|
2634
|
+
} else {
|
|
2635
|
+
console.clear();
|
|
2636
|
+
const { waitUntilExit } = render(React2.createElement(App, { repoPath: resolvedPath, engineOpts }));
|
|
2637
|
+
await waitUntilExit();
|
|
2638
|
+
}
|