@agentplate/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -0
  4. package/agents/architect.md +108 -0
  5. package/agents/builder.md +97 -0
  6. package/agents/coordinator.md +113 -0
  7. package/agents/deployer.md +117 -0
  8. package/agents/devops.md +114 -0
  9. package/agents/lead.md +107 -0
  10. package/agents/merger.md +103 -0
  11. package/agents/reviewer.md +90 -0
  12. package/agents/scout.md +95 -0
  13. package/agents/verifier.md +106 -0
  14. package/package.json +64 -0
  15. package/src/agents/guard-rules.ts +55 -0
  16. package/src/agents/identity.test.ts +161 -0
  17. package/src/agents/identity.ts +229 -0
  18. package/src/agents/manifest.test.ts +260 -0
  19. package/src/agents/manifest.ts +286 -0
  20. package/src/agents/overlay.test.ts +190 -0
  21. package/src/agents/overlay.ts +212 -0
  22. package/src/agents/system-prompt.test.ts +53 -0
  23. package/src/agents/system-prompt.ts +95 -0
  24. package/src/agents/turn-runner.ts +79 -0
  25. package/src/commands/coordinator.test.ts +75 -0
  26. package/src/commands/coordinator.ts +259 -0
  27. package/src/commands/deploy.test.ts +504 -0
  28. package/src/commands/deploy.ts +874 -0
  29. package/src/commands/doctor.test.ts +106 -0
  30. package/src/commands/doctor.ts +208 -0
  31. package/src/commands/init.ts +71 -0
  32. package/src/commands/log.ts +51 -0
  33. package/src/commands/mail.ts +197 -0
  34. package/src/commands/merge.ts +127 -0
  35. package/src/commands/model.ts +58 -0
  36. package/src/commands/prime.ts +61 -0
  37. package/src/commands/reap.ts +87 -0
  38. package/src/commands/serve.ts +61 -0
  39. package/src/commands/setup.ts +48 -0
  40. package/src/commands/ship.test.ts +106 -0
  41. package/src/commands/ship.ts +202 -0
  42. package/src/commands/skill.test.ts +458 -0
  43. package/src/commands/skill.ts +730 -0
  44. package/src/commands/sling.ts +365 -0
  45. package/src/commands/status.ts +60 -0
  46. package/src/commands/stop.ts +56 -0
  47. package/src/commands/tui.ts +199 -0
  48. package/src/commands/worktree.ts +77 -0
  49. package/src/config.test.ts +92 -0
  50. package/src/config.ts +202 -0
  51. package/src/db/sqlite.test.ts +77 -0
  52. package/src/db/sqlite.ts +102 -0
  53. package/src/deploy/audit.test.ts +233 -0
  54. package/src/deploy/audit.ts +245 -0
  55. package/src/deploy/context.test.ts +243 -0
  56. package/src/deploy/context.ts +72 -0
  57. package/src/deploy/registry.test.ts +101 -0
  58. package/src/deploy/registry.ts +86 -0
  59. package/src/deploy/secrets.test.ts +129 -0
  60. package/src/deploy/secrets.ts +69 -0
  61. package/src/deploy/targets/docker-gha.test.ts +323 -0
  62. package/src/deploy/targets/docker-gha.ts +841 -0
  63. package/src/deploy/types.ts +153 -0
  64. package/src/errors.test.ts +42 -0
  65. package/src/errors.ts +69 -0
  66. package/src/events/store.test.ts +183 -0
  67. package/src/events/store.ts +201 -0
  68. package/src/index.ts +137 -0
  69. package/src/insights/quality-gates.ts +73 -0
  70. package/src/json.test.ts +28 -0
  71. package/src/json.ts +50 -0
  72. package/src/logging/color.ts +62 -0
  73. package/src/logging/logger.ts +60 -0
  74. package/src/logging/sanitizer.test.ts +36 -0
  75. package/src/logging/sanitizer.ts +57 -0
  76. package/src/mail/client.test.ts +192 -0
  77. package/src/mail/client.ts +188 -0
  78. package/src/mail/store.test.ts +279 -0
  79. package/src/mail/store.ts +311 -0
  80. package/src/merge/lock.test.ts +88 -0
  81. package/src/merge/lock.ts +84 -0
  82. package/src/merge/queue.test.ts +136 -0
  83. package/src/merge/queue.ts +177 -0
  84. package/src/merge/resolver.test.ts +219 -0
  85. package/src/merge/resolver.ts +274 -0
  86. package/src/paths.ts +36 -0
  87. package/src/providers/apply.test.ts +90 -0
  88. package/src/providers/apply.ts +66 -0
  89. package/src/providers/registry.test.ts +74 -0
  90. package/src/providers/registry.ts +254 -0
  91. package/src/runtimes/claude.ts +313 -0
  92. package/src/runtimes/codex.ts +280 -0
  93. package/src/runtimes/cursor.ts +247 -0
  94. package/src/runtimes/gemini.ts +173 -0
  95. package/src/runtimes/mock.ts +71 -0
  96. package/src/runtimes/opencode.ts +259 -0
  97. package/src/runtimes/registry.test.ts +924 -0
  98. package/src/runtimes/registry.ts +63 -0
  99. package/src/runtimes/resolve.ts +45 -0
  100. package/src/runtimes/types.ts +97 -0
  101. package/src/scaffold.ts +68 -0
  102. package/src/secrets.test.ts +51 -0
  103. package/src/secrets.ts +78 -0
  104. package/src/serve/api.ts +667 -0
  105. package/src/serve/server.test.ts +433 -0
  106. package/src/serve/server.ts +271 -0
  107. package/src/serve/system.ts +90 -0
  108. package/src/serve/weather.ts +140 -0
  109. package/src/sessions/reaper.test.ts +162 -0
  110. package/src/sessions/reaper.ts +149 -0
  111. package/src/sessions/store.test.ts +351 -0
  112. package/src/sessions/store.ts +350 -0
  113. package/src/skills/distiller.test.ts +498 -0
  114. package/src/skills/distiller.ts +426 -0
  115. package/src/skills/feedback.test.ts +300 -0
  116. package/src/skills/feedback.ts +168 -0
  117. package/src/skills/lifecycle.ts +169 -0
  118. package/src/skills/retrieval.test.ts +421 -0
  119. package/src/skills/retrieval.ts +365 -0
  120. package/src/skills/safety.test.ts +335 -0
  121. package/src/skills/safety.ts +216 -0
  122. package/src/skills/store.test.ts +425 -0
  123. package/src/skills/store.ts +684 -0
  124. package/src/skills/types.ts +107 -0
  125. package/src/types.ts +442 -0
  126. package/src/utils/detect.test.ts +35 -0
  127. package/src/utils/detect.ts +82 -0
  128. package/src/version.test.ts +19 -0
  129. package/src/version.ts +7 -0
  130. package/src/wizard/setup.ts +254 -0
  131. package/src/worktree/manager.test.ts +181 -0
  132. package/src/worktree/manager.ts +229 -0
  133. package/templates/overlay.md.tmpl +102 -0
  134. package/ui/dist/assets/index-C7rXIMER.css +1 -0
  135. package/ui/dist/assets/index-W4kbr4by.js +4526 -0
  136. package/ui/dist/favicon.svg +21 -0
  137. package/ui/dist/index.html +16 -0
  138. package/ui/dist/logo-clay.svg +21 -0
  139. package/ui/dist/logo.svg +18 -0
