@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.
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/agents/architect.md +108 -0
- package/agents/builder.md +97 -0
- package/agents/coordinator.md +113 -0
- package/agents/deployer.md +117 -0
- package/agents/devops.md +114 -0
- package/agents/lead.md +107 -0
- package/agents/merger.md +103 -0
- package/agents/reviewer.md +90 -0
- package/agents/scout.md +95 -0
- package/agents/verifier.md +106 -0
- package/package.json +64 -0
- package/src/agents/guard-rules.ts +55 -0
- package/src/agents/identity.test.ts +161 -0
- package/src/agents/identity.ts +229 -0
- package/src/agents/manifest.test.ts +260 -0
- package/src/agents/manifest.ts +286 -0
- package/src/agents/overlay.test.ts +190 -0
- package/src/agents/overlay.ts +212 -0
- package/src/agents/system-prompt.test.ts +53 -0
- package/src/agents/system-prompt.ts +95 -0
- package/src/agents/turn-runner.ts +79 -0
- package/src/commands/coordinator.test.ts +75 -0
- package/src/commands/coordinator.ts +259 -0
- package/src/commands/deploy.test.ts +504 -0
- package/src/commands/deploy.ts +874 -0
- package/src/commands/doctor.test.ts +106 -0
- package/src/commands/doctor.ts +208 -0
- package/src/commands/init.ts +71 -0
- package/src/commands/log.ts +51 -0
- package/src/commands/mail.ts +197 -0
- package/src/commands/merge.ts +127 -0
- package/src/commands/model.ts +58 -0
- package/src/commands/prime.ts +61 -0
- package/src/commands/reap.ts +87 -0
- package/src/commands/serve.ts +61 -0
- package/src/commands/setup.ts +48 -0
- package/src/commands/ship.test.ts +106 -0
- package/src/commands/ship.ts +202 -0
- package/src/commands/skill.test.ts +458 -0
- package/src/commands/skill.ts +730 -0
- package/src/commands/sling.ts +365 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/stop.ts +56 -0
- package/src/commands/tui.ts +199 -0
- package/src/commands/worktree.ts +77 -0
- package/src/config.test.ts +92 -0
- package/src/config.ts +202 -0
- package/src/db/sqlite.test.ts +77 -0
- package/src/db/sqlite.ts +102 -0
- package/src/deploy/audit.test.ts +233 -0
- package/src/deploy/audit.ts +245 -0
- package/src/deploy/context.test.ts +243 -0
- package/src/deploy/context.ts +72 -0
- package/src/deploy/registry.test.ts +101 -0
- package/src/deploy/registry.ts +86 -0
- package/src/deploy/secrets.test.ts +129 -0
- package/src/deploy/secrets.ts +69 -0
- package/src/deploy/targets/docker-gha.test.ts +323 -0
- package/src/deploy/targets/docker-gha.ts +841 -0
- package/src/deploy/types.ts +153 -0
- package/src/errors.test.ts +42 -0
- package/src/errors.ts +69 -0
- package/src/events/store.test.ts +183 -0
- package/src/events/store.ts +201 -0
- package/src/index.ts +137 -0
- package/src/insights/quality-gates.ts +73 -0
- package/src/json.test.ts +28 -0
- package/src/json.ts +50 -0
- package/src/logging/color.ts +62 -0
- package/src/logging/logger.ts +60 -0
- package/src/logging/sanitizer.test.ts +36 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/client.test.ts +192 -0
- package/src/mail/client.ts +188 -0
- package/src/mail/store.test.ts +279 -0
- package/src/mail/store.ts +311 -0
- package/src/merge/lock.test.ts +88 -0
- package/src/merge/lock.ts +84 -0
- package/src/merge/queue.test.ts +136 -0
- package/src/merge/queue.ts +177 -0
- package/src/merge/resolver.test.ts +219 -0
- package/src/merge/resolver.ts +274 -0
- package/src/paths.ts +36 -0
- package/src/providers/apply.test.ts +90 -0
- package/src/providers/apply.ts +66 -0
- package/src/providers/registry.test.ts +74 -0
- package/src/providers/registry.ts +254 -0
- package/src/runtimes/claude.ts +313 -0
- package/src/runtimes/codex.ts +280 -0
- package/src/runtimes/cursor.ts +247 -0
- package/src/runtimes/gemini.ts +173 -0
- package/src/runtimes/mock.ts +71 -0
- package/src/runtimes/opencode.ts +259 -0
- package/src/runtimes/registry.test.ts +924 -0
- package/src/runtimes/registry.ts +63 -0
- package/src/runtimes/resolve.ts +45 -0
- package/src/runtimes/types.ts +97 -0
- package/src/scaffold.ts +68 -0
- package/src/secrets.test.ts +51 -0
- package/src/secrets.ts +78 -0
- package/src/serve/api.ts +667 -0
- package/src/serve/server.test.ts +433 -0
- package/src/serve/server.ts +271 -0
- package/src/serve/system.ts +90 -0
- package/src/serve/weather.ts +140 -0
- package/src/sessions/reaper.test.ts +162 -0
- package/src/sessions/reaper.ts +149 -0
- package/src/sessions/store.test.ts +351 -0
- package/src/sessions/store.ts +350 -0
- package/src/skills/distiller.test.ts +498 -0
- package/src/skills/distiller.ts +426 -0
- package/src/skills/feedback.test.ts +300 -0
- package/src/skills/feedback.ts +168 -0
- package/src/skills/lifecycle.ts +169 -0
- package/src/skills/retrieval.test.ts +421 -0
- package/src/skills/retrieval.ts +365 -0
- package/src/skills/safety.test.ts +335 -0
- package/src/skills/safety.ts +216 -0
- package/src/skills/store.test.ts +425 -0
- package/src/skills/store.ts +684 -0
- package/src/skills/types.ts +107 -0
- package/src/types.ts +442 -0
- package/src/utils/detect.test.ts +35 -0
- package/src/utils/detect.ts +82 -0
- package/src/version.test.ts +19 -0
- package/src/version.ts +7 -0
- package/src/wizard/setup.ts +254 -0
- package/src/worktree/manager.test.ts +181 -0
- package/src/worktree/manager.ts +229 -0
- package/templates/overlay.md.tmpl +102 -0
- package/ui/dist/assets/index-C7rXIMER.css +1 -0
- package/ui/dist/assets/index-W4kbr4by.js +4526 -0
- package/ui/dist/favicon.svg +21 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/logo-clay.svg +21 -0
- 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
|
+
}
|