@os-eco/overstory-cli 0.9.4 → 0.11.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 +50 -19
- package/agents/builder.md +19 -9
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +204 -87
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +219 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/overlay.test.ts +60 -4
- package/src/agents/overlay.ts +63 -8
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/commands/agents.ts +9 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +254 -0
- package/src/commands/coordinator.ts +273 -8
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +14 -4
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +187 -11
- package/src/commands/log.ts +171 -71
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +230 -1
- package/src/commands/merge.ts +68 -12
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +177 -1
- package/src/commands/sling.ts +243 -71
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +255 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +57 -6
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.ts +29 -0
- package/src/logging/theme.ts +4 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/resolver.ts +1 -1
- package/src/mulch/client.ts +3 -3
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +657 -29
- package/src/sessions/store.ts +286 -23
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +107 -2
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1607 -376
- package/src/watchdog/daemon.ts +462 -88
- package/src/watchdog/health.test.ts +282 -0
- package/src/watchdog/health.ts +126 -27
- package/src/worktree/manager.test.ts +218 -1
- package/src/worktree/manager.ts +55 -0
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +28 -0
- package/src/worktree/tmux.ts +27 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +5 -2
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
2
3
|
import { mkdir } from "node:fs/promises";
|
|
3
4
|
import { join } from "node:path";
|
|
4
|
-
import { ValidationError } from "../errors.ts";
|
|
5
|
+
import { MergeError, ValidationError } from "../errors.ts";
|
|
6
|
+
import { mergeLockPath } from "../merge/lock.ts";
|
|
5
7
|
import { createMergeQueue } from "../merge/queue.ts";
|
|
6
8
|
import {
|
|
7
9
|
cleanupTempDir,
|
|
@@ -597,6 +599,233 @@ merge:
|
|
|
597
599
|
});
|
|
598
600
|
});
|
|
599
601
|
|
|
602
|
+
describe("concurrent-merge lock", () => {
|
|
603
|
+
test("refuses to start when another live ov merge holds the lock for the same target", async () => {
|
|
604
|
+
await setupProject(repoDir, defaultBranch);
|
|
605
|
+
const branchName = "overstory/builder/bead-lock-1";
|
|
606
|
+
await createCleanFeatureBranch(repoDir, branchName);
|
|
607
|
+
|
|
608
|
+
// Simulate another live ov merge by writing a lock file with this
|
|
609
|
+
// process's own PID — it is guaranteed alive, so the lock-holder check
|
|
610
|
+
// must treat it as an in-flight merge.
|
|
611
|
+
const lockPath = mergeLockPath(join(repoDir, ".overstory"), defaultBranch);
|
|
612
|
+
writeFileSync(
|
|
613
|
+
lockPath,
|
|
614
|
+
JSON.stringify({
|
|
615
|
+
pid: process.pid,
|
|
616
|
+
acquiredAt: new Date().toISOString(),
|
|
617
|
+
targetBranch: defaultBranch,
|
|
618
|
+
}),
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
622
|
+
process.stdout.write = (): boolean => true;
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
await mergeCommand({ branch: branchName });
|
|
626
|
+
expect(true).toBe(false); // should not reach here
|
|
627
|
+
} catch (err: unknown) {
|
|
628
|
+
expect(err).toBeInstanceOf(MergeError);
|
|
629
|
+
const msg = (err as MergeError).message;
|
|
630
|
+
expect(msg).toContain("Another ov merge is already running");
|
|
631
|
+
expect(msg).toContain(defaultBranch);
|
|
632
|
+
} finally {
|
|
633
|
+
process.stdout.write = originalWrite;
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("releases the lock after a successful merge so a second run can proceed", async () => {
|
|
638
|
+
await setupProject(repoDir, defaultBranch);
|
|
639
|
+
const branchName = "overstory/builder/bead-lock-2";
|
|
640
|
+
await createCleanFeatureBranch(repoDir, branchName);
|
|
641
|
+
|
|
642
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
643
|
+
process.stdout.write = (): boolean => true;
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
await mergeCommand({ branch: branchName, json: true });
|
|
647
|
+
} finally {
|
|
648
|
+
process.stdout.write = originalWrite;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// After release, a fresh `ov merge --dry-run` for an unrelated branch
|
|
652
|
+
// (which doesn't take a lock) should still see no leftover lock file.
|
|
653
|
+
const lockPath = mergeLockPath(join(repoDir, ".overstory"), defaultBranch);
|
|
654
|
+
expect(await Bun.file(lockPath).exists()).toBe(false);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("--dry-run does not acquire the lock", async () => {
|
|
658
|
+
await setupProject(repoDir, defaultBranch);
|
|
659
|
+
const branchName = "overstory/builder/bead-lock-dry";
|
|
660
|
+
await createCleanFeatureBranch(repoDir, branchName);
|
|
661
|
+
|
|
662
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
663
|
+
process.stdout.write = (): boolean => true;
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
await mergeCommand({ branch: branchName, dryRun: true, json: true });
|
|
667
|
+
} finally {
|
|
668
|
+
process.stdout.write = originalWrite;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const lockPath = mergeLockPath(join(repoDir, ".overstory"), defaultBranch);
|
|
672
|
+
expect(await Bun.file(lockPath).exists()).toBe(false);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("steals a stale lock (dead PID) and proceeds", async () => {
|
|
676
|
+
await setupProject(repoDir, defaultBranch);
|
|
677
|
+
const branchName = "overstory/builder/bead-lock-stale";
|
|
678
|
+
await createCleanFeatureBranch(repoDir, branchName);
|
|
679
|
+
|
|
680
|
+
// PID 2147483647 (INT_MAX) is virtually never assigned — treat as dead.
|
|
681
|
+
const lockPath = mergeLockPath(join(repoDir, ".overstory"), defaultBranch);
|
|
682
|
+
writeFileSync(
|
|
683
|
+
lockPath,
|
|
684
|
+
JSON.stringify({
|
|
685
|
+
pid: 2147483647,
|
|
686
|
+
acquiredAt: new Date(Date.now() - 10 * 60_000).toISOString(),
|
|
687
|
+
targetBranch: defaultBranch,
|
|
688
|
+
}),
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
let output = "";
|
|
692
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
693
|
+
process.stdout.write = (chunk: unknown): boolean => {
|
|
694
|
+
output += String(chunk);
|
|
695
|
+
return true;
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
try {
|
|
699
|
+
await mergeCommand({ branch: branchName, json: true });
|
|
700
|
+
} finally {
|
|
701
|
+
process.stdout.write = originalWrite;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const parsed = JSON.parse(output);
|
|
705
|
+
expect(parsed.success).toBe(true);
|
|
706
|
+
// Lock should be released after success.
|
|
707
|
+
expect(await Bun.file(lockPath).exists()).toBe(false);
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
describe("--dry-run conflict prediction", () => {
|
|
712
|
+
test("--dry-run --json includes prediction.wouldRequireAgent for contentful-canonical conflict", async () => {
|
|
713
|
+
await setupProject(repoDir, defaultBranch);
|
|
714
|
+
|
|
715
|
+
// Build a real contentful-canonical conflict on src/shared.ts.
|
|
716
|
+
await commitFile(repoDir, "src/shared.ts", "shared base\n");
|
|
717
|
+
const branchName = "overstory/builder/bead-predict-1";
|
|
718
|
+
await runGitInDir(repoDir, ["checkout", "-b", branchName]);
|
|
719
|
+
await commitFile(repoDir, "src/shared.ts", "feature side\n");
|
|
720
|
+
await runGitInDir(repoDir, ["checkout", defaultBranch]);
|
|
721
|
+
await commitFile(repoDir, "src/shared.ts", "main side\n");
|
|
722
|
+
|
|
723
|
+
let output = "";
|
|
724
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
725
|
+
process.stdout.write = (chunk: unknown): boolean => {
|
|
726
|
+
output += String(chunk);
|
|
727
|
+
return true;
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
await mergeCommand({ branch: branchName, dryRun: true, json: true });
|
|
732
|
+
} finally {
|
|
733
|
+
process.stdout.write = originalWrite;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const parsed = JSON.parse(output);
|
|
737
|
+
expect(parsed.prediction).toBeDefined();
|
|
738
|
+
expect(parsed.prediction.wouldRequireAgent).toBe(true);
|
|
739
|
+
expect(parsed.prediction.predictedTier).toBe("ai-resolve");
|
|
740
|
+
expect(parsed.prediction.conflictFiles).toContain("src/shared.ts");
|
|
741
|
+
expect(typeof parsed.prediction.reason).toBe("string");
|
|
742
|
+
|
|
743
|
+
// Existing dry-run fields must still be present.
|
|
744
|
+
expect(parsed.branchName).toBe(branchName);
|
|
745
|
+
expect(parsed.status).toBe("pending");
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("--dry-run --json reports clean-merge prediction for non-conflicting branch", async () => {
|
|
749
|
+
await setupProject(repoDir, defaultBranch);
|
|
750
|
+
const branchName = "overstory/builder/bead-predict-2";
|
|
751
|
+
await createCleanFeatureBranch(repoDir, branchName);
|
|
752
|
+
|
|
753
|
+
let output = "";
|
|
754
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
755
|
+
process.stdout.write = (chunk: unknown): boolean => {
|
|
756
|
+
output += String(chunk);
|
|
757
|
+
return true;
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
try {
|
|
761
|
+
await mergeCommand({ branch: branchName, dryRun: true, json: true });
|
|
762
|
+
} finally {
|
|
763
|
+
process.stdout.write = originalWrite;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const parsed = JSON.parse(output);
|
|
767
|
+
expect(parsed.prediction.predictedTier).toBe("clean-merge");
|
|
768
|
+
expect(parsed.prediction.wouldRequireAgent).toBe(false);
|
|
769
|
+
expect(parsed.prediction.conflictFiles).toEqual([]);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("--all --dry-run --json attaches prediction to each entry", async () => {
|
|
773
|
+
await setupProject(repoDir, defaultBranch);
|
|
774
|
+
const cleanBranch = "overstory/builder/bead-predict-all-clean";
|
|
775
|
+
await createCleanFeatureBranch(repoDir, cleanBranch);
|
|
776
|
+
|
|
777
|
+
// Add a contentful-canonical conflict branch.
|
|
778
|
+
await commitFile(repoDir, "src/shared.ts", "shared base\n");
|
|
779
|
+
const conflictBranch = "overstory/builder/bead-predict-all-conflict";
|
|
780
|
+
await runGitInDir(repoDir, ["checkout", "-b", conflictBranch]);
|
|
781
|
+
await commitFile(repoDir, "src/shared.ts", "feature side\n");
|
|
782
|
+
await runGitInDir(repoDir, ["checkout", defaultBranch]);
|
|
783
|
+
await commitFile(repoDir, "src/shared.ts", "main side\n");
|
|
784
|
+
|
|
785
|
+
const queuePath = join(repoDir, ".overstory", "merge-queue.db");
|
|
786
|
+
const queue = createMergeQueue(queuePath);
|
|
787
|
+
queue.enqueue({
|
|
788
|
+
branchName: cleanBranch,
|
|
789
|
+
taskId: "bead-predict-all-clean",
|
|
790
|
+
agentName: "builder",
|
|
791
|
+
filesModified: [`src/${cleanBranch}.ts`],
|
|
792
|
+
});
|
|
793
|
+
queue.enqueue({
|
|
794
|
+
branchName: conflictBranch,
|
|
795
|
+
taskId: "bead-predict-all-conflict",
|
|
796
|
+
agentName: "builder",
|
|
797
|
+
filesModified: ["src/shared.ts"],
|
|
798
|
+
});
|
|
799
|
+
queue.close();
|
|
800
|
+
|
|
801
|
+
let output = "";
|
|
802
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
803
|
+
process.stdout.write = (chunk: unknown): boolean => {
|
|
804
|
+
output += String(chunk);
|
|
805
|
+
return true;
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
try {
|
|
809
|
+
await mergeCommand({ all: true, dryRun: true, json: true });
|
|
810
|
+
} finally {
|
|
811
|
+
process.stdout.write = originalWrite;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const parsed = JSON.parse(output);
|
|
815
|
+
expect(parsed.entries).toHaveLength(2);
|
|
816
|
+
const cleanEntry = parsed.entries.find(
|
|
817
|
+
(e: { branchName: string }) => e.branchName === cleanBranch,
|
|
818
|
+
);
|
|
819
|
+
const conflictEntry = parsed.entries.find(
|
|
820
|
+
(e: { branchName: string }) => e.branchName === conflictBranch,
|
|
821
|
+
);
|
|
822
|
+
expect(cleanEntry.prediction.predictedTier).toBe("clean-merge");
|
|
823
|
+
expect(cleanEntry.prediction.wouldRequireAgent).toBe(false);
|
|
824
|
+
expect(conflictEntry.prediction.predictedTier).toBe("ai-resolve");
|
|
825
|
+
expect(conflictEntry.prediction.wouldRequireAgent).toBe(true);
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
|
|
600
829
|
describe("conflict handling", () => {
|
|
601
830
|
test("content conflict auto-resolves: same file modified on both branches, verify incoming content wins", async () => {
|
|
602
831
|
await setupProject(repoDir, defaultBranch);
|
package/src/commands/merge.ts
CHANGED
|
@@ -16,10 +16,12 @@ import { loadConfig } from "../config.ts";
|
|
|
16
16
|
import { MergeError, ValidationError } from "../errors.ts";
|
|
17
17
|
import { jsonOutput } from "../json.ts";
|
|
18
18
|
import { accent, printHint } from "../logging/color.ts";
|
|
19
|
+
import { acquireMergeLock } from "../merge/lock.ts";
|
|
20
|
+
import { predictConflicts } from "../merge/predict.ts";
|
|
19
21
|
import { createMergeQueue } from "../merge/queue.ts";
|
|
20
22
|
import { createMergeResolver } from "../merge/resolver.ts";
|
|
21
23
|
import { createMulchClient } from "../mulch/client.ts";
|
|
22
|
-
import type { MergeEntry, MergeResult } from "../types.ts";
|
|
24
|
+
import type { ConflictPrediction, MergeEntry, MergeResult } from "../types.ts";
|
|
23
25
|
|
|
24
26
|
export interface MergeOptions {
|
|
25
27
|
branch?: string;
|
|
@@ -108,7 +110,7 @@ function formatResult(result: MergeResult): string {
|
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
/** Format a dry-run report for a merge entry. */
|
|
111
|
-
function formatDryRun(entry: MergeEntry): string {
|
|
113
|
+
function formatDryRun(entry: MergeEntry, prediction?: ConflictPrediction): string {
|
|
112
114
|
const lines: string[] = [
|
|
113
115
|
`[dry-run] Branch: ${accent(entry.branchName)}`,
|
|
114
116
|
` Agent: ${accent(entry.agentName)} | Task: ${accent(entry.taskId)}`,
|
|
@@ -122,9 +124,41 @@ function formatDryRun(entry: MergeEntry): string {
|
|
|
122
124
|
}
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
if (prediction) {
|
|
128
|
+
const agentSuffix = prediction.wouldRequireAgent ? " (would require merger agent)" : "";
|
|
129
|
+
lines.push(` Prediction: ${prediction.predictedTier}${agentSuffix} — ${prediction.reason}`);
|
|
130
|
+
if (prediction.conflictFiles.length > 0) {
|
|
131
|
+
lines.push(` Conflict files: ${prediction.conflictFiles.join(", ")}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
125
135
|
return lines.join("\n");
|
|
126
136
|
}
|
|
127
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Predict the merge tier for a single entry, swallowing errors into a
|
|
140
|
+
* deterministic `ai-resolve` envelope so that `--all --dry-run` can keep
|
|
141
|
+
* going if one branch's prediction blows up.
|
|
142
|
+
*/
|
|
143
|
+
async function safePredictForEntry(
|
|
144
|
+
entry: MergeEntry,
|
|
145
|
+
canonicalBranch: string,
|
|
146
|
+
repoRoot: string,
|
|
147
|
+
mulchClient: ReturnType<typeof createMulchClient>,
|
|
148
|
+
): Promise<ConflictPrediction> {
|
|
149
|
+
try {
|
|
150
|
+
return await predictConflicts(entry, canonicalBranch, repoRoot, mulchClient);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
153
|
+
return {
|
|
154
|
+
predictedTier: "ai-resolve",
|
|
155
|
+
conflictFiles: [],
|
|
156
|
+
wouldRequireAgent: true,
|
|
157
|
+
reason: `prediction-failed: ${msg}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
128
162
|
/**
|
|
129
163
|
* Entry point for `ov merge [flags]`.
|
|
130
164
|
*
|
|
@@ -168,10 +202,22 @@ export async function mergeCommand(opts: MergeOptions): Promise<void> {
|
|
|
168
202
|
mulchClient,
|
|
169
203
|
});
|
|
170
204
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
205
|
+
// Dry-run is read-only with respect to git state — no lock needed. The
|
|
206
|
+
// real merge path acquires a lock on the target branch so a parallel
|
|
207
|
+
// `ov merge` can't observe in-progress conflict markers and report a
|
|
208
|
+
// false failure (seeds: overstory-9610).
|
|
209
|
+
const lock = dryRun
|
|
210
|
+
? null
|
|
211
|
+
: acquireMergeLock(join(config.project.root, ".overstory"), targetBranch);
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
if (branchName) {
|
|
215
|
+
await handleBranch(branchName, queue, resolver, config, targetBranch, dryRun, json);
|
|
216
|
+
} else {
|
|
217
|
+
await handleAll(queue, resolver, config, targetBranch, dryRun, json);
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
lock?.release();
|
|
175
221
|
}
|
|
176
222
|
}
|
|
177
223
|
|
|
@@ -225,10 +271,13 @@ async function handleBranch(
|
|
|
225
271
|
}
|
|
226
272
|
|
|
227
273
|
if (dryRun) {
|
|
274
|
+
const mulchClient = createMulchClient(config.project.root);
|
|
275
|
+
const prediction = await safePredictForEntry(entry, canonicalBranch, repoRoot, mulchClient);
|
|
276
|
+
|
|
228
277
|
if (json) {
|
|
229
|
-
jsonOutput("merge", { ...entry });
|
|
278
|
+
jsonOutput("merge", { ...entry, prediction });
|
|
230
279
|
} else {
|
|
231
|
-
process.stdout.write(`${formatDryRun(entry)}\n`);
|
|
280
|
+
process.stdout.write(`${formatDryRun(entry, prediction)}\n`);
|
|
232
281
|
}
|
|
233
282
|
return;
|
|
234
283
|
}
|
|
@@ -280,14 +329,21 @@ async function handleAll(
|
|
|
280
329
|
}
|
|
281
330
|
|
|
282
331
|
if (dryRun) {
|
|
332
|
+
const mulchClient = createMulchClient(config.project.root);
|
|
333
|
+
const enrichedEntries: Array<MergeEntry & { prediction: ConflictPrediction }> = [];
|
|
334
|
+
for (const entry of pendingEntries) {
|
|
335
|
+
const prediction = await safePredictForEntry(entry, canonicalBranch, repoRoot, mulchClient);
|
|
336
|
+
enrichedEntries.push({ ...entry, prediction });
|
|
337
|
+
}
|
|
338
|
+
|
|
283
339
|
if (json) {
|
|
284
|
-
jsonOutput("merge", { entries:
|
|
340
|
+
jsonOutput("merge", { entries: enrichedEntries });
|
|
285
341
|
} else {
|
|
286
342
|
process.stdout.write(
|
|
287
|
-
`${
|
|
343
|
+
`${enrichedEntries.length} pending branch${enrichedEntries.length === 1 ? "" : "es"}:\n\n`,
|
|
288
344
|
);
|
|
289
|
-
for (const entry of
|
|
290
|
-
process.stdout.write(`${formatDryRun(entry)}\n\n`);
|
|
345
|
+
for (const entry of enrichedEntries) {
|
|
346
|
+
process.stdout.write(`${formatDryRun(entry, entry.prediction)}\n\n`);
|
|
291
347
|
}
|
|
292
348
|
}
|
|
293
349
|
return;
|