@os-eco/overstory-cli 0.6.1
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/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- package/templates/overlay.md.tmpl +81 -0
|
@@ -0,0 +1,1345 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeAll,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
spyOn,
|
|
9
|
+
test,
|
|
10
|
+
} from "bun:test";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { MergeError } from "../errors.ts";
|
|
13
|
+
import type { MulchClient } from "../mulch/client.ts";
|
|
14
|
+
import {
|
|
15
|
+
cleanupTempDir,
|
|
16
|
+
commitFile,
|
|
17
|
+
createTempGitRepo,
|
|
18
|
+
getDefaultBranch,
|
|
19
|
+
runGitInDir,
|
|
20
|
+
} from "../test-helpers.ts";
|
|
21
|
+
import type { MergeEntry, ParsedConflictPattern } from "../types.ts";
|
|
22
|
+
import {
|
|
23
|
+
buildConflictHistory,
|
|
24
|
+
createMergeResolver,
|
|
25
|
+
looksLikeProse,
|
|
26
|
+
parseConflictPatterns,
|
|
27
|
+
} from "./resolver.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Helper to create a mock Bun.spawn return value for claude CLI mocking.
|
|
31
|
+
*
|
|
32
|
+
* The resolver reads stdout/stderr via `new Response(proc.stdout).text()`
|
|
33
|
+
* and `new Response(proc.stderr).text()`, so we need ReadableStreams.
|
|
34
|
+
*/
|
|
35
|
+
function mockSpawnResult(
|
|
36
|
+
stdout: string,
|
|
37
|
+
stderr: string,
|
|
38
|
+
exitCode: number,
|
|
39
|
+
): {
|
|
40
|
+
stdout: ReadableStream<Uint8Array>;
|
|
41
|
+
stderr: ReadableStream<Uint8Array>;
|
|
42
|
+
exited: Promise<number>;
|
|
43
|
+
pid: number;
|
|
44
|
+
} {
|
|
45
|
+
return {
|
|
46
|
+
stdout: new Response(stdout).body as ReadableStream<Uint8Array>,
|
|
47
|
+
stderr: new Response(stderr).body as ReadableStream<Uint8Array>,
|
|
48
|
+
exited: Promise.resolve(exitCode),
|
|
49
|
+
pid: 12345,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeTestEntry(overrides?: Partial<MergeEntry>): MergeEntry {
|
|
54
|
+
return {
|
|
55
|
+
branchName: overrides?.branchName ?? "feature-branch",
|
|
56
|
+
beadId: overrides?.beadId ?? "bead-123",
|
|
57
|
+
agentName: overrides?.agentName ?? "test-agent",
|
|
58
|
+
filesModified: overrides?.filesModified ?? ["src/test.ts"],
|
|
59
|
+
enqueuedAt: overrides?.enqueuedAt ?? new Date().toISOString(),
|
|
60
|
+
status: overrides?.status ?? "pending",
|
|
61
|
+
resolvedTier: overrides?.resolvedTier ?? null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Set up a clean merge scenario: feature branch adds a new file with no conflict.
|
|
67
|
+
*/
|
|
68
|
+
async function setupCleanMerge(dir: string, baseBranch: string): Promise<void> {
|
|
69
|
+
await commitFile(dir, "src/main-file.ts", "main content\n");
|
|
70
|
+
await runGitInDir(dir, ["checkout", "-b", "feature-branch"]);
|
|
71
|
+
await commitFile(dir, "src/feature-file.ts", "feature content\n");
|
|
72
|
+
await runGitInDir(dir, ["checkout", baseBranch]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Set up a real content conflict: create a file, branch, modify on both
|
|
77
|
+
* branches. Both sides must diverge from the common ancestor to produce
|
|
78
|
+
* conflict markers.
|
|
79
|
+
*/
|
|
80
|
+
async function setupContentConflict(dir: string, baseBranch: string): Promise<void> {
|
|
81
|
+
await commitFile(dir, "src/test.ts", "original content\n");
|
|
82
|
+
await runGitInDir(dir, ["checkout", "-b", "feature-branch"]);
|
|
83
|
+
await commitFile(dir, "src/test.ts", "feature content\n");
|
|
84
|
+
await runGitInDir(dir, ["checkout", baseBranch]);
|
|
85
|
+
await commitFile(dir, "src/test.ts", "main modified content\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create a delete/modify conflict: file is deleted on main but modified on
|
|
90
|
+
* the feature branch. This produces a conflict with NO conflict markers in
|
|
91
|
+
* the working copy, causing Tier 2 auto-resolve to fail (resolveConflictsKeepIncoming
|
|
92
|
+
* returns null). This naturally escalates to Tier 3 or 4.
|
|
93
|
+
*/
|
|
94
|
+
async function setupDeleteModifyConflict(
|
|
95
|
+
dir: string,
|
|
96
|
+
baseBranch: string,
|
|
97
|
+
branchName = "feature-branch",
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
await commitFile(dir, "src/test.ts", "original content\n");
|
|
100
|
+
await runGitInDir(dir, ["checkout", "-b", branchName]);
|
|
101
|
+
await commitFile(dir, "src/test.ts", "modified by agent\n");
|
|
102
|
+
await runGitInDir(dir, ["checkout", baseBranch]);
|
|
103
|
+
await runGitInDir(dir, ["rm", "src/test.ts"]);
|
|
104
|
+
await runGitInDir(dir, ["commit", "-m", "delete src/test.ts"]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Set up a scenario where Tier 2 auto-resolve fails but Tier 4 reimagine can
|
|
109
|
+
* succeed. We create a delete/modify conflict on one file (causes Tier 2 to fail)
|
|
110
|
+
* and set entry.filesModified to a different file that exists on both branches
|
|
111
|
+
* (so git show works for both in reimagine).
|
|
112
|
+
*/
|
|
113
|
+
async function setupReimagineScenario(dir: string, baseBranch: string): Promise<void> {
|
|
114
|
+
await commitFile(dir, "src/conflict-file.ts", "original content\n");
|
|
115
|
+
await commitFile(dir, "src/reimagine-target.ts", "main version of target\n");
|
|
116
|
+
await runGitInDir(dir, ["checkout", "-b", "feature-branch"]);
|
|
117
|
+
await commitFile(dir, "src/conflict-file.ts", "modified by agent\n");
|
|
118
|
+
await commitFile(dir, "src/reimagine-target.ts", "feature version of target\n");
|
|
119
|
+
await runGitInDir(dir, ["checkout", baseBranch]);
|
|
120
|
+
await runGitInDir(dir, ["rm", "src/conflict-file.ts"]);
|
|
121
|
+
await runGitInDir(dir, ["commit", "-m", "delete conflict file"]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create a mock MulchClient for testing.
|
|
126
|
+
* Optionally override the record method to track calls or simulate failures.
|
|
127
|
+
*/
|
|
128
|
+
function createMockMulchClient(
|
|
129
|
+
recordImpl?: (domain: string, options: unknown) => Promise<void>,
|
|
130
|
+
): MulchClient {
|
|
131
|
+
return {
|
|
132
|
+
async prime() {
|
|
133
|
+
return "";
|
|
134
|
+
},
|
|
135
|
+
async status() {
|
|
136
|
+
return { domains: [] };
|
|
137
|
+
},
|
|
138
|
+
async record(domain: string, options: unknown) {
|
|
139
|
+
if (recordImpl) {
|
|
140
|
+
return recordImpl(domain, options);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
async query() {
|
|
144
|
+
return "";
|
|
145
|
+
},
|
|
146
|
+
async search() {
|
|
147
|
+
return "";
|
|
148
|
+
},
|
|
149
|
+
async diff() {
|
|
150
|
+
return {
|
|
151
|
+
success: true,
|
|
152
|
+
command: "diff",
|
|
153
|
+
since: "HEAD",
|
|
154
|
+
domains: [],
|
|
155
|
+
message: "",
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
async learn() {
|
|
159
|
+
return {
|
|
160
|
+
success: true,
|
|
161
|
+
command: "learn",
|
|
162
|
+
changedFiles: [],
|
|
163
|
+
suggestedDomains: [],
|
|
164
|
+
unmatchedFiles: [],
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
async prune() {
|
|
168
|
+
return {
|
|
169
|
+
success: true,
|
|
170
|
+
command: "prune",
|
|
171
|
+
dryRun: false,
|
|
172
|
+
totalPruned: 0,
|
|
173
|
+
results: [],
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
async doctor() {
|
|
177
|
+
return {
|
|
178
|
+
success: true,
|
|
179
|
+
command: "doctor",
|
|
180
|
+
checks: [],
|
|
181
|
+
summary: {
|
|
182
|
+
pass: 0,
|
|
183
|
+
warn: 0,
|
|
184
|
+
fail: 0,
|
|
185
|
+
totalIssues: 0,
|
|
186
|
+
fixableIssues: 0,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
async ready() {
|
|
191
|
+
return {
|
|
192
|
+
success: true,
|
|
193
|
+
command: "ready",
|
|
194
|
+
count: 0,
|
|
195
|
+
entries: [],
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
async compact() {
|
|
199
|
+
return {
|
|
200
|
+
success: true,
|
|
201
|
+
command: "compact",
|
|
202
|
+
action: "analyze",
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
describe("createMergeResolver", () => {
|
|
209
|
+
describe("Tier 1: Clean merge", () => {
|
|
210
|
+
test("returns success with correct result shape and file content", async () => {
|
|
211
|
+
const repoDir = await createTempGitRepo();
|
|
212
|
+
try {
|
|
213
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
214
|
+
await setupCleanMerge(repoDir, defaultBranch);
|
|
215
|
+
|
|
216
|
+
const entry = makeTestEntry({
|
|
217
|
+
branchName: "feature-branch",
|
|
218
|
+
filesModified: ["src/feature-file.ts"],
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const resolver = createMergeResolver({
|
|
222
|
+
aiResolveEnabled: false,
|
|
223
|
+
reimagineEnabled: false,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
227
|
+
|
|
228
|
+
expect(result.success).toBe(true);
|
|
229
|
+
expect(result.tier).toBe("clean-merge");
|
|
230
|
+
expect(result.entry.status).toBe("merged");
|
|
231
|
+
expect(result.entry.resolvedTier).toBe("clean-merge");
|
|
232
|
+
expect(result.conflictFiles).toEqual([]);
|
|
233
|
+
expect(result.errorMessage).toBeNull();
|
|
234
|
+
|
|
235
|
+
// After merge, the feature file should exist on main
|
|
236
|
+
const file = Bun.file(join(repoDir, "src/feature-file.ts"));
|
|
237
|
+
const content = await file.text();
|
|
238
|
+
expect(content).toBe("feature content\n");
|
|
239
|
+
} finally {
|
|
240
|
+
await cleanupTempDir(repoDir);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("Tier 1: Checkout failure", () => {
|
|
246
|
+
// Both tests only attempt checkout of nonexistent branches -- no repo mutation.
|
|
247
|
+
let repoDir: string;
|
|
248
|
+
|
|
249
|
+
beforeAll(async () => {
|
|
250
|
+
repoDir = await createTempGitRepo();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
afterAll(async () => {
|
|
254
|
+
await cleanupTempDir(repoDir);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("throws MergeError if checkout fails", async () => {
|
|
258
|
+
const entry = makeTestEntry();
|
|
259
|
+
|
|
260
|
+
const resolver = createMergeResolver({
|
|
261
|
+
aiResolveEnabled: false,
|
|
262
|
+
reimagineEnabled: false,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await expect(resolver.resolve(entry, "nonexistent-branch", repoDir)).rejects.toThrow(
|
|
266
|
+
MergeError,
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("MergeError from checkout failure includes branch name", async () => {
|
|
271
|
+
const entry = makeTestEntry();
|
|
272
|
+
|
|
273
|
+
const resolver = createMergeResolver({
|
|
274
|
+
aiResolveEnabled: false,
|
|
275
|
+
reimagineEnabled: false,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
await resolver.resolve(entry, "develop", repoDir);
|
|
280
|
+
expect(true).toBe(false);
|
|
281
|
+
} catch (err: unknown) {
|
|
282
|
+
expect(err).toBeInstanceOf(MergeError);
|
|
283
|
+
const mergeErr = err as MergeError;
|
|
284
|
+
expect(mergeErr.message).toContain("develop");
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("Tier 1 fail -> Tier 2: Auto-resolve", () => {
|
|
290
|
+
test("auto-resolves conflicts keeping incoming changes with correct content", async () => {
|
|
291
|
+
const repoDir = await createTempGitRepo();
|
|
292
|
+
try {
|
|
293
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
294
|
+
await setupContentConflict(repoDir, defaultBranch);
|
|
295
|
+
|
|
296
|
+
const entry = makeTestEntry({
|
|
297
|
+
branchName: "feature-branch",
|
|
298
|
+
filesModified: ["src/test.ts"],
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const resolver = createMergeResolver({
|
|
302
|
+
aiResolveEnabled: false,
|
|
303
|
+
reimagineEnabled: false,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
307
|
+
|
|
308
|
+
expect(result.success).toBe(true);
|
|
309
|
+
expect(result.tier).toBe("auto-resolve");
|
|
310
|
+
expect(result.entry.status).toBe("merged");
|
|
311
|
+
expect(result.entry.resolvedTier).toBe("auto-resolve");
|
|
312
|
+
|
|
313
|
+
// The resolved file should contain the incoming (feature branch) content
|
|
314
|
+
const file = Bun.file(join(repoDir, "src/test.ts"));
|
|
315
|
+
const content = await file.text();
|
|
316
|
+
expect(content).toBe("feature content\n");
|
|
317
|
+
} finally {
|
|
318
|
+
await cleanupTempDir(repoDir);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("Tier 3: AI-resolve", () => {
|
|
324
|
+
// After the first test (aiResolve=false), the resolver aborts the merge and
|
|
325
|
+
// leaves the repo clean. The second test can retry the merge on the same repo.
|
|
326
|
+
let repoDir: string;
|
|
327
|
+
let defaultBranch: string;
|
|
328
|
+
|
|
329
|
+
beforeAll(async () => {
|
|
330
|
+
repoDir = await createTempGitRepo();
|
|
331
|
+
defaultBranch = await getDefaultBranch(repoDir);
|
|
332
|
+
await setupDeleteModifyConflict(repoDir, defaultBranch);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
afterAll(async () => {
|
|
336
|
+
await cleanupTempDir(repoDir);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// This test MUST run first -- it fails to merge and aborts, leaving repo clean
|
|
340
|
+
test("is skipped when aiResolveEnabled is false", async () => {
|
|
341
|
+
const entry = makeTestEntry({
|
|
342
|
+
branchName: "feature-branch",
|
|
343
|
+
filesModified: ["src/test.ts"],
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const resolver = createMergeResolver({
|
|
347
|
+
aiResolveEnabled: false,
|
|
348
|
+
reimagineEnabled: false,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
352
|
+
|
|
353
|
+
expect(result.success).toBe(false);
|
|
354
|
+
expect(result.entry.status).toBe("failed");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// This test runs second -- repo is clean from the abort, same conflict is available
|
|
358
|
+
test("invokes claude when aiResolveEnabled is true and tier 2 fails", async () => {
|
|
359
|
+
// Selective spy: mock only claude, let git commands through.
|
|
360
|
+
const originalSpawn = Bun.spawn;
|
|
361
|
+
let claudeCalled = false;
|
|
362
|
+
|
|
363
|
+
const selectiveMock = (...args: unknown[]): unknown => {
|
|
364
|
+
const cmd = args[0] as string[];
|
|
365
|
+
if (cmd?.[0] === "claude") {
|
|
366
|
+
claudeCalled = true;
|
|
367
|
+
return mockSpawnResult("resolved content from AI\n", "", 0);
|
|
368
|
+
}
|
|
369
|
+
return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const entry = makeTestEntry({
|
|
376
|
+
branchName: "feature-branch",
|
|
377
|
+
filesModified: ["src/test.ts"],
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const resolver = createMergeResolver({
|
|
381
|
+
aiResolveEnabled: true,
|
|
382
|
+
reimagineEnabled: false,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
386
|
+
|
|
387
|
+
expect(claudeCalled).toBe(true);
|
|
388
|
+
expect(result.success).toBe(true);
|
|
389
|
+
expect(result.tier).toBe("ai-resolve");
|
|
390
|
+
expect(result.entry.status).toBe("merged");
|
|
391
|
+
expect(result.entry.resolvedTier).toBe("ai-resolve");
|
|
392
|
+
} finally {
|
|
393
|
+
spawnSpy.mockRestore();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe("Tier 4: Re-imagine", () => {
|
|
399
|
+
// After the first test (reimagine=false), the resolver aborts the merge and
|
|
400
|
+
// leaves the repo clean. The second test can retry the merge on the same repo.
|
|
401
|
+
let repoDir: string;
|
|
402
|
+
let defaultBranch: string;
|
|
403
|
+
|
|
404
|
+
beforeAll(async () => {
|
|
405
|
+
repoDir = await createTempGitRepo();
|
|
406
|
+
defaultBranch = await getDefaultBranch(repoDir);
|
|
407
|
+
await setupReimagineScenario(repoDir, defaultBranch);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
afterAll(async () => {
|
|
411
|
+
await cleanupTempDir(repoDir);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// This test MUST run first -- it fails to merge and aborts, leaving repo clean
|
|
415
|
+
test("is skipped when reimagineEnabled is false", async () => {
|
|
416
|
+
const entry = makeTestEntry({
|
|
417
|
+
branchName: "feature-branch",
|
|
418
|
+
filesModified: ["src/reimagine-target.ts"],
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const resolver = createMergeResolver({
|
|
422
|
+
aiResolveEnabled: false,
|
|
423
|
+
reimagineEnabled: false,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
427
|
+
|
|
428
|
+
expect(result.success).toBe(false);
|
|
429
|
+
expect(result.entry.status).toBe("failed");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// This test runs second -- repo is clean from the abort, same conflict is available
|
|
433
|
+
test("aborts merge and reimplements when reimagineEnabled is true", async () => {
|
|
434
|
+
// Selective spy: mock only claude, let git commands through.
|
|
435
|
+
const originalSpawn = Bun.spawn;
|
|
436
|
+
let claudeCalled = false;
|
|
437
|
+
|
|
438
|
+
const selectiveMock = (...args: unknown[]): unknown => {
|
|
439
|
+
const cmd = args[0] as string[];
|
|
440
|
+
if (cmd?.[0] === "claude") {
|
|
441
|
+
claudeCalled = true;
|
|
442
|
+
return mockSpawnResult("reimagined content\n", "", 0);
|
|
443
|
+
}
|
|
444
|
+
return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const entry = makeTestEntry({
|
|
451
|
+
branchName: "feature-branch",
|
|
452
|
+
filesModified: ["src/reimagine-target.ts"],
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const resolver = createMergeResolver({
|
|
456
|
+
aiResolveEnabled: false,
|
|
457
|
+
reimagineEnabled: true,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
461
|
+
|
|
462
|
+
expect(claudeCalled).toBe(true);
|
|
463
|
+
expect(result.success).toBe(true);
|
|
464
|
+
expect(result.tier).toBe("reimagine");
|
|
465
|
+
expect(result.entry.status).toBe("merged");
|
|
466
|
+
expect(result.entry.resolvedTier).toBe("reimagine");
|
|
467
|
+
|
|
468
|
+
// Verify the reimagined content was written
|
|
469
|
+
const file = Bun.file(join(repoDir, "src/reimagine-target.ts"));
|
|
470
|
+
const content = await file.text();
|
|
471
|
+
expect(content).toBe("reimagined content\n");
|
|
472
|
+
} finally {
|
|
473
|
+
spawnSpy.mockRestore();
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
describe("All tiers fail", () => {
|
|
479
|
+
test("returns failed status and repo is clean when all tiers fail", async () => {
|
|
480
|
+
const repoDir = await createTempGitRepo();
|
|
481
|
+
try {
|
|
482
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
483
|
+
await setupDeleteModifyConflict(repoDir, defaultBranch);
|
|
484
|
+
|
|
485
|
+
const entry = makeTestEntry({
|
|
486
|
+
branchName: "feature-branch",
|
|
487
|
+
filesModified: ["src/test.ts"],
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const resolver = createMergeResolver({
|
|
491
|
+
aiResolveEnabled: false,
|
|
492
|
+
reimagineEnabled: false,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
496
|
+
|
|
497
|
+
expect(result.success).toBe(false);
|
|
498
|
+
expect(result.entry.status).toBe("failed");
|
|
499
|
+
expect(result.entry.resolvedTier).toBeNull();
|
|
500
|
+
expect(result.errorMessage).not.toBeNull();
|
|
501
|
+
expect(result.errorMessage).toContain("failed");
|
|
502
|
+
|
|
503
|
+
// Verify the repo is in a clean state (merge was aborted)
|
|
504
|
+
const status = await runGitInDir(repoDir, ["status", "--porcelain"]);
|
|
505
|
+
expect(status.trim()).toBe("");
|
|
506
|
+
} finally {
|
|
507
|
+
await cleanupTempDir(repoDir);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
describe("result shape", () => {
|
|
513
|
+
let repoDir: string;
|
|
514
|
+
let defaultBranch: string;
|
|
515
|
+
|
|
516
|
+
beforeEach(async () => {
|
|
517
|
+
repoDir = await createTempGitRepo();
|
|
518
|
+
defaultBranch = await getDefaultBranch(repoDir);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
afterEach(async () => {
|
|
522
|
+
await cleanupTempDir(repoDir);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("successful result has correct MergeResult shape", async () => {
|
|
526
|
+
await setupCleanMerge(repoDir, defaultBranch);
|
|
527
|
+
|
|
528
|
+
const resolver = createMergeResolver({
|
|
529
|
+
aiResolveEnabled: false,
|
|
530
|
+
reimagineEnabled: false,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
const result = await resolver.resolve(
|
|
534
|
+
makeTestEntry({
|
|
535
|
+
branchName: "feature-branch",
|
|
536
|
+
filesModified: ["src/feature-file.ts"],
|
|
537
|
+
}),
|
|
538
|
+
defaultBranch,
|
|
539
|
+
repoDir,
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
expect(result).toHaveProperty("entry");
|
|
543
|
+
expect(result).toHaveProperty("success");
|
|
544
|
+
expect(result).toHaveProperty("tier");
|
|
545
|
+
expect(result).toHaveProperty("conflictFiles");
|
|
546
|
+
expect(result).toHaveProperty("errorMessage");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("failed result preserves original entry fields", async () => {
|
|
550
|
+
await setupDeleteModifyConflict(repoDir, defaultBranch, "overstory/my-agent/bead-xyz");
|
|
551
|
+
|
|
552
|
+
const entry = makeTestEntry({
|
|
553
|
+
branchName: "overstory/my-agent/bead-xyz",
|
|
554
|
+
beadId: "bead-xyz",
|
|
555
|
+
agentName: "my-agent",
|
|
556
|
+
filesModified: ["src/test.ts"],
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const resolver = createMergeResolver({
|
|
560
|
+
aiResolveEnabled: false,
|
|
561
|
+
reimagineEnabled: false,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
565
|
+
|
|
566
|
+
expect(result.entry.branchName).toBe("overstory/my-agent/bead-xyz");
|
|
567
|
+
expect(result.entry.beadId).toBe("bead-xyz");
|
|
568
|
+
expect(result.entry.agentName).toBe("my-agent");
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
describe("checkout skip when already on canonical branch", () => {
|
|
573
|
+
test("succeeds when already on canonical branch (skips checkout)", async () => {
|
|
574
|
+
const repoDir = await createTempGitRepo();
|
|
575
|
+
try {
|
|
576
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
577
|
+
await setupCleanMerge(repoDir, defaultBranch);
|
|
578
|
+
|
|
579
|
+
// Verify we're on the default branch
|
|
580
|
+
const branch = await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"]);
|
|
581
|
+
expect(branch.trim()).toBe(defaultBranch);
|
|
582
|
+
|
|
583
|
+
const entry = makeTestEntry({
|
|
584
|
+
branchName: "feature-branch",
|
|
585
|
+
filesModified: ["src/feature-file.ts"],
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const resolver = createMergeResolver({
|
|
589
|
+
aiResolveEnabled: false,
|
|
590
|
+
reimagineEnabled: false,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
594
|
+
expect(result.success).toBe(true);
|
|
595
|
+
expect(result.tier).toBe("clean-merge");
|
|
596
|
+
} finally {
|
|
597
|
+
await cleanupTempDir(repoDir);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("checks out canonical when on a different branch", async () => {
|
|
602
|
+
const repoDir = await createTempGitRepo();
|
|
603
|
+
try {
|
|
604
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
605
|
+
await setupCleanMerge(repoDir, defaultBranch);
|
|
606
|
+
|
|
607
|
+
// Switch to a different branch
|
|
608
|
+
await runGitInDir(repoDir, ["checkout", "-b", "some-other-branch"]);
|
|
609
|
+
const branch = await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"]);
|
|
610
|
+
expect(branch.trim()).toBe("some-other-branch");
|
|
611
|
+
|
|
612
|
+
const entry = makeTestEntry({
|
|
613
|
+
branchName: "feature-branch",
|
|
614
|
+
filesModified: ["src/feature-file.ts"],
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const resolver = createMergeResolver({
|
|
618
|
+
aiResolveEnabled: false,
|
|
619
|
+
reimagineEnabled: false,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
623
|
+
expect(result.success).toBe(true);
|
|
624
|
+
expect(result.tier).toBe("clean-merge");
|
|
625
|
+
} finally {
|
|
626
|
+
await cleanupTempDir(repoDir);
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
describe("looksLikeProse", () => {
|
|
632
|
+
test("detects conversational prose", () => {
|
|
633
|
+
expect(looksLikeProse("I need permission to edit the file")).toBe(true);
|
|
634
|
+
expect(looksLikeProse("Here's the resolved content:")).toBe(true);
|
|
635
|
+
expect(looksLikeProse("Here is the file")).toBe(true);
|
|
636
|
+
expect(looksLikeProse("The conflict can be resolved by")).toBe(true);
|
|
637
|
+
expect(looksLikeProse("Let me resolve this for you")).toBe(true);
|
|
638
|
+
expect(looksLikeProse("Sure, here's the resolved file")).toBe(true);
|
|
639
|
+
expect(looksLikeProse("I cannot access the file")).toBe(true);
|
|
640
|
+
expect(looksLikeProse("I don't have access")).toBe(true);
|
|
641
|
+
expect(looksLikeProse("To resolve this, we need to")).toBe(true);
|
|
642
|
+
expect(looksLikeProse("Looking at the conflict")).toBe(true);
|
|
643
|
+
expect(looksLikeProse("Based on both versions")).toBe(true);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("detects markdown fencing", () => {
|
|
647
|
+
expect(looksLikeProse("```typescript\nconst x = 1;\n```")).toBe(true);
|
|
648
|
+
expect(looksLikeProse("```\nsome code\n```")).toBe(true);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("detects empty output", () => {
|
|
652
|
+
expect(looksLikeProse("")).toBe(true);
|
|
653
|
+
expect(looksLikeProse(" ")).toBe(true);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("accepts valid code", () => {
|
|
657
|
+
expect(looksLikeProse("const x = 1;")).toBe(false);
|
|
658
|
+
expect(looksLikeProse("import { foo } from 'bar';")).toBe(false);
|
|
659
|
+
expect(looksLikeProse("export function resolve() {}")).toBe(false);
|
|
660
|
+
expect(looksLikeProse("function hello() {\n return 'world';\n}")).toBe(false);
|
|
661
|
+
expect(looksLikeProse("// comment\nconst a = 1;")).toBe(false);
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
describe("Tier 3: AI-resolve prose rejection", () => {
|
|
666
|
+
test("rejects prose output and falls through to failure", async () => {
|
|
667
|
+
const repoDir = await createTempGitRepo();
|
|
668
|
+
try {
|
|
669
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
670
|
+
await setupDeleteModifyConflict(repoDir, defaultBranch);
|
|
671
|
+
|
|
672
|
+
const originalSpawn = Bun.spawn;
|
|
673
|
+
const selectiveMock = (...args: unknown[]): unknown => {
|
|
674
|
+
const cmd = args[0] as string[];
|
|
675
|
+
if (cmd?.[0] === "claude") {
|
|
676
|
+
// Return prose instead of code
|
|
677
|
+
return mockSpawnResult(
|
|
678
|
+
"I need permission to edit the file. Here's the resolved content:\n```\nresolved\n```",
|
|
679
|
+
"",
|
|
680
|
+
0,
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
|
|
687
|
+
|
|
688
|
+
try {
|
|
689
|
+
const entry = makeTestEntry({
|
|
690
|
+
branchName: "feature-branch",
|
|
691
|
+
filesModified: ["src/test.ts"],
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
const resolver = createMergeResolver({
|
|
695
|
+
aiResolveEnabled: true,
|
|
696
|
+
reimagineEnabled: false,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
700
|
+
|
|
701
|
+
// Should fail because prose was rejected
|
|
702
|
+
expect(result.success).toBe(false);
|
|
703
|
+
} finally {
|
|
704
|
+
spawnSpy.mockRestore();
|
|
705
|
+
}
|
|
706
|
+
} finally {
|
|
707
|
+
await cleanupTempDir(repoDir);
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
describe("Conflict pattern recording", () => {
|
|
713
|
+
test("no recording when mulchClient is not provided (backward compatible)", async () => {
|
|
714
|
+
const repoDir = await createTempGitRepo();
|
|
715
|
+
try {
|
|
716
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
717
|
+
await setupContentConflict(repoDir, defaultBranch);
|
|
718
|
+
|
|
719
|
+
const entry = makeTestEntry({
|
|
720
|
+
branchName: "feature-branch",
|
|
721
|
+
filesModified: ["src/test.ts"],
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// No mulchClient passed — should work as before
|
|
725
|
+
const resolver = createMergeResolver({
|
|
726
|
+
aiResolveEnabled: false,
|
|
727
|
+
reimagineEnabled: false,
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
731
|
+
|
|
732
|
+
expect(result.success).toBe(true);
|
|
733
|
+
expect(result.tier).toBe("auto-resolve");
|
|
734
|
+
} finally {
|
|
735
|
+
await cleanupTempDir(repoDir);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("records pattern on tier 2 auto-resolve success", async () => {
|
|
740
|
+
const repoDir = await createTempGitRepo();
|
|
741
|
+
try {
|
|
742
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
743
|
+
await setupContentConflict(repoDir, defaultBranch);
|
|
744
|
+
|
|
745
|
+
const entry = makeTestEntry({
|
|
746
|
+
branchName: "feature-branch",
|
|
747
|
+
beadId: "bead-abc-123",
|
|
748
|
+
agentName: "test-builder",
|
|
749
|
+
filesModified: ["src/test.ts"],
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
// Create a mock MulchClient with a spy on record
|
|
753
|
+
const recordCalls: Array<{
|
|
754
|
+
domain: string;
|
|
755
|
+
options: {
|
|
756
|
+
type: string;
|
|
757
|
+
description?: string;
|
|
758
|
+
tags?: string[];
|
|
759
|
+
evidenceBead?: string;
|
|
760
|
+
};
|
|
761
|
+
}> = [];
|
|
762
|
+
|
|
763
|
+
const mockMulchClient = createMockMulchClient(async (domain, options) => {
|
|
764
|
+
recordCalls.push({
|
|
765
|
+
domain,
|
|
766
|
+
options: options as {
|
|
767
|
+
type: string;
|
|
768
|
+
description?: string;
|
|
769
|
+
tags?: string[];
|
|
770
|
+
evidenceBead?: string;
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
const resolver = createMergeResolver({
|
|
776
|
+
aiResolveEnabled: false,
|
|
777
|
+
reimagineEnabled: false,
|
|
778
|
+
mulchClient: mockMulchClient,
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
782
|
+
|
|
783
|
+
expect(result.success).toBe(true);
|
|
784
|
+
expect(result.tier).toBe("auto-resolve");
|
|
785
|
+
|
|
786
|
+
// Verify record was called
|
|
787
|
+
expect(recordCalls.length).toBe(1);
|
|
788
|
+
const call = recordCalls[0];
|
|
789
|
+
expect(call?.domain).toBe("architecture");
|
|
790
|
+
expect(call?.options.type).toBe("pattern");
|
|
791
|
+
expect(call?.options.tags).toContain("merge-conflict");
|
|
792
|
+
expect(call?.options.evidenceBead).toBe("bead-abc-123");
|
|
793
|
+
|
|
794
|
+
// Verify description contains key details
|
|
795
|
+
const desc = call?.options.description ?? "";
|
|
796
|
+
expect(desc).toContain("resolved");
|
|
797
|
+
expect(desc).toContain("auto-resolve");
|
|
798
|
+
expect(desc).toContain("feature-branch");
|
|
799
|
+
expect(desc).toContain("test-builder");
|
|
800
|
+
expect(desc).toContain("src/test.ts");
|
|
801
|
+
} finally {
|
|
802
|
+
await cleanupTempDir(repoDir);
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("records pattern on total failure", async () => {
|
|
807
|
+
const repoDir = await createTempGitRepo();
|
|
808
|
+
try {
|
|
809
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
810
|
+
await setupDeleteModifyConflict(repoDir, defaultBranch);
|
|
811
|
+
|
|
812
|
+
const entry = makeTestEntry({
|
|
813
|
+
branchName: "feature-branch",
|
|
814
|
+
beadId: "bead-fail-456",
|
|
815
|
+
agentName: "test-agent",
|
|
816
|
+
filesModified: ["src/test.ts"],
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
const recordCalls: Array<{
|
|
820
|
+
domain: string;
|
|
821
|
+
options: {
|
|
822
|
+
type: string;
|
|
823
|
+
description?: string;
|
|
824
|
+
tags?: string[];
|
|
825
|
+
evidenceBead?: string;
|
|
826
|
+
};
|
|
827
|
+
}> = [];
|
|
828
|
+
|
|
829
|
+
const mockMulchClient = createMockMulchClient(async (domain, options) => {
|
|
830
|
+
recordCalls.push({
|
|
831
|
+
domain,
|
|
832
|
+
options: options as {
|
|
833
|
+
type: string;
|
|
834
|
+
description?: string;
|
|
835
|
+
tags?: string[];
|
|
836
|
+
evidenceBead?: string;
|
|
837
|
+
},
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
// AI and reimagine disabled — will fail at tier 2
|
|
842
|
+
const resolver = createMergeResolver({
|
|
843
|
+
aiResolveEnabled: false,
|
|
844
|
+
reimagineEnabled: false,
|
|
845
|
+
mulchClient: mockMulchClient,
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
849
|
+
|
|
850
|
+
expect(result.success).toBe(false);
|
|
851
|
+
|
|
852
|
+
// Verify record was called for failure
|
|
853
|
+
expect(recordCalls.length).toBe(1);
|
|
854
|
+
const call = recordCalls[0];
|
|
855
|
+
expect(call?.domain).toBe("architecture");
|
|
856
|
+
expect(call?.options.type).toBe("pattern");
|
|
857
|
+
expect(call?.options.evidenceBead).toBe("bead-fail-456");
|
|
858
|
+
|
|
859
|
+
// Verify description contains "failed" not "resolved"
|
|
860
|
+
const desc = call?.options.description ?? "";
|
|
861
|
+
expect(desc).toContain("failed");
|
|
862
|
+
expect(desc).not.toContain("resolved");
|
|
863
|
+
expect(desc).toContain("auto-resolve"); // last attempted tier
|
|
864
|
+
} finally {
|
|
865
|
+
await cleanupTempDir(repoDir);
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
test("recording failure does not affect merge result (fire-and-forget)", async () => {
|
|
870
|
+
const repoDir = await createTempGitRepo();
|
|
871
|
+
try {
|
|
872
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
873
|
+
await setupContentConflict(repoDir, defaultBranch);
|
|
874
|
+
|
|
875
|
+
const entry = makeTestEntry({
|
|
876
|
+
branchName: "feature-branch",
|
|
877
|
+
filesModified: ["src/test.ts"],
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// Mock mulchClient whose record rejects
|
|
881
|
+
const mockMulchClient = createMockMulchClient(async () => {
|
|
882
|
+
throw new Error("Mulch recording failed!");
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
const resolver = createMergeResolver({
|
|
886
|
+
aiResolveEnabled: false,
|
|
887
|
+
reimagineEnabled: false,
|
|
888
|
+
mulchClient: mockMulchClient,
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
// Should still succeed despite recording failure (fire-and-forget)
|
|
892
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
893
|
+
|
|
894
|
+
expect(result.success).toBe(true);
|
|
895
|
+
expect(result.tier).toBe("auto-resolve");
|
|
896
|
+
} finally {
|
|
897
|
+
await cleanupTempDir(repoDir);
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test("records pattern on tier 3 ai-resolve success", async () => {
|
|
902
|
+
const repoDir = await createTempGitRepo();
|
|
903
|
+
try {
|
|
904
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
905
|
+
await setupDeleteModifyConflict(repoDir, defaultBranch);
|
|
906
|
+
|
|
907
|
+
const entry = makeTestEntry({
|
|
908
|
+
branchName: "feature-branch",
|
|
909
|
+
beadId: "bead-ai-789",
|
|
910
|
+
filesModified: ["src/test.ts"],
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
const recordCalls: Array<{
|
|
914
|
+
domain: string;
|
|
915
|
+
options: {
|
|
916
|
+
type: string;
|
|
917
|
+
description?: string;
|
|
918
|
+
tags?: string[];
|
|
919
|
+
evidenceBead?: string;
|
|
920
|
+
};
|
|
921
|
+
}> = [];
|
|
922
|
+
|
|
923
|
+
const mockMulchClient = createMockMulchClient(async (domain, options) => {
|
|
924
|
+
recordCalls.push({
|
|
925
|
+
domain,
|
|
926
|
+
options: options as {
|
|
927
|
+
type: string;
|
|
928
|
+
description?: string;
|
|
929
|
+
tags?: string[];
|
|
930
|
+
evidenceBead?: string;
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// Mock claude to succeed
|
|
936
|
+
const originalSpawn = Bun.spawn;
|
|
937
|
+
const selectiveMock = (...args: unknown[]): unknown => {
|
|
938
|
+
const cmd = args[0] as string[];
|
|
939
|
+
if (cmd?.[0] === "claude") {
|
|
940
|
+
return mockSpawnResult("resolved content from AI\n", "", 0);
|
|
941
|
+
}
|
|
942
|
+
return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
|
|
946
|
+
|
|
947
|
+
try {
|
|
948
|
+
const resolver = createMergeResolver({
|
|
949
|
+
aiResolveEnabled: true,
|
|
950
|
+
reimagineEnabled: false,
|
|
951
|
+
mulchClient: mockMulchClient,
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
955
|
+
|
|
956
|
+
expect(result.success).toBe(true);
|
|
957
|
+
expect(result.tier).toBe("ai-resolve");
|
|
958
|
+
|
|
959
|
+
// Verify record was called
|
|
960
|
+
expect(recordCalls.length).toBe(1);
|
|
961
|
+
const call = recordCalls[0];
|
|
962
|
+
expect(call?.domain).toBe("architecture");
|
|
963
|
+
expect(call?.options.evidenceBead).toBe("bead-ai-789");
|
|
964
|
+
|
|
965
|
+
const desc = call?.options.description ?? "";
|
|
966
|
+
expect(desc).toContain("resolved");
|
|
967
|
+
expect(desc).toContain("ai-resolve");
|
|
968
|
+
} finally {
|
|
969
|
+
spawnSpy.mockRestore();
|
|
970
|
+
}
|
|
971
|
+
} finally {
|
|
972
|
+
await cleanupTempDir(repoDir);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
test("records pattern on tier 4 reimagine success", async () => {
|
|
977
|
+
const repoDir = await createTempGitRepo();
|
|
978
|
+
try {
|
|
979
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
980
|
+
await setupReimagineScenario(repoDir, defaultBranch);
|
|
981
|
+
|
|
982
|
+
const entry = makeTestEntry({
|
|
983
|
+
branchName: "feature-branch",
|
|
984
|
+
beadId: "bead-reimagine-xyz",
|
|
985
|
+
filesModified: ["src/reimagine-target.ts"],
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
const recordCalls: Array<{
|
|
989
|
+
domain: string;
|
|
990
|
+
options: {
|
|
991
|
+
type: string;
|
|
992
|
+
description?: string;
|
|
993
|
+
tags?: string[];
|
|
994
|
+
evidenceBead?: string;
|
|
995
|
+
};
|
|
996
|
+
}> = [];
|
|
997
|
+
|
|
998
|
+
const mockMulchClient = createMockMulchClient(async (domain, options) => {
|
|
999
|
+
recordCalls.push({
|
|
1000
|
+
domain,
|
|
1001
|
+
options: options as {
|
|
1002
|
+
type: string;
|
|
1003
|
+
description?: string;
|
|
1004
|
+
tags?: string[];
|
|
1005
|
+
evidenceBead?: string;
|
|
1006
|
+
},
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// Mock claude to succeed
|
|
1011
|
+
const originalSpawn = Bun.spawn;
|
|
1012
|
+
const selectiveMock = (...args: unknown[]): unknown => {
|
|
1013
|
+
const cmd = args[0] as string[];
|
|
1014
|
+
if (cmd?.[0] === "claude") {
|
|
1015
|
+
return mockSpawnResult("reimagined content\n", "", 0);
|
|
1016
|
+
}
|
|
1017
|
+
return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
|
|
1021
|
+
|
|
1022
|
+
try {
|
|
1023
|
+
const resolver = createMergeResolver({
|
|
1024
|
+
aiResolveEnabled: false,
|
|
1025
|
+
reimagineEnabled: true,
|
|
1026
|
+
mulchClient: mockMulchClient,
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1030
|
+
|
|
1031
|
+
expect(result.success).toBe(true);
|
|
1032
|
+
expect(result.tier).toBe("reimagine");
|
|
1033
|
+
|
|
1034
|
+
// Verify record was called
|
|
1035
|
+
expect(recordCalls.length).toBe(1);
|
|
1036
|
+
const call = recordCalls[0];
|
|
1037
|
+
expect(call?.domain).toBe("architecture");
|
|
1038
|
+
expect(call?.options.evidenceBead).toBe("bead-reimagine-xyz");
|
|
1039
|
+
|
|
1040
|
+
const desc = call?.options.description ?? "";
|
|
1041
|
+
expect(desc).toContain("resolved");
|
|
1042
|
+
expect(desc).toContain("reimagine");
|
|
1043
|
+
} finally {
|
|
1044
|
+
spawnSpy.mockRestore();
|
|
1045
|
+
}
|
|
1046
|
+
} finally {
|
|
1047
|
+
await cleanupTempDir(repoDir);
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
test("no recording on tier 1 clean merge (no conflict)", async () => {
|
|
1052
|
+
const repoDir = await createTempGitRepo();
|
|
1053
|
+
try {
|
|
1054
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1055
|
+
await setupCleanMerge(repoDir, defaultBranch);
|
|
1056
|
+
|
|
1057
|
+
const entry = makeTestEntry({
|
|
1058
|
+
branchName: "feature-branch",
|
|
1059
|
+
filesModified: ["src/feature-file.ts"],
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
const recordCalls: Array<unknown> = [];
|
|
1063
|
+
|
|
1064
|
+
const mockMulchClient = createMockMulchClient(async (domain, options) => {
|
|
1065
|
+
recordCalls.push({ domain, options });
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
const resolver = createMergeResolver({
|
|
1069
|
+
aiResolveEnabled: false,
|
|
1070
|
+
reimagineEnabled: false,
|
|
1071
|
+
mulchClient: mockMulchClient,
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1075
|
+
|
|
1076
|
+
expect(result.success).toBe(true);
|
|
1077
|
+
expect(result.tier).toBe("clean-merge");
|
|
1078
|
+
|
|
1079
|
+
// No recording on clean merge (no conflict occurred)
|
|
1080
|
+
expect(recordCalls.length).toBe(0);
|
|
1081
|
+
} finally {
|
|
1082
|
+
await cleanupTempDir(repoDir);
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
describe("parseConflictPatterns", () => {
|
|
1088
|
+
test("parses successful resolution pattern", () => {
|
|
1089
|
+
const input =
|
|
1090
|
+
"Merge conflict resolved at tier auto-resolve. Branch: feature-branch. Agent: test-builder. Conflicting files: src/test.ts.";
|
|
1091
|
+
const patterns = parseConflictPatterns(input);
|
|
1092
|
+
expect(patterns.length).toBe(1);
|
|
1093
|
+
expect(patterns[0]?.tier).toBe("auto-resolve");
|
|
1094
|
+
expect(patterns[0]?.success).toBe(true);
|
|
1095
|
+
expect(patterns[0]?.files).toEqual(["src/test.ts"]);
|
|
1096
|
+
expect(patterns[0]?.agent).toBe("test-builder");
|
|
1097
|
+
expect(patterns[0]?.branch).toBe("feature-branch");
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
test("parses failed resolution pattern", () => {
|
|
1101
|
+
const input =
|
|
1102
|
+
"Merge conflict failed at tier ai-resolve. Branch: other-branch. Agent: my-agent. Conflicting files: src/foo.ts, src/bar.ts.";
|
|
1103
|
+
const patterns = parseConflictPatterns(input);
|
|
1104
|
+
expect(patterns.length).toBe(1);
|
|
1105
|
+
expect(patterns[0]?.tier).toBe("ai-resolve");
|
|
1106
|
+
expect(patterns[0]?.success).toBe(false);
|
|
1107
|
+
expect(patterns[0]?.files).toEqual(["src/foo.ts", "src/bar.ts"]);
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
test("parses multiple patterns from search output", () => {
|
|
1111
|
+
const input = [
|
|
1112
|
+
"Some mulch header text",
|
|
1113
|
+
"Merge conflict resolved at tier auto-resolve. Branch: b1. Agent: a1. Conflicting files: src/a.ts.",
|
|
1114
|
+
"Other text in between",
|
|
1115
|
+
"Merge conflict failed at tier reimagine. Branch: b2. Agent: a2. Conflicting files: src/b.ts, src/c.ts.",
|
|
1116
|
+
].join("\n");
|
|
1117
|
+
const patterns = parseConflictPatterns(input);
|
|
1118
|
+
expect(patterns.length).toBe(2);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
test("returns empty array for no matches", () => {
|
|
1122
|
+
expect(parseConflictPatterns("")).toEqual([]);
|
|
1123
|
+
expect(parseConflictPatterns("no patterns here")).toEqual([]);
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
describe("buildConflictHistory", () => {
|
|
1128
|
+
test("returns empty history when no patterns match entry files", () => {
|
|
1129
|
+
const patterns: ParsedConflictPattern[] = [
|
|
1130
|
+
{
|
|
1131
|
+
tier: "auto-resolve",
|
|
1132
|
+
success: true,
|
|
1133
|
+
files: ["unrelated.ts"],
|
|
1134
|
+
agent: "a",
|
|
1135
|
+
branch: "b",
|
|
1136
|
+
},
|
|
1137
|
+
];
|
|
1138
|
+
const history = buildConflictHistory(patterns, ["src/test.ts"]);
|
|
1139
|
+
expect(history.skipTiers).toEqual([]);
|
|
1140
|
+
expect(history.pastResolutions).toEqual([]);
|
|
1141
|
+
expect(history.predictedConflictFiles).toEqual([]);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
test("builds skip tier list when tier fails >= 2 times with no successes", () => {
|
|
1145
|
+
const patterns: ParsedConflictPattern[] = [
|
|
1146
|
+
{
|
|
1147
|
+
tier: "ai-resolve",
|
|
1148
|
+
success: false,
|
|
1149
|
+
files: ["src/test.ts"],
|
|
1150
|
+
agent: "a",
|
|
1151
|
+
branch: "b1",
|
|
1152
|
+
},
|
|
1153
|
+
{
|
|
1154
|
+
tier: "ai-resolve",
|
|
1155
|
+
success: false,
|
|
1156
|
+
files: ["src/test.ts"],
|
|
1157
|
+
agent: "a",
|
|
1158
|
+
branch: "b2",
|
|
1159
|
+
},
|
|
1160
|
+
];
|
|
1161
|
+
const history = buildConflictHistory(patterns, ["src/test.ts"]);
|
|
1162
|
+
expect(history.skipTiers).toContain("ai-resolve");
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
test("does not skip tier if it has any successes", () => {
|
|
1166
|
+
const patterns: ParsedConflictPattern[] = [
|
|
1167
|
+
{
|
|
1168
|
+
tier: "ai-resolve",
|
|
1169
|
+
success: false,
|
|
1170
|
+
files: ["src/test.ts"],
|
|
1171
|
+
agent: "a",
|
|
1172
|
+
branch: "b1",
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
tier: "ai-resolve",
|
|
1176
|
+
success: false,
|
|
1177
|
+
files: ["src/test.ts"],
|
|
1178
|
+
agent: "a",
|
|
1179
|
+
branch: "b2",
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
tier: "ai-resolve",
|
|
1183
|
+
success: true,
|
|
1184
|
+
files: ["src/test.ts"],
|
|
1185
|
+
agent: "a",
|
|
1186
|
+
branch: "b3",
|
|
1187
|
+
},
|
|
1188
|
+
];
|
|
1189
|
+
const history = buildConflictHistory(patterns, ["src/test.ts"]);
|
|
1190
|
+
expect(history.skipTiers).not.toContain("ai-resolve");
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
test("does not skip tier with only 1 failure", () => {
|
|
1194
|
+
const patterns: ParsedConflictPattern[] = [
|
|
1195
|
+
{
|
|
1196
|
+
tier: "reimagine",
|
|
1197
|
+
success: false,
|
|
1198
|
+
files: ["src/test.ts"],
|
|
1199
|
+
agent: "a",
|
|
1200
|
+
branch: "b1",
|
|
1201
|
+
},
|
|
1202
|
+
];
|
|
1203
|
+
const history = buildConflictHistory(patterns, ["src/test.ts"]);
|
|
1204
|
+
expect(history.skipTiers).not.toContain("reimagine");
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
test("collects past successful resolutions", () => {
|
|
1208
|
+
const patterns: ParsedConflictPattern[] = [
|
|
1209
|
+
{
|
|
1210
|
+
tier: "auto-resolve",
|
|
1211
|
+
success: true,
|
|
1212
|
+
files: ["src/test.ts"],
|
|
1213
|
+
agent: "a",
|
|
1214
|
+
branch: "b1",
|
|
1215
|
+
},
|
|
1216
|
+
{
|
|
1217
|
+
tier: "ai-resolve",
|
|
1218
|
+
success: true,
|
|
1219
|
+
files: ["src/test.ts", "src/other.ts"],
|
|
1220
|
+
agent: "b",
|
|
1221
|
+
branch: "b2",
|
|
1222
|
+
},
|
|
1223
|
+
];
|
|
1224
|
+
const history = buildConflictHistory(patterns, ["src/test.ts"]);
|
|
1225
|
+
expect(history.pastResolutions.length).toBe(2);
|
|
1226
|
+
expect(history.pastResolutions[0]).toContain("auto-resolve");
|
|
1227
|
+
expect(history.pastResolutions[1]).toContain("ai-resolve");
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
test("predicts conflict files from historical patterns", () => {
|
|
1231
|
+
const patterns: ParsedConflictPattern[] = [
|
|
1232
|
+
{
|
|
1233
|
+
tier: "auto-resolve",
|
|
1234
|
+
success: true,
|
|
1235
|
+
files: ["src/test.ts", "src/utils.ts"],
|
|
1236
|
+
agent: "a",
|
|
1237
|
+
branch: "b1",
|
|
1238
|
+
},
|
|
1239
|
+
];
|
|
1240
|
+
const history = buildConflictHistory(patterns, ["src/test.ts"]);
|
|
1241
|
+
expect(history.predictedConflictFiles).toContain("src/test.ts");
|
|
1242
|
+
expect(history.predictedConflictFiles).toContain("src/utils.ts");
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
test("returns empty history for empty patterns array", () => {
|
|
1246
|
+
const history = buildConflictHistory([], ["src/test.ts"]);
|
|
1247
|
+
expect(history.skipTiers).toEqual([]);
|
|
1248
|
+
expect(history.pastResolutions).toEqual([]);
|
|
1249
|
+
expect(history.predictedConflictFiles).toEqual([]);
|
|
1250
|
+
});
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
describe("Conflict history tier skipping", () => {
|
|
1254
|
+
test("skips auto-resolve tier when history says it always fails for these files", async () => {
|
|
1255
|
+
const repoDir = await createTempGitRepo();
|
|
1256
|
+
try {
|
|
1257
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1258
|
+
await setupDeleteModifyConflict(repoDir, defaultBranch);
|
|
1259
|
+
|
|
1260
|
+
const entry = makeTestEntry({
|
|
1261
|
+
branchName: "feature-branch",
|
|
1262
|
+
filesModified: ["src/test.ts"],
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
// Mock mulchClient that returns history showing auto-resolve always fails
|
|
1266
|
+
const mockMulchClient = createMockMulchClient();
|
|
1267
|
+
mockMulchClient.search = async () => {
|
|
1268
|
+
return [
|
|
1269
|
+
"Merge conflict failed at tier auto-resolve. Branch: b1. Agent: a1. Conflicting files: src/test.ts.",
|
|
1270
|
+
"Merge conflict failed at tier auto-resolve. Branch: b2. Agent: a2. Conflicting files: src/test.ts.",
|
|
1271
|
+
].join("\n");
|
|
1272
|
+
};
|
|
1273
|
+
|
|
1274
|
+
// AI and reimagine disabled, auto-resolve should be skipped -> fails immediately
|
|
1275
|
+
const resolver = createMergeResolver({
|
|
1276
|
+
aiResolveEnabled: false,
|
|
1277
|
+
reimagineEnabled: false,
|
|
1278
|
+
mulchClient: mockMulchClient,
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1282
|
+
|
|
1283
|
+
// Should fail, and the last tier should NOT be auto-resolve (it was skipped)
|
|
1284
|
+
expect(result.success).toBe(false);
|
|
1285
|
+
} finally {
|
|
1286
|
+
await cleanupTempDir(repoDir);
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
describe("AI-resolve with history context", () => {
|
|
1292
|
+
test("includes historical context in AI prompt when available", async () => {
|
|
1293
|
+
const repoDir = await createTempGitRepo();
|
|
1294
|
+
try {
|
|
1295
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1296
|
+
await setupDeleteModifyConflict(repoDir, defaultBranch);
|
|
1297
|
+
|
|
1298
|
+
const entry = makeTestEntry({
|
|
1299
|
+
branchName: "feature-branch",
|
|
1300
|
+
filesModified: ["src/test.ts"],
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
// Mock mulchClient that returns successful resolution history
|
|
1304
|
+
const mockMulchClient = createMockMulchClient();
|
|
1305
|
+
mockMulchClient.search = async () => {
|
|
1306
|
+
return "Merge conflict resolved at tier ai-resolve. Branch: old-branch. Agent: old-agent. Conflicting files: src/test.ts.";
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
// Capture the prompt sent to claude
|
|
1310
|
+
let capturedPrompt = "";
|
|
1311
|
+
const originalSpawn = Bun.spawn;
|
|
1312
|
+
const selectiveMock = (...args: unknown[]): unknown => {
|
|
1313
|
+
const cmd = args[0] as string[];
|
|
1314
|
+
if (cmd?.[0] === "claude") {
|
|
1315
|
+
capturedPrompt = cmd[3] ?? "";
|
|
1316
|
+
return mockSpawnResult("resolved content\n", "", 0);
|
|
1317
|
+
}
|
|
1318
|
+
return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
|
|
1322
|
+
|
|
1323
|
+
try {
|
|
1324
|
+
const resolver = createMergeResolver({
|
|
1325
|
+
aiResolveEnabled: true,
|
|
1326
|
+
reimagineEnabled: false,
|
|
1327
|
+
mulchClient: mockMulchClient,
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1331
|
+
|
|
1332
|
+
expect(result.success).toBe(true);
|
|
1333
|
+
expect(result.tier).toBe("ai-resolve");
|
|
1334
|
+
// Verify historical context was included in the prompt
|
|
1335
|
+
expect(capturedPrompt).toContain("Historical context");
|
|
1336
|
+
expect(capturedPrompt).toContain("ai-resolve");
|
|
1337
|
+
} finally {
|
|
1338
|
+
spawnSpy.mockRestore();
|
|
1339
|
+
}
|
|
1340
|
+
} finally {
|
|
1341
|
+
await cleanupTempDir(repoDir);
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
});
|