@os-eco/overstory-cli 0.6.4 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/agents/checkpoint.test.ts +2 -2
- package/src/agents/hooks-deployer.test.ts +37 -0
- package/src/agents/hooks-deployer.ts +15 -1
- package/src/agents/identity.test.ts +27 -27
- package/src/agents/identity.ts +10 -10
- package/src/agents/lifecycle.test.ts +6 -6
- package/src/agents/lifecycle.ts +2 -2
- package/src/agents/overlay.test.ts +9 -9
- package/src/agents/overlay.ts +4 -4
- package/src/commands/agents.test.ts +5 -5
- package/src/commands/agents.ts +3 -3
- package/src/commands/clean.test.ts +5 -5
- package/src/commands/coordinator.test.ts +2 -2
- package/src/commands/coordinator.ts +1 -1
- package/src/commands/costs.test.ts +45 -45
- package/src/commands/dashboard.ts +3 -3
- package/src/commands/inspect.test.ts +16 -16
- package/src/commands/inspect.ts +1 -1
- package/src/commands/log.test.ts +21 -21
- package/src/commands/log.ts +7 -7
- package/src/commands/mail.test.ts +5 -5
- package/src/commands/merge.test.ts +8 -8
- package/src/commands/merge.ts +8 -8
- package/src/commands/metrics.test.ts +6 -6
- package/src/commands/metrics.ts +1 -1
- package/src/commands/monitor.ts +1 -1
- package/src/commands/nudge.test.ts +1 -1
- package/src/commands/prime.test.ts +4 -4
- package/src/commands/prime.ts +6 -6
- package/src/commands/run.test.ts +1 -1
- package/src/commands/sling.test.ts +5 -5
- package/src/commands/sling.ts +13 -10
- package/src/commands/spec.test.ts +2 -2
- package/src/commands/spec.ts +8 -8
- package/src/commands/status.test.ts +97 -1
- package/src/commands/status.ts +17 -16
- package/src/commands/stop.test.ts +1 -1
- package/src/commands/supervisor.test.ts +9 -9
- package/src/commands/supervisor.ts +11 -11
- package/src/commands/trace.test.ts +6 -6
- package/src/commands/trace.ts +6 -6
- package/src/commands/worktree.test.ts +205 -29
- package/src/commands/worktree.ts +47 -9
- package/src/doctor/consistency.test.ts +14 -14
- package/src/doctor/merge-queue.test.ts +4 -4
- package/src/e2e/init-sling-lifecycle.test.ts +2 -2
- package/src/errors.ts +1 -1
- package/src/index.ts +3 -3
- package/src/mail/broadcast.test.ts +1 -1
- package/src/mail/client.test.ts +6 -6
- package/src/mail/store.test.ts +3 -3
- package/src/merge/queue.test.ts +12 -12
- package/src/merge/queue.ts +2 -2
- package/src/merge/resolver.test.ts +159 -7
- package/src/merge/resolver.ts +46 -2
- package/src/metrics/store.test.ts +44 -44
- package/src/metrics/store.ts +2 -2
- package/src/metrics/summary.test.ts +35 -35
- package/src/mulch/client.test.ts +1 -1
- package/src/sessions/compat.test.ts +3 -3
- package/src/sessions/compat.ts +1 -1
- package/src/sessions/store.test.ts +4 -4
- package/src/sessions/store.ts +2 -2
- package/src/types.ts +14 -14
- package/src/watchdog/daemon.test.ts +10 -10
- package/src/watchdog/daemon.ts +1 -1
- package/src/watchdog/health.test.ts +1 -1
- package/src/worktree/manager.test.ts +20 -20
- package/src/worktree/manager.ts +120 -4
|
@@ -77,13 +77,13 @@ describe("checkMergeQueue", () => {
|
|
|
77
77
|
const queue = createMergeQueue(dbPath);
|
|
78
78
|
queue.enqueue({
|
|
79
79
|
branchName: "feature/test",
|
|
80
|
-
|
|
80
|
+
taskId: "beads-abc",
|
|
81
81
|
agentName: "test-agent",
|
|
82
82
|
filesModified: ["src/test.ts"],
|
|
83
83
|
});
|
|
84
84
|
queue.enqueue({
|
|
85
85
|
branchName: "feature/another",
|
|
86
|
-
|
|
86
|
+
taskId: "beads-def",
|
|
87
87
|
agentName: "another-agent",
|
|
88
88
|
filesModified: ["src/another.ts"],
|
|
89
89
|
});
|
|
@@ -194,13 +194,13 @@ describe("checkMergeQueue", () => {
|
|
|
194
194
|
const queue = createMergeQueue(dbPath);
|
|
195
195
|
queue.enqueue({
|
|
196
196
|
branchName: "feature/duplicate",
|
|
197
|
-
|
|
197
|
+
taskId: "beads-abc",
|
|
198
198
|
agentName: "test-agent",
|
|
199
199
|
filesModified: ["src/test.ts"],
|
|
200
200
|
});
|
|
201
201
|
queue.enqueue({
|
|
202
202
|
branchName: "feature/duplicate",
|
|
203
|
-
|
|
203
|
+
taskId: "beads-def",
|
|
204
204
|
agentName: "another-agent",
|
|
205
205
|
filesModified: ["src/another.ts"],
|
|
206
206
|
});
|
|
@@ -174,7 +174,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
|
|
|
174
174
|
|
|
175
175
|
const overlayConfig: OverlayConfig = {
|
|
176
176
|
agentName: "test-agent",
|
|
177
|
-
|
|
177
|
+
taskId: "test-bead-001",
|
|
178
178
|
specPath: null,
|
|
179
179
|
branchName: "overstory/test-agent/test-bead-001",
|
|
180
180
|
worktreePath: join(tempDir, ".overstory", "worktrees", "test-agent"),
|
|
@@ -241,7 +241,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
|
|
|
241
241
|
const builderDef = await Bun.file(join(agentDefsDir, "builder.md")).text();
|
|
242
242
|
const overlayConfig: OverlayConfig = {
|
|
243
243
|
agentName: "lifecycle-builder",
|
|
244
|
-
|
|
244
|
+
taskId: "lifecycle-001",
|
|
245
245
|
specPath: join(tempDir, ".overstory", "specs", "lifecycle-001.md"),
|
|
246
246
|
branchName: "overstory/lifecycle-builder/lifecycle-001",
|
|
247
247
|
worktreePath: join(tempDir, ".overstory", "worktrees", "lifecycle-builder"),
|
package/src/errors.ts
CHANGED
|
@@ -152,7 +152,7 @@ export class MergeError extends OverstoryError {
|
|
|
152
152
|
|
|
153
153
|
/**
|
|
154
154
|
* Raised when input validation fails.
|
|
155
|
-
* Examples: invalid agent names, malformed
|
|
155
|
+
* Examples: invalid agent names, malformed taskIds, bad CLI arguments.
|
|
156
156
|
*/
|
|
157
157
|
export class ValidationError extends OverstoryError {
|
|
158
158
|
readonly field: string | null;
|
package/src/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ import { createWorktreeCommand } from "./commands/worktree.ts";
|
|
|
42
42
|
import { OverstoryError, WorktreeError } from "./errors.ts";
|
|
43
43
|
import { setQuiet } from "./logging/color.ts";
|
|
44
44
|
|
|
45
|
-
const VERSION = "0.6.
|
|
45
|
+
const VERSION = "0.6.5";
|
|
46
46
|
|
|
47
47
|
const COMMANDS = [
|
|
48
48
|
"agents",
|
|
@@ -180,8 +180,8 @@ specCmd
|
|
|
180
180
|
.argument("<bead-id>", "Task ID for the spec file")
|
|
181
181
|
.option("--body <content>", "Spec content (or pipe via stdin)")
|
|
182
182
|
.option("--agent <name>", "Agent writing the spec (for attribution)")
|
|
183
|
-
.action(async (
|
|
184
|
-
await specWriteCommand(
|
|
183
|
+
.action(async (taskId, opts) => {
|
|
184
|
+
await specWriteCommand(taskId, opts);
|
|
185
185
|
});
|
|
186
186
|
|
|
187
187
|
program
|
|
@@ -30,7 +30,7 @@ describe("resolveGroupAddress", () => {
|
|
|
30
30
|
capability,
|
|
31
31
|
worktreePath: `/worktrees/${agentName}`,
|
|
32
32
|
branchName: `branch-${agentName}`,
|
|
33
|
-
|
|
33
|
+
taskId: "bead-001",
|
|
34
34
|
tmuxSession: `overstory-test-${agentName}`,
|
|
35
35
|
state: "working",
|
|
36
36
|
pid: 12345,
|
package/src/mail/client.test.ts
CHANGED
|
@@ -598,7 +598,7 @@ describe("createMailClient", () => {
|
|
|
598
598
|
describe("sendProtocol", () => {
|
|
599
599
|
test("sends a worker_done message with serialized payload", () => {
|
|
600
600
|
const payload: WorkerDonePayload = {
|
|
601
|
-
|
|
601
|
+
taskId: "beads-abc",
|
|
602
602
|
branch: "agent/builder-1",
|
|
603
603
|
exitCode: 0,
|
|
604
604
|
filesModified: ["src/foo.ts", "src/bar.ts"],
|
|
@@ -625,7 +625,7 @@ describe("createMailClient", () => {
|
|
|
625
625
|
subject: "Merged",
|
|
626
626
|
body: "Branch merged",
|
|
627
627
|
type: "merged",
|
|
628
|
-
payload: { branch: "agent/b1",
|
|
628
|
+
payload: { branch: "agent/b1", taskId: "beads-xyz", tier: "clean-merge" as const },
|
|
629
629
|
});
|
|
630
630
|
|
|
631
631
|
const msg = store.getById(id);
|
|
@@ -640,7 +640,7 @@ describe("createMailClient", () => {
|
|
|
640
640
|
body: "Build failing",
|
|
641
641
|
type: "escalation",
|
|
642
642
|
priority: "urgent",
|
|
643
|
-
payload: { severity: "critical" as const,
|
|
643
|
+
payload: { severity: "critical" as const, taskId: null, context: "OOM" },
|
|
644
644
|
});
|
|
645
645
|
|
|
646
646
|
const msg = store.getById(id);
|
|
@@ -656,7 +656,7 @@ describe("createMailClient", () => {
|
|
|
656
656
|
type: "assign",
|
|
657
657
|
threadId: "thread-dispatch-1",
|
|
658
658
|
payload: {
|
|
659
|
-
|
|
659
|
+
taskId: "beads-123",
|
|
660
660
|
specPath: ".overstory/specs/beads-123.md",
|
|
661
661
|
workerName: "builder-1",
|
|
662
662
|
branch: "agent/builder-1",
|
|
@@ -671,7 +671,7 @@ describe("createMailClient", () => {
|
|
|
671
671
|
describe("parsePayload", () => {
|
|
672
672
|
test("parses a valid JSON payload", () => {
|
|
673
673
|
const payload: WorkerDonePayload = {
|
|
674
|
-
|
|
674
|
+
taskId: "beads-abc",
|
|
675
675
|
branch: "agent/builder-1",
|
|
676
676
|
exitCode: 0,
|
|
677
677
|
filesModified: ["src/foo.ts"],
|
|
@@ -727,7 +727,7 @@ describe("createMailClient", () => {
|
|
|
727
727
|
describe("checkInject with protocol messages", () => {
|
|
728
728
|
test("includes payload in injection output for protocol messages", () => {
|
|
729
729
|
const payload: WorkerDonePayload = {
|
|
730
|
-
|
|
730
|
+
taskId: "beads-abc",
|
|
731
731
|
branch: "agent/builder-1",
|
|
732
732
|
exitCode: 0,
|
|
733
733
|
filesModified: ["src/foo.ts"],
|
package/src/mail/store.test.ts
CHANGED
|
@@ -638,7 +638,7 @@ describe("createMailStore", () => {
|
|
|
638
638
|
|
|
639
639
|
test("stores JSON payload string", () => {
|
|
640
640
|
const payload = JSON.stringify({
|
|
641
|
-
|
|
641
|
+
taskId: "beads-abc",
|
|
642
642
|
branch: "agent/builder-1",
|
|
643
643
|
exitCode: 0,
|
|
644
644
|
filesModified: ["src/foo.ts"],
|
|
@@ -661,7 +661,7 @@ describe("createMailStore", () => {
|
|
|
661
661
|
});
|
|
662
662
|
|
|
663
663
|
test("returns payload in getUnread results", () => {
|
|
664
|
-
const payload = JSON.stringify({ severity: "critical",
|
|
664
|
+
const payload = JSON.stringify({ severity: "critical", taskId: null, context: "OOM" });
|
|
665
665
|
store.insert({
|
|
666
666
|
id: "msg-escalation",
|
|
667
667
|
from: "builder-1",
|
|
@@ -682,7 +682,7 @@ describe("createMailStore", () => {
|
|
|
682
682
|
test("returns payload in getAll results", () => {
|
|
683
683
|
const payload = JSON.stringify({
|
|
684
684
|
branch: "agent/b1",
|
|
685
|
-
|
|
685
|
+
taskId: "beads-xyz",
|
|
686
686
|
tier: "clean-merge",
|
|
687
687
|
});
|
|
688
688
|
store.insert({
|
package/src/merge/queue.test.ts
CHANGED
|
@@ -23,14 +23,14 @@ describe("createMergeQueue", () => {
|
|
|
23
23
|
function makeInput(
|
|
24
24
|
overrides?: Partial<{
|
|
25
25
|
branchName: string;
|
|
26
|
-
|
|
26
|
+
taskId: string;
|
|
27
27
|
agentName: string;
|
|
28
28
|
filesModified: string[];
|
|
29
29
|
}>,
|
|
30
30
|
) {
|
|
31
31
|
return {
|
|
32
32
|
branchName: overrides?.branchName ?? "overstory/test-agent/bead-123",
|
|
33
|
-
|
|
33
|
+
taskId: overrides?.taskId ?? "bead-123",
|
|
34
34
|
agentName: overrides?.agentName ?? "test-agent",
|
|
35
35
|
filesModified: overrides?.filesModified ?? ["src/test.ts"],
|
|
36
36
|
};
|
|
@@ -52,7 +52,7 @@ describe("createMergeQueue", () => {
|
|
|
52
52
|
const after = new Date().toISOString();
|
|
53
53
|
|
|
54
54
|
expect(entry.branchName).toBe("overstory/test-agent/bead-123");
|
|
55
|
-
expect(entry.
|
|
55
|
+
expect(entry.taskId).toBe("bead-123");
|
|
56
56
|
expect(entry.agentName).toBe("test-agent");
|
|
57
57
|
expect(entry.filesModified).toEqual(["src/test.ts"]);
|
|
58
58
|
expect(entry.enqueuedAt).toBeDefined();
|
|
@@ -65,7 +65,7 @@ describe("createMergeQueue", () => {
|
|
|
65
65
|
const queue = createMergeQueue(queuePath);
|
|
66
66
|
const input = makeInput({
|
|
67
67
|
branchName: "overstory/builder-1/bead-xyz",
|
|
68
|
-
|
|
68
|
+
taskId: "bead-xyz",
|
|
69
69
|
agentName: "builder-1",
|
|
70
70
|
filesModified: ["src/a.ts", "src/b.ts"],
|
|
71
71
|
});
|
|
@@ -73,7 +73,7 @@ describe("createMergeQueue", () => {
|
|
|
73
73
|
const entry = queue.enqueue(input);
|
|
74
74
|
|
|
75
75
|
expect(entry.branchName).toBe("overstory/builder-1/bead-xyz");
|
|
76
|
-
expect(entry.
|
|
76
|
+
expect(entry.taskId).toBe("bead-xyz");
|
|
77
77
|
expect(entry.agentName).toBe("builder-1");
|
|
78
78
|
expect(entry.filesModified).toEqual(["src/a.ts", "src/b.ts"]);
|
|
79
79
|
});
|
|
@@ -82,8 +82,8 @@ describe("createMergeQueue", () => {
|
|
|
82
82
|
describe("dequeue", () => {
|
|
83
83
|
test("returns first pending entry (FIFO)", () => {
|
|
84
84
|
const queue = createMergeQueue(queuePath);
|
|
85
|
-
queue.enqueue(makeInput({ branchName: "branch-a",
|
|
86
|
-
queue.enqueue(makeInput({ branchName: "branch-b",
|
|
85
|
+
queue.enqueue(makeInput({ branchName: "branch-a", taskId: "bead-a" }));
|
|
86
|
+
queue.enqueue(makeInput({ branchName: "branch-b", taskId: "bead-b" }));
|
|
87
87
|
|
|
88
88
|
const dequeued = queue.dequeue();
|
|
89
89
|
|
|
@@ -363,17 +363,17 @@ describe("createMergeQueue", () => {
|
|
|
363
363
|
const entries = queue.list();
|
|
364
364
|
|
|
365
365
|
expect(entries).toHaveLength(1);
|
|
366
|
-
expect(entries[0]?.
|
|
366
|
+
expect(entries[0]?.taskId).toBe("bead-1");
|
|
367
367
|
expect(entries[0]?.branchName).toBe("overstory/test/bead-1");
|
|
368
368
|
|
|
369
369
|
// New inserts should also work
|
|
370
370
|
const newEntry = queue.enqueue({
|
|
371
371
|
branchName: "overstory/test/bead-2",
|
|
372
|
-
|
|
372
|
+
taskId: "bead-2",
|
|
373
373
|
agentName: "test",
|
|
374
374
|
filesModified: ["src/b.ts"],
|
|
375
375
|
});
|
|
376
|
-
expect(newEntry.
|
|
376
|
+
expect(newEntry.taskId).toBe("bead-2");
|
|
377
377
|
|
|
378
378
|
queue.close();
|
|
379
379
|
});
|
|
@@ -383,7 +383,7 @@ describe("createMergeQueue", () => {
|
|
|
383
383
|
const queue1 = createMergeQueue(queuePath);
|
|
384
384
|
queue1.enqueue({
|
|
385
385
|
branchName: "overstory/test/bead-1",
|
|
386
|
-
|
|
386
|
+
taskId: "bead-1",
|
|
387
387
|
agentName: "test",
|
|
388
388
|
filesModified: [],
|
|
389
389
|
});
|
|
@@ -394,7 +394,7 @@ describe("createMergeQueue", () => {
|
|
|
394
394
|
const entries = queue2.list();
|
|
395
395
|
|
|
396
396
|
expect(entries).toHaveLength(1);
|
|
397
|
-
expect(entries[0]?.
|
|
397
|
+
expect(entries[0]?.taskId).toBe("bead-1");
|
|
398
398
|
queue2.close();
|
|
399
399
|
});
|
|
400
400
|
});
|
package/src/merge/queue.ts
CHANGED
|
@@ -74,7 +74,7 @@ function rowToEntry(row: MergeQueueRow): MergeEntry {
|
|
|
74
74
|
|
|
75
75
|
return {
|
|
76
76
|
branchName: row.branch_name,
|
|
77
|
-
|
|
77
|
+
taskId: row.task_id,
|
|
78
78
|
agentName: row.agent_name,
|
|
79
79
|
filesModified,
|
|
80
80
|
enqueuedAt: row.enqueued_at,
|
|
@@ -172,7 +172,7 @@ export function createMergeQueue(dbPath: string): MergeQueue {
|
|
|
172
172
|
|
|
173
173
|
const row = insertStmt.get({
|
|
174
174
|
$branch_name: input.branchName,
|
|
175
|
-
$task_id: input.
|
|
175
|
+
$task_id: input.taskId,
|
|
176
176
|
$agent_name: input.agentName,
|
|
177
177
|
$files_modified: filesModifiedJson,
|
|
178
178
|
$enqueued_at: enqueuedAt,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
createMergeResolver,
|
|
25
25
|
looksLikeProse,
|
|
26
26
|
parseConflictPatterns,
|
|
27
|
+
resolveConflictsUnion,
|
|
27
28
|
} from "./resolver.ts";
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -53,7 +54,7 @@ function mockSpawnResult(
|
|
|
53
54
|
function makeTestEntry(overrides?: Partial<MergeEntry>): MergeEntry {
|
|
54
55
|
return {
|
|
55
56
|
branchName: overrides?.branchName ?? "feature-branch",
|
|
56
|
-
|
|
57
|
+
taskId: overrides?.taskId ?? "bead-123",
|
|
57
58
|
agentName: overrides?.agentName ?? "test-agent",
|
|
58
59
|
filesModified: overrides?.filesModified ?? ["src/test.ts"],
|
|
59
60
|
enqueuedAt: overrides?.enqueuedAt ?? new Date().toISOString(),
|
|
@@ -551,7 +552,7 @@ describe("createMergeResolver", () => {
|
|
|
551
552
|
|
|
552
553
|
const entry = makeTestEntry({
|
|
553
554
|
branchName: "overstory/my-agent/bead-xyz",
|
|
554
|
-
|
|
555
|
+
taskId: "bead-xyz",
|
|
555
556
|
agentName: "my-agent",
|
|
556
557
|
filesModified: ["src/test.ts"],
|
|
557
558
|
});
|
|
@@ -564,7 +565,7 @@ describe("createMergeResolver", () => {
|
|
|
564
565
|
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
565
566
|
|
|
566
567
|
expect(result.entry.branchName).toBe("overstory/my-agent/bead-xyz");
|
|
567
|
-
expect(result.entry.
|
|
568
|
+
expect(result.entry.taskId).toBe("bead-xyz");
|
|
568
569
|
expect(result.entry.agentName).toBe("my-agent");
|
|
569
570
|
});
|
|
570
571
|
});
|
|
@@ -744,7 +745,7 @@ describe("createMergeResolver", () => {
|
|
|
744
745
|
|
|
745
746
|
const entry = makeTestEntry({
|
|
746
747
|
branchName: "feature-branch",
|
|
747
|
-
|
|
748
|
+
taskId: "bead-abc-123",
|
|
748
749
|
agentName: "test-builder",
|
|
749
750
|
filesModified: ["src/test.ts"],
|
|
750
751
|
});
|
|
@@ -811,7 +812,7 @@ describe("createMergeResolver", () => {
|
|
|
811
812
|
|
|
812
813
|
const entry = makeTestEntry({
|
|
813
814
|
branchName: "feature-branch",
|
|
814
|
-
|
|
815
|
+
taskId: "bead-fail-456",
|
|
815
816
|
agentName: "test-agent",
|
|
816
817
|
filesModified: ["src/test.ts"],
|
|
817
818
|
});
|
|
@@ -906,7 +907,7 @@ describe("createMergeResolver", () => {
|
|
|
906
907
|
|
|
907
908
|
const entry = makeTestEntry({
|
|
908
909
|
branchName: "feature-branch",
|
|
909
|
-
|
|
910
|
+
taskId: "bead-ai-789",
|
|
910
911
|
filesModified: ["src/test.ts"],
|
|
911
912
|
});
|
|
912
913
|
|
|
@@ -981,7 +982,7 @@ describe("createMergeResolver", () => {
|
|
|
981
982
|
|
|
982
983
|
const entry = makeTestEntry({
|
|
983
984
|
branchName: "feature-branch",
|
|
984
|
-
|
|
985
|
+
taskId: "bead-reimagine-xyz",
|
|
985
986
|
filesModified: ["src/reimagine-target.ts"],
|
|
986
987
|
});
|
|
987
988
|
|
|
@@ -1288,6 +1289,157 @@ describe("createMergeResolver", () => {
|
|
|
1288
1289
|
});
|
|
1289
1290
|
});
|
|
1290
1291
|
|
|
1292
|
+
describe("resolveConflictsUnion", () => {
|
|
1293
|
+
test("returns null when no conflict markers are present", () => {
|
|
1294
|
+
expect(resolveConflictsUnion("no conflicts here\n")).toBeNull();
|
|
1295
|
+
expect(resolveConflictsUnion("")).toBeNull();
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test("keeps both canonical and incoming content for a single conflict", () => {
|
|
1299
|
+
const content = [
|
|
1300
|
+
"<<<<<<< HEAD\n",
|
|
1301
|
+
'{"id":"a"}\n',
|
|
1302
|
+
'{"id":"c"}\n',
|
|
1303
|
+
"=======\n",
|
|
1304
|
+
'{"id":"a"}\n',
|
|
1305
|
+
'{"id":"b"}\n',
|
|
1306
|
+
">>>>>>> feature-branch\n",
|
|
1307
|
+
].join("");
|
|
1308
|
+
const result = resolveConflictsUnion(content);
|
|
1309
|
+
expect(result).not.toBeNull();
|
|
1310
|
+
expect(result).toContain('{"id":"a"}\n{"id":"c"}\n');
|
|
1311
|
+
expect(result).toContain('{"id":"a"}\n{"id":"b"}\n');
|
|
1312
|
+
// No conflict markers remain
|
|
1313
|
+
expect(result).not.toContain("<<<<<<<");
|
|
1314
|
+
expect(result).not.toContain("=======");
|
|
1315
|
+
expect(result).not.toContain(">>>>>>>");
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
test("resolves multiple conflict blocks with union strategy", () => {
|
|
1319
|
+
const block = (canonical: string, incoming: string): string =>
|
|
1320
|
+
`<<<<<<< HEAD\n${canonical}\n=======\n${incoming}\n>>>>>>> branch\n`;
|
|
1321
|
+
const content = `${block("line-a\n", "line-b\n")}middle\n${block("line-c\n", "line-d\n")}`;
|
|
1322
|
+
const result = resolveConflictsUnion(content);
|
|
1323
|
+
expect(result).not.toBeNull();
|
|
1324
|
+
expect(result).toContain("line-a\n");
|
|
1325
|
+
expect(result).toContain("line-b\n");
|
|
1326
|
+
expect(result).toContain("middle\n");
|
|
1327
|
+
expect(result).toContain("line-c\n");
|
|
1328
|
+
expect(result).toContain("line-d\n");
|
|
1329
|
+
expect(result).not.toContain("<<<<<<<");
|
|
1330
|
+
});
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
describe("merge=union gitattribute support", () => {
|
|
1334
|
+
test("union strategy preserves lines from both sides (git handles cleanly or via auto-resolve)", async () => {
|
|
1335
|
+
const repoDir = await createTempGitRepo();
|
|
1336
|
+
try {
|
|
1337
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1338
|
+
|
|
1339
|
+
// Set up .gitattributes with merge=union for *.jsonl files so that
|
|
1340
|
+
// both git's built-in union driver AND overstory's Tier 2 union path
|
|
1341
|
+
// are configured to keep all lines from both sides.
|
|
1342
|
+
await commitFile(repoDir, ".gitattributes", "*.jsonl merge=union\n");
|
|
1343
|
+
// Common ancestor: one line
|
|
1344
|
+
await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n');
|
|
1345
|
+
|
|
1346
|
+
// Feature branch: adds line b
|
|
1347
|
+
await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
|
|
1348
|
+
await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"b"}\n');
|
|
1349
|
+
|
|
1350
|
+
// Back to main: adds line c (diverges from ancestor)
|
|
1351
|
+
await runGitInDir(repoDir, ["checkout", defaultBranch]);
|
|
1352
|
+
await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"c"}\n');
|
|
1353
|
+
|
|
1354
|
+
const entry = makeTestEntry({
|
|
1355
|
+
branchName: "feature-branch",
|
|
1356
|
+
filesModified: ["data.jsonl"],
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
const resolver = createMergeResolver({
|
|
1360
|
+
aiResolveEnabled: false,
|
|
1361
|
+
reimagineEnabled: false,
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1365
|
+
|
|
1366
|
+
// With merge=union, git either resolves cleanly (Tier 1) or
|
|
1367
|
+
// overstory's union path handles it (Tier 2). Either way, success
|
|
1368
|
+
// and both sides' content must be preserved.
|
|
1369
|
+
expect(result.success).toBe(true);
|
|
1370
|
+
expect(result.entry.status).toBe("merged");
|
|
1371
|
+
|
|
1372
|
+
// Both sides' lines must be present — no lines dropped
|
|
1373
|
+
const file = Bun.file(join(repoDir, "data.jsonl"));
|
|
1374
|
+
const content = await file.text();
|
|
1375
|
+
expect(content).toContain('{"id":"a"}');
|
|
1376
|
+
expect(content).toContain('{"id":"b"}');
|
|
1377
|
+
expect(content).toContain('{"id":"c"}');
|
|
1378
|
+
} finally {
|
|
1379
|
+
await cleanupTempDir(repoDir);
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
test("Tier 2 union auto-resolve keeps both sides when git produces conflict markers", async () => {
|
|
1384
|
+
// This test verifies the Tier 2 code path: when git produces conflict
|
|
1385
|
+
// markers for a file that has merge=union set in .gitattributes,
|
|
1386
|
+
// overstory resolves it by keeping both canonical and incoming content.
|
|
1387
|
+
// We produce conflict markers by doing a content conflict on a file whose
|
|
1388
|
+
// attribute is set to merge=union AFTER the conflict state exists, then
|
|
1389
|
+
// run only the auto-resolve path via a standalone resolver call.
|
|
1390
|
+
//
|
|
1391
|
+
// To force conflict markers despite merge=union: we DON'T commit
|
|
1392
|
+
// .gitattributes before the merge, so git uses the default driver and
|
|
1393
|
+
// produces conflict markers. Then we write .gitattributes to the working
|
|
1394
|
+
// tree (not committed) so git check-attr sees it and our code detects union.
|
|
1395
|
+
const repoDir = await createTempGitRepo();
|
|
1396
|
+
try {
|
|
1397
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1398
|
+
|
|
1399
|
+
// Common ancestor
|
|
1400
|
+
await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n');
|
|
1401
|
+
|
|
1402
|
+
// Feature branch: adds line b (also appends to same position as main)
|
|
1403
|
+
await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
|
|
1404
|
+
await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"b"}\n');
|
|
1405
|
+
|
|
1406
|
+
// Back to main: adds line c — diverges from ancestor at same position
|
|
1407
|
+
await runGitInDir(repoDir, ["checkout", defaultBranch]);
|
|
1408
|
+
await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"c"}\n');
|
|
1409
|
+
|
|
1410
|
+
// Now WRITE .gitattributes to working tree (not committed).
|
|
1411
|
+
// git check-attr reads from the working tree during Tier 2.
|
|
1412
|
+
await Bun.write(`${repoDir}/.gitattributes`, "*.jsonl merge=union\n");
|
|
1413
|
+
|
|
1414
|
+
// Attempt merge — git uses default driver (no committed .gitattributes)
|
|
1415
|
+
// so it WILL produce conflict markers if branches diverge at same position.
|
|
1416
|
+
// (If git resolves cleanly, the test still passes because content is preserved.)
|
|
1417
|
+
const entry = makeTestEntry({
|
|
1418
|
+
branchName: "feature-branch",
|
|
1419
|
+
filesModified: ["data.jsonl"],
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
const resolver = createMergeResolver({
|
|
1423
|
+
aiResolveEnabled: false,
|
|
1424
|
+
reimagineEnabled: false,
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1428
|
+
|
|
1429
|
+
expect(result.success).toBe(true);
|
|
1430
|
+
expect(result.entry.status).toBe("merged");
|
|
1431
|
+
|
|
1432
|
+
const file = Bun.file(join(repoDir, "data.jsonl"));
|
|
1433
|
+
const content = await file.text();
|
|
1434
|
+
expect(content).toContain('{"id":"a"}');
|
|
1435
|
+
expect(content).toContain('{"id":"b"}');
|
|
1436
|
+
expect(content).toContain('{"id":"c"}');
|
|
1437
|
+
} finally {
|
|
1438
|
+
await cleanupTempDir(repoDir);
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1291
1443
|
describe("AI-resolve with history context", () => {
|
|
1292
1444
|
test("includes historical context in AI prompt when available", async () => {
|
|
1293
1445
|
const repoDir = await createTempGitRepo();
|
package/src/merge/resolver.ts
CHANGED
|
@@ -89,6 +89,47 @@ function resolveConflictsKeepIncoming(content: string): string | null {
|
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Parse conflict markers in file content and keep ALL lines from both sides.
|
|
94
|
+
* Used when the file has `merge=union` gitattribute — dedup-on-read handles duplicates.
|
|
95
|
+
*
|
|
96
|
+
* A conflict block looks like:
|
|
97
|
+
* ```
|
|
98
|
+
* <<<<<<< HEAD
|
|
99
|
+
* canonical content
|
|
100
|
+
* =======
|
|
101
|
+
* incoming content
|
|
102
|
+
* >>>>>>> branch
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* This function replaces each conflict block with canonical + incoming content concatenated.
|
|
106
|
+
* Returns the resolved content, or null if no conflict markers were found.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveConflictsUnion(content: string): string | null {
|
|
109
|
+
const conflictPattern = /^<{7} .+\n([\s\S]*?)^={7}\n([\s\S]*?)^>{7} .+\n?/gm;
|
|
110
|
+
|
|
111
|
+
if (!conflictPattern.test(content)) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Reset regex lastIndex after test()
|
|
116
|
+
conflictPattern.lastIndex = 0;
|
|
117
|
+
|
|
118
|
+
return content.replace(conflictPattern, (_match, canonical: string, incoming: string) => {
|
|
119
|
+
return canonical + incoming;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if a file has the `merge=union` gitattribute set.
|
|
125
|
+
* Returns true if `git check-attr merge -- <file>` ends with ": merge: union".
|
|
126
|
+
*/
|
|
127
|
+
async function checkMergeUnion(repoRoot: string, filePath: string): Promise<boolean> {
|
|
128
|
+
const { stdout, exitCode } = await runGit(repoRoot, ["check-attr", "merge", "--", filePath]);
|
|
129
|
+
if (exitCode !== 0) return false;
|
|
130
|
+
return stdout.trim().endsWith(": merge: union");
|
|
131
|
+
}
|
|
132
|
+
|
|
92
133
|
/**
|
|
93
134
|
* Read a file's content using Bun.file().
|
|
94
135
|
*/
|
|
@@ -138,7 +179,10 @@ async function tryAutoResolve(
|
|
|
138
179
|
|
|
139
180
|
try {
|
|
140
181
|
const content = await readFile(filePath);
|
|
141
|
-
const
|
|
182
|
+
const isUnion = await checkMergeUnion(repoRoot, file);
|
|
183
|
+
const resolved = isUnion
|
|
184
|
+
? resolveConflictsUnion(content)
|
|
185
|
+
: resolveConflictsKeepIncoming(content);
|
|
142
186
|
|
|
143
187
|
if (resolved === null) {
|
|
144
188
|
// No conflict markers found (shouldn't happen but be defensive)
|
|
@@ -496,7 +540,7 @@ function recordConflictPattern(
|
|
|
496
540
|
type: "pattern",
|
|
497
541
|
description,
|
|
498
542
|
tags: ["merge-conflict"],
|
|
499
|
-
evidenceBead: entry.
|
|
543
|
+
evidenceBead: entry.taskId,
|
|
500
544
|
})
|
|
501
545
|
.catch(() => {});
|
|
502
546
|
}
|