@@ -0,0 +1,177 @@
1
+ /**
2
+ * FIFO merge queue backed by SQLite.
3
+ *
4
+ * WHY a persisted queue (and not an in-memory array): agent branches become
5
+ * mergeable asynchronously and from different processes — a worker signals
6
+ * `merge_ready`, the coordinator (a separate process, possibly a separate run)
7
+ * drains the queue later. The queue therefore has to survive process exits and
8
+ * be safe under concurrent access, which is exactly what our WAL-mode SQLite
9
+ * helper (`openDatabase`) provides.
10
+ *
11
+ * FIFO ordering: rows carry a monotonically increasing integer `seq`
12
+ * (AUTOINCREMENT) used only for ordering. The caller-facing `id` is a random
13
+ * UUID (per project convention) and says nothing about insertion order, so we
14
+ * cannot order by it. `seq` is an internal column and never leaves this module.
15
+ */
16
+
17
+ import type { Database } from "bun:sqlite";
18
+
19
+ import { openDatabase } from "../db/sqlite.ts";
20
+ import { NotFoundError } from "../errors.ts";
21
+ import type { MergeEntry, MergeStatus } from "../types.ts";
22
+
23
+ /** A merge queue handle bound to one SQLite database. */
24
+ export interface MergeQueue {
25
+ /**
26
+ * Append a new entry to the queue. `id`, `createdAt`, and `status` are
27
+ * assigned here (status starts as "pending"); the caller supplies the rest.
28
+ */
29
+ enqueue(entry: Omit<MergeEntry, "id" | "createdAt" | "status">): MergeEntry;
30
+ /** All pending entries, oldest first. */
31
+ listPending(): MergeEntry[];
32
+ /** Set the status of an entry by id. Throws {@link NotFoundError} if absent. */
33
+ markStatus(id: string, status: MergeStatus): void;
34
+ /**
35
+ * Remove and return the oldest pending entry, or null if none remain.
36
+ * The returned object reflects its pre-removal `status` ("pending").
37
+ */
38
+ dequeue(): MergeEntry | null;
39
+ /** Close the underlying database connection. */
40
+ close(): void;
41
+ }
42
+
43
+ /** Row shape as stored in SQLite (snake_case columns + internal `seq`). */
44
+ interface MergeQueueRow {
45
+ seq: number;
46
+ id: string;
47
+ branch_name: string;
48
+ agent_name: string;
49
+ task_id: string;
50
+ target_branch: string;
51
+ status: string;
52
+ created_at: string;
53
+ }
54
+
55
+ // `seq` drives FIFO ordering; `id` is the stable public identifier. We index
56
+ // status because every read path (listPending / dequeue) filters on it.
57
+ const CREATE_TABLE = `
58
+ CREATE TABLE IF NOT EXISTS merge_queue (
59
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ id TEXT NOT NULL UNIQUE,
61
+ branch_name TEXT NOT NULL,
62
+ agent_name TEXT NOT NULL,
63
+ task_id TEXT NOT NULL,
64
+ target_branch TEXT NOT NULL,
65
+ status TEXT NOT NULL DEFAULT 'pending'
66
+ CHECK(status IN ('pending','merged','failed')),
67
+ created_at TEXT NOT NULL
68
+ )`;
69
+
70
+ const CREATE_INDEX = `
71
+ CREATE INDEX IF NOT EXISTS idx_merge_queue_status_seq ON merge_queue(status, seq)`;
72
+
73
+ /** Map a stored row to the public {@link MergeEntry} shape (drops `seq`). */
74
+ function rowToEntry(row: MergeQueueRow): MergeEntry {
75
+ return {
76
+ id: row.id,
77
+ branchName: row.branch_name,
78
+ agentName: row.agent_name,
79
+ taskId: row.task_id,
80
+ targetBranch: row.target_branch,
81
+ // The CHECK constraint guarantees this is a valid MergeStatus.
82
+ status: row.status as MergeStatus,
83
+ createdAt: row.created_at,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Open (or create) a FIFO merge queue at `dbPath`. Pass `":memory:"` for an
89
+ * ephemeral queue in tests. The standard project path is
90
+ * `<root>/.agentplate/merge-queue.db`.
91
+ */
92
+ export function createMergeQueue(dbPath: string): MergeQueue {
93
+ const db: Database = openDatabase(dbPath);
94
+ db.exec(CREATE_TABLE);
95
+ db.exec(CREATE_INDEX);
96
+
97
+ // Prepared statements are reused across calls for the hot paths.
98
+ const insertStmt = db.query<
99
+ MergeQueueRow,
100
+ {
101
+ $id: string;
102
+ $branch_name: string;
103
+ $agent_name: string;
104
+ $task_id: string;
105
+ $target_branch: string;
106
+ $created_at: string;
107
+ }
108
+ >(
109
+ `INSERT INTO merge_queue (id, branch_name, agent_name, task_id, target_branch, created_at)
110
+ VALUES ($id, $branch_name, $agent_name, $task_id, $target_branch, $created_at)
111
+ RETURNING *`,
112
+ );
113
+
114
+ const listPendingStmt = db.query<MergeQueueRow, []>(
115
+ "SELECT * FROM merge_queue WHERE status = 'pending' ORDER BY seq ASC",
116
+ );
117
+
118
+ const firstPendingStmt = db.query<MergeQueueRow, []>(
119
+ "SELECT * FROM merge_queue WHERE status = 'pending' ORDER BY seq ASC LIMIT 1",
120
+ );
121
+
122
+ const getByIdStmt = db.query<MergeQueueRow, { $id: string }>(
123
+ "SELECT * FROM merge_queue WHERE id = $id",
124
+ );
125
+
126
+ const updateStatusStmt = db.query<void, { $id: string; $status: string }>(
127
+ "UPDATE merge_queue SET status = $status WHERE id = $id",
128
+ );
129
+
130
+ const deleteBySeqStmt = db.query<void, { $seq: number }>(
131
+ "DELETE FROM merge_queue WHERE seq = $seq",
132
+ );
133
+
134
+ return {
135
+ enqueue(entry): MergeEntry {
136
+ const row = insertStmt.get({
137
+ $id: crypto.randomUUID(),
138
+ $branch_name: entry.branchName,
139
+ $agent_name: entry.agentName,
140
+ $task_id: entry.taskId,
141
+ $target_branch: entry.targetBranch,
142
+ $created_at: new Date().toISOString(),
143
+ });
144
+ // RETURNING * always yields a row on a successful INSERT; the guard is
145
+ // here only to satisfy noUncheckedIndexedAccess-style strictness.
146
+ if (row === null) {
147
+ throw new NotFoundError("merge queue insert returned no row");
148
+ }
149
+ return rowToEntry(row);
150
+ },
151
+
152
+ listPending(): MergeEntry[] {
153
+ return listPendingStmt.all().map(rowToEntry);
154
+ },
155
+
156
+ markStatus(id, status): void {
157
+ const existing = getByIdStmt.get({ $id: id });
158
+ if (existing === null) {
159
+ throw new NotFoundError(`No merge queue entry with id "${id}"`);
160
+ }
161
+ updateStatusStmt.run({ $id: id, $status: status });
162
+ },
163
+
164
+ dequeue(): MergeEntry | null {
165
+ const row = firstPendingStmt.get();
166
+ if (row === null) return null;
167
+ // Remove by the internal seq so we delete exactly the row we read,
168
+ // even if two entries somehow shared other column values.
169
+ deleteBySeqStmt.run({ $seq: row.seq });
170
+ return rowToEntry(row);
171
+ },
172
+
173
+ close(): void {
174
+ db.close();
175
+ },
176
+ };
177
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Tests for branch merge + conflict resolution.
3
+ *
4
+ * Every test runs against a REAL git repository created in a temp directory
5
+ * (per the project's "never mock what you can use for real" rule). We drive git
6
+ * through Bun.spawn exactly as the resolver does, so the tests validate true git
7
+ * behavior — clean merges, conflict detection, abort cleanliness, and the
8
+ * keep-theirs auto-resolution.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ import { mergeBranch, predictMerge } from "./resolver.ts";
17
+
18
+ let repo: string;
19
+
20
+ /** Run a git command in the test repo, asserting success. Returns stdout. */
21
+ async function git(...args: string[]): Promise<string> {
22
+ const proc = Bun.spawn(["git", ...args], { cwd: repo, stdout: "pipe", stderr: "pipe" });
23
+ const [stdout, stderr, exitCode] = await Promise.all([
24
+ new Response(proc.stdout).text(),
25
+ new Response(proc.stderr).text(),
26
+ proc.exited,
27
+ ]);
28
+ if (exitCode !== 0) {
29
+ throw new Error(`git ${args.join(" ")} failed (exit ${exitCode}): ${stderr}`);
30
+ }
31
+ return stdout;
32
+ }
33
+
34
+ /** Write a file (relative to repo root) and return its absolute path. */
35
+ async function writeFile(rel: string, content: string): Promise<void> {
36
+ await Bun.write(join(repo, rel), content);
37
+ }
38
+
39
+ /** Read a tracked file's current working-tree content. */
40
+ async function readFile(rel: string): Promise<string> {
41
+ return Bun.file(join(repo, rel)).text();
42
+ }
43
+
44
+ /**
45
+ * Create a fresh repo on branch `main` with an initial commit containing
46
+ * `file.txt`. Returns once HEAD is on `main`.
47
+ */
48
+ async function initRepo(): Promise<void> {
49
+ await git("init", "-q");
50
+ // Deterministic identity so commits succeed in CI without global git config.
51
+ await git("config", "user.email", "test@agentplate.dev");
52
+ await git("config", "user.name", "Agentplate Test");
53
+ // Pin the default branch name regardless of the host's init.defaultBranch.
54
+ await git("checkout", "-q", "-b", "main");
55
+ await writeFile("file.txt", "line1\nline2\nline3\n");
56
+ await git("add", "-A");
57
+ await git("commit", "-q", "-m", "initial");
58
+ }
59
+
60
+ /** Create `branch` off the current HEAD and check it out. */
61
+ async function branchOff(branch: string): Promise<void> {
62
+ await git("checkout", "-q", "-b", branch);
63
+ }
64
+
65
+ /** Switch to an existing branch. */
66
+ async function checkout(branch: string): Promise<void> {
67
+ await git("checkout", "-q", branch);
68
+ }
69
+
70
+ /** Commit the current working tree with a message. */
71
+ async function commitAll(message: string): Promise<void> {
72
+ await git("add", "-A");
73
+ await git("commit", "-q", "-m", message);
74
+ }
75
+
76
+ beforeEach(async () => {
77
+ repo = mkdtempSync(join(tmpdir(), "agentplate-merge-"));
78
+ await initRepo();
79
+ });
80
+
81
+ afterEach(() => {
82
+ rmSync(repo, { recursive: true, force: true });
83
+ });
84
+
85
+ describe("mergeBranch — clean merge", () => {
86
+ test("non-conflicting branch merges cleanly", async () => {
87
+ // Branch touches a NEW file; main is unchanged -> no conflict.
88
+ await branchOff("agent/clean");
89
+ await writeFile("feature.txt", "hello from agent\n");
90
+ await commitAll("add feature.txt");
91
+
92
+ await checkout("main");
93
+ const result = await mergeBranch(repo, "agent/clean", "main");
94
+
95
+ expect(result.status).toBe("merged");
96
+ expect(result.tier).toBe("clean-merge");
97
+ expect(result.conflictFiles).toEqual([]);
98
+ expect(result.branchName).toBe("agent/clean");
99
+
100
+ // The branch's file is now present on main.
101
+ expect(await readFile("feature.txt")).toBe("hello from agent\n");
102
+
103
+ // A --no-ff merge always records a merge commit (two parents).
104
+ const parents = (await git("rev-list", "--parents", "-n", "1", "HEAD")).trim().split(/\s+/);
105
+ expect(parents.length).toBe(3); // commit + 2 parents
106
+ });
107
+ });
108
+
109
+ describe("mergeBranch — conflict handling", () => {
110
+ /** Build a branch that conflicts with main on file.txt's first line. */
111
+ async function makeConflict(): Promise<void> {
112
+ await branchOff("agent/conflict");
113
+ await writeFile("file.txt", "BRANCH-EDIT\nline2\nline3\n");
114
+ await commitAll("branch edits line1");
115
+
116
+ await checkout("main");
117
+ await writeFile("file.txt", "MAIN-EDIT\nline2\nline3\n");
118
+ await commitAll("main edits line1");
119
+ }
120
+
121
+ test("autoResolve=false fails and leaves NO in-progress merge", async () => {
122
+ await makeConflict();
123
+
124
+ const result = await mergeBranch(repo, "agent/conflict", "main", { autoResolve: false });
125
+
126
+ expect(result.status).toBe("failed");
127
+ expect(result.tier).toBeNull();
128
+ expect(result.conflictFiles).toContain("file.txt");
129
+
130
+ // Critical: the repo must be clean — no MERGE_HEAD, no conflict markers.
131
+ // `git rev-parse --verify MERGE_HEAD` must fail (exit != 0).
132
+ const mh = Bun.spawn(["git", "rev-parse", "--verify", "-q", "MERGE_HEAD"], {
133
+ cwd: repo,
134
+ stdout: "pipe",
135
+ stderr: "pipe",
136
+ });
137
+ expect(await mh.exited).not.toBe(0);
138
+
139
+ // Working tree is clean (porcelain output empty).
140
+ const status = await git("status", "--porcelain");
141
+ expect(status.trim()).toBe("");
142
+
143
+ // main's content is untouched (the conflicting merge was aborted).
144
+ expect(await readFile("file.txt")).toBe("MAIN-EDIT\nline2\nline3\n");
145
+ });
146
+
147
+ test("autoResolve=true merges by keeping incoming (theirs) changes", async () => {
148
+ await makeConflict();
149
+
150
+ const result = await mergeBranch(repo, "agent/conflict", "main", { autoResolve: true });
151
+
152
+ expect(result.status).toBe("merged");
153
+ expect(result.tier).toBe("auto-resolve");
154
+ expect(result.conflictFiles).toContain("file.txt");
155
+
156
+ // keep-theirs => the incoming branch's version wins.
157
+ expect(await readFile("file.txt")).toBe("BRANCH-EDIT\nline2\nline3\n");
158
+
159
+ // Merge is committed: clean tree, no MERGE_HEAD.
160
+ const status = await git("status", "--porcelain");
161
+ expect(status.trim()).toBe("");
162
+ const mh = Bun.spawn(["git", "rev-parse", "--verify", "-q", "MERGE_HEAD"], {
163
+ cwd: repo,
164
+ stdout: "pipe",
165
+ stderr: "pipe",
166
+ });
167
+ expect(await mh.exited).not.toBe(0);
168
+ });
169
+ });
170
+
171
+ describe("predictMerge — side-effect-free", () => {
172
+ test("reports clean for a non-conflicting branch without changing HEAD", async () => {
173
+ await branchOff("agent/clean");
174
+ await writeFile("feature.txt", "x\n");
175
+ await commitAll("add feature.txt");
176
+ await checkout("main");
177
+
178
+ const headBefore = (await git("rev-parse", "HEAD")).trim();
179
+ const result = await predictMerge(repo, "agent/clean", "main");
180
+
181
+ expect(result.status).toBe("pending");
182
+ expect(result.tier).toBe("clean-merge");
183
+ expect(result.conflictFiles).toEqual([]);
184
+
185
+ // HEAD unchanged and working tree clean: prediction had no side effects.
186
+ expect((await git("rev-parse", "HEAD")).trim()).toBe(headBefore);
187
+ expect((await git("status", "--porcelain")).trim()).toBe("");
188
+ // The would-be-merged file was NOT brought in.
189
+ expect(await Bun.file(join(repo, "feature.txt")).exists()).toBe(false);
190
+ });
191
+
192
+ test("reports conflicts without changing HEAD or leaving a merge in progress", async () => {
193
+ // Conflicting setup.
194
+ await branchOff("agent/conflict");
195
+ await writeFile("file.txt", "BRANCH\nline2\nline3\n");
196
+ await commitAll("branch edit");
197
+ await checkout("main");
198
+ await writeFile("file.txt", "MAIN\nline2\nline3\n");
199
+ await commitAll("main edit");
200
+
201
+ const headBefore = (await git("rev-parse", "HEAD")).trim();
202
+ const result = await predictMerge(repo, "agent/conflict", "main");
203
+
204
+ expect(result.status).toBe("pending");
205
+ expect(result.tier).toBe("auto-resolve");
206
+ expect(result.conflictFiles).toContain("file.txt");
207
+
208
+ // No side effects: HEAD pinned, tree clean, no MERGE_HEAD, content intact.
209
+ expect((await git("rev-parse", "HEAD")).trim()).toBe(headBefore);
210
+ expect((await git("status", "--porcelain")).trim()).toBe("");
211
+ expect(await readFile("file.txt")).toBe("MAIN\nline2\nline3\n");
212
+ const mh = Bun.spawn(["git", "rev-parse", "--verify", "-q", "MERGE_HEAD"], {
213
+ cwd: repo,
214
+ stdout: "pipe",
215
+ stderr: "pipe",
216
+ });
217
+ expect(await mh.exited).not.toBe(0);
218
+ });
219
+ });
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Branch merge with tiered conflict resolution.
3
+ *
4
+ * Two execution paths share the same git primitives:
5
+ *
6
+ * mergeBranch() — mutating. Checks out the target branch and runs a real
7
+ * `git merge`. On conflict it either auto-resolves
8
+ * (keep-theirs) or aborts cleanly, depending on `opts`.
9
+ * predictMerge() — side-effect-free. Reports whether a merge WOULD conflict
10
+ * and which tier would apply, WITHOUT touching HEAD or the
11
+ * working tree. Used by `agentplate merge --dry-run`.
12
+ *
13
+ * Resolution tiers (this module implements the first two; "ai-resolve" /
14
+ * "reimagine" from {@link MergeTier} are reserved for a later phase):
15
+ *
16
+ * clean-merge — `git merge --no-ff` succeeded with no conflicts.
17
+ * auto-resolve — conflicts existed; we took the incoming branch's version of
18
+ * every conflicted file (`git checkout --theirs`) and
19
+ * committed. This is "agent work wins", matching the
20
+ * orchestration model where the branch is the source of truth.
21
+ *
22
+ * WHY keep-theirs for auto-resolve: agent worktrees are branched from the
23
+ * target and own an exclusive file scope, so when a conflict does occur the
24
+ * branch side is the intended change and the target side is stale. Taking
25
+ * "theirs" wholesale is the deterministic, no-LLM resolution. Semantically
26
+ * risky conflicts are a future-phase concern (ai-resolve).
27
+ */
28
+
29
+ import { SubprocessError, WorktreeError } from "../errors.ts";
30
+ import type { MergeResult } from "../types.ts";
31
+
32
+ /** Result of running a git subprocess. */
33
+ interface GitResult {
34
+ stdout: string;
35
+ stderr: string;
36
+ exitCode: number;
37
+ }
38
+
39
+ /**
40
+ * Run a git command in `repoRoot` and capture its output. Never throws on a
41
+ * non-zero exit — callers inspect `exitCode` because for git a non-zero exit is
42
+ * frequently expected (a merge conflict is exit 1, not a crash).
43
+ */
44
+ async function runGit(repoRoot: string, args: string[]): Promise<GitResult> {
45
+ const proc = Bun.spawn(["git", ...args], {
46
+ cwd: repoRoot,
47
+ stdout: "pipe",
48
+ stderr: "pipe",
49
+ });
50
+ const [stdout, stderr, exitCode] = await Promise.all([
51
+ new Response(proc.stdout).text(),
52
+ new Response(proc.stderr).text(),
53
+ proc.exited,
54
+ ]);
55
+ return { stdout, stderr, exitCode };
56
+ }
57
+
58
+ /**
59
+ * Run a git command that is expected to succeed. Throws {@link SubprocessError}
60
+ * (carrying the git exit code) on any non-zero exit. Use this only for steps
61
+ * where a failure is genuinely exceptional (checkout of an existing branch,
62
+ * staging, committing a resolution).
63
+ */
64
+ async function runGitChecked(repoRoot: string, args: string[]): Promise<GitResult> {
65
+ const result = await runGit(repoRoot, args);
66
+ if (result.exitCode !== 0) {
67
+ throw new SubprocessError(
68
+ `git ${args.join(" ")} failed (exit ${result.exitCode}): ${result.stderr.trim()}`,
69
+ result.exitCode,
70
+ );
71
+ }
72
+ return result;
73
+ }
74
+
75
+ /**
76
+ * Split git's newline-delimited path output into a clean array, dropping the
77
+ * trailing empty entry that a final newline produces.
78
+ */
79
+ function parsePathList(stdout: string): string[] {
80
+ return stdout
81
+ .trim()
82
+ .split("\n")
83
+ .filter((line) => line.length > 0);
84
+ }
85
+
86
+ /** Files currently in an unmerged (conflicted) state in the index. */
87
+ async function conflictedFiles(repoRoot: string): Promise<string[]> {
88
+ const { stdout } = await runGit(repoRoot, ["diff", "--name-only", "--diff-filter=U"]);
89
+ return parsePathList(stdout);
90
+ }
91
+
92
+ /** True if a merge is currently in progress (MERGE_HEAD exists). */
93
+ async function mergeInProgress(repoRoot: string): Promise<boolean> {
94
+ // `git rev-parse --verify -q MERGE_HEAD` exits 0 iff a merge is in progress.
95
+ const { exitCode } = await runGit(repoRoot, ["rev-parse", "--verify", "-q", "MERGE_HEAD"]);
96
+ return exitCode === 0;
97
+ }
98
+
99
+ /** The short name of the currently checked-out branch (empty if detached). */
100
+ async function currentBranch(repoRoot: string): Promise<string> {
101
+ const { stdout, exitCode } = await runGit(repoRoot, ["symbolic-ref", "--short", "-q", "HEAD"]);
102
+ return exitCode === 0 ? stdout.trim() : "";
103
+ }
104
+
105
+ /**
106
+ * Ensure `branch` is the checked-out branch. No-op when already on it — this
107
+ * avoids git's "already checked out / would overwrite" error when the target is
108
+ * current (common when merging straight into the session branch).
109
+ */
110
+ async function ensureOnBranch(repoRoot: string, branch: string): Promise<void> {
111
+ if ((await currentBranch(repoRoot)) === branch) return;
112
+ const { exitCode, stderr } = await runGit(repoRoot, ["checkout", branch]);
113
+ if (exitCode !== 0) {
114
+ throw new WorktreeError(`Failed to checkout "${branch}": ${stderr.trim()}`);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Merge `branchName` into `targetBranch`, resolving conflicts per `opts`.
120
+ *
121
+ * Always returns a {@link MergeResult} (never throws for the ordinary
122
+ * conflict/failure case) so the caller can record an outcome uniformly. It does
123
+ * throw {@link WorktreeError}/{@link SubprocessError} for genuine git failures
124
+ * that are not "this branch conflicts" (e.g. the target branch does not exist).
125
+ *
126
+ * @param opts.autoResolve When true, conflicts are resolved keep-theirs and
127
+ * committed (tier "auto-resolve"). When false (default), a conflicting merge
128
+ * is aborted and reported as failed, leaving the repo with no in-progress
129
+ * merge.
130
+ */
131
+ export async function mergeBranch(
132
+ repoRoot: string,
133
+ branchName: string,
134
+ targetBranch: string,
135
+ opts: { autoResolve?: boolean } = {},
136
+ ): Promise<MergeResult> {
137
+ await ensureOnBranch(repoRoot, targetBranch);
138
+
139
+ // --no-ff: always create a merge commit so the branch's history is preserved
140
+ // as a distinct unit. --no-edit: accept the default merge message
141
+ // non-interactively (no editor in a headless context).
142
+ const merge = await runGit(repoRoot, ["merge", "--no-ff", "--no-edit", branchName]);
143
+
144
+ if (merge.exitCode === 0) {
145
+ return {
146
+ branchName,
147
+ status: "merged",
148
+ tier: "clean-merge",
149
+ conflictFiles: [],
150
+ message: `Merged ${branchName} into ${targetBranch} cleanly.`,
151
+ };
152
+ }
153
+
154
+ // Non-zero exit: capture the conflict set. If git failed for a reason other
155
+ // than a content conflict (e.g. it refused to start the merge), there will be
156
+ // no unmerged files — surface that as a typed error rather than a silent
157
+ // "failed with no conflicts".
158
+ const conflicts = await conflictedFiles(repoRoot);
159
+ if (conflicts.length === 0) {
160
+ // Abort any half-started merge so we never leave the repo wedged.
161
+ if (await mergeInProgress(repoRoot)) {
162
+ await runGit(repoRoot, ["merge", "--abort"]);
163
+ }
164
+ throw new SubprocessError(
165
+ `git merge of ${branchName} into ${targetBranch} failed without conflicts: ${merge.stderr.trim()}`,
166
+ merge.exitCode,
167
+ );
168
+ }
169
+
170
+ if (!opts.autoResolve) {
171
+ // Leave the repo exactly as we found it: abort the merge so there is no
172
+ // MERGE_HEAD and `git status` is clean for the next caller.
173
+ await runGitChecked(repoRoot, ["merge", "--abort"]);
174
+ return {
175
+ branchName,
176
+ status: "failed",
177
+ tier: null,
178
+ conflictFiles: conflicts,
179
+ message: `Merge of ${branchName} into ${targetBranch} conflicts in ${conflicts.length} file(s); auto-resolve disabled, merge aborted.`,
180
+ };
181
+ }
182
+
183
+ // Auto-resolve: take the incoming branch's version of each conflicted file.
184
+ // `--theirs` during a merge refers to the branch being merged in (branchName).
185
+ for (const file of conflicts) {
186
+ await runGitChecked(repoRoot, ["checkout", "--theirs", "--", file]);
187
+ }
188
+ // Stage everything (including any deletes/renames the resolution implies) and
189
+ // commit the in-progress merge with its default message.
190
+ await runGitChecked(repoRoot, ["add", "-A"]);
191
+ await runGitChecked(repoRoot, ["commit", "--no-edit"]);
192
+
193
+ return {
194
+ branchName,
195
+ status: "merged",
196
+ tier: "auto-resolve",
197
+ conflictFiles: conflicts,
198
+ message: `Merged ${branchName} into ${targetBranch}; auto-resolved ${conflicts.length} conflict(s) by keeping incoming changes.`,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Predict the outcome of merging `branchName` into `targetBranch` WITHOUT
204
+ * changing HEAD or the working tree.
205
+ *
206
+ * Strategy: a trial `git merge --no-commit --no-ff` followed unconditionally by
207
+ * `git merge --abort`. We deliberately do NOT use `git merge-tree` here so the
208
+ * prediction exercises the same merge machinery the real run does (identical
209
+ * rename/conflict detection), and because `--no-commit` works on every git
210
+ * version we support. The `--abort` in the `finally` guarantees the repo is
211
+ * restored even if parsing throws.
212
+ *
213
+ * Returned status is "pending" (a prediction, not a completed merge):
214
+ * - clean -> tier "clean-merge", no conflict files
215
+ * - conflict -> tier "auto-resolve" (what mergeBranch would apply), files listed
216
+ */
217
+ export async function predictMerge(
218
+ repoRoot: string,
219
+ branchName: string,
220
+ targetBranch: string,
221
+ ): Promise<MergeResult> {
222
+ await ensureOnBranch(repoRoot, targetBranch);
223
+
224
+ // `--no-commit` still applies the merge to the index/worktree, so we MUST
225
+ // undo it. `--no-ff` forces the merge machinery even for fast-forwardable
226
+ // branches, matching mergeBranch's behavior.
227
+ const trial = await runGit(repoRoot, ["merge", "--no-commit", "--no-ff", branchName]);
228
+ try {
229
+ if (trial.exitCode === 0) {
230
+ return {
231
+ branchName,
232
+ status: "pending",
233
+ tier: "clean-merge",
234
+ conflictFiles: [],
235
+ message: `${branchName} would merge into ${targetBranch} cleanly.`,
236
+ };
237
+ }
238
+
239
+ const conflicts = await conflictedFiles(repoRoot);
240
+ if (conflicts.length === 0) {
241
+ // Non-zero exit with no unmerged files means git refused the merge for
242
+ // a structural reason (e.g. unrelated histories). Report it as a failure
243
+ // prediction rather than inventing a tier.
244
+ return {
245
+ branchName,
246
+ status: "failed",
247
+ tier: null,
248
+ conflictFiles: [],
249
+ message: `${branchName} cannot be merged into ${targetBranch}: ${trial.stderr.trim()}`,
250
+ };
251
+ }
252
+
253
+ return {
254
+ branchName,
255
+ status: "pending",
256
+ tier: "auto-resolve",
257
+ conflictFiles: conflicts,
258
+ message: `${branchName} would conflict with ${targetBranch} in ${conflicts.length} file(s); auto-resolve would keep incoming changes.`,
259
+ };
260
+ } finally {
261
+ // Restore HEAD/index/worktree no matter what. A trial merge that
262
+ // fast-forwarded or fully applied leaves MERGE_HEAD unset, in which case
263
+ // `merge --abort` is a harmless no-op error we ignore; when there are
264
+ // staged-but-uncommitted merge changes, it rewinds them.
265
+ if (await mergeInProgress(repoRoot)) {
266
+ await runGit(repoRoot, ["merge", "--abort"]);
267
+ } else {
268
+ // A --no-commit merge that did not record MERGE_HEAD (rare) can still
269
+ // leave staged changes; hard-reset to HEAD to guarantee a clean tree
270
+ // without altering committed history.
271
+ await runGit(repoRoot, ["reset", "--hard", "HEAD"]);
272
+ }
273
+ }
274
+ }