huddle-cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +102 -0
- package/README.md +376 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +54 -0
- package/dist/adapters/claude.js +86 -0
- package/dist/adapters/codex.js +64 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +92 -0
- package/dist/adapters/custom.js +342 -0
- package/dist/adapters/droid.js +58 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +57 -0
- package/dist/adapters/index.js +161 -0
- package/dist/adapters/kiro.js +41 -0
- package/dist/adapters/opencode.js +63 -0
- package/dist/adapters/qwen.js +67 -0
- package/dist/adapters/types.js +6 -0
- package/dist/adapters/usage.js +141 -0
- package/dist/attach/connect.js +412 -0
- package/dist/attach/guard.js +83 -0
- package/dist/attach/live.js +226 -0
- package/dist/attach/plug.js +185 -0
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +177 -0
- package/dist/cli.js +244 -0
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +267 -0
- package/dist/orchestrator/ask.js +54 -0
- package/dist/orchestrator/brain.js +174 -0
- package/dist/orchestrator/council.js +615 -0
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +171 -0
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +212 -0
- package/dist/orchestrator/qa.js +398 -0
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +1207 -0
- package/dist/orchestrator/takeover.js +240 -0
- package/dist/platform.js +273 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +680 -0
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +348 -0
- package/dist/room/mail.js +212 -0
- package/dist/room/progress.js +149 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +882 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +86 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +392 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1955 -0
- package/dist/ui/catalog.js +113 -0
- package/dist/ui/dashboard.js +335 -0
- package/dist/ui/doctor.js +58 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +338 -0
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +1529 -0
- package/dist/ui/term.js +249 -0
- package/dist/ui/watch.js +271 -0
- package/install.sh +44 -0
- package/package.json +63 -4
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// Sandboxes = git worktrees. Each claimed task gets its own working copy that
|
|
2
|
+
// shares the repo's object store, so isolation is cheap and "merging" is just
|
|
3
|
+
// git. The user never sees any of this — it's the hidden plumbing.
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { shellCommand } from "../platform.js";
|
|
8
|
+
/** true if the command exits 0, false if non-zero. */
|
|
9
|
+
function spawnSyncOk(cmd, args, cwd) {
|
|
10
|
+
try {
|
|
11
|
+
execFileSync(cmd, args, { cwd, stdio: "ignore" });
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function git(cwd, args) {
|
|
19
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
20
|
+
}
|
|
21
|
+
/** true if `a` is an ancestor of (or the same commit as) `b`. */
|
|
22
|
+
function isAncestor(cwd, a, b) {
|
|
23
|
+
return spawnSyncOk("git", ["merge-base", "--is-ancestor", a, b], cwd);
|
|
24
|
+
}
|
|
25
|
+
export function isGitRepo(dir) {
|
|
26
|
+
try {
|
|
27
|
+
git(dir, ["rev-parse", "--is-inside-work-tree"]);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create an isolated worktree for a task, branched off the current HEAD.
|
|
36
|
+
* Worktrees live under .huddle/worktrees/ so they're easy to clean up.
|
|
37
|
+
*/
|
|
38
|
+
/** Where a task's worktree lives — computed without touching disk. */
|
|
39
|
+
export function worktreeFor(repoRoot, agentId, taskId) {
|
|
40
|
+
const slug = `${agentId}-${taskId}`.replace(/[^\w.-]/g, "_");
|
|
41
|
+
return { branch: `huddle/${slug}`, path: path.join(repoRoot, ".huddle", "worktrees", slug) };
|
|
42
|
+
}
|
|
43
|
+
export function createWorktree(repoRoot, agentId, taskId) {
|
|
44
|
+
const { branch, path: wtPath } = worktreeFor(repoRoot, agentId, taskId);
|
|
45
|
+
fs.mkdirSync(path.dirname(wtPath), { recursive: true });
|
|
46
|
+
if (fs.existsSync(wtPath))
|
|
47
|
+
return { branch, path: wtPath };
|
|
48
|
+
// Reuse the branch if it already exists, else create it.
|
|
49
|
+
const branches = git(repoRoot, ["branch", "--list", branch]);
|
|
50
|
+
if (branches) {
|
|
51
|
+
git(repoRoot, ["worktree", "add", wtPath, branch]);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
git(repoRoot, ["worktree", "add", "-b", branch, wtPath, "HEAD"]);
|
|
55
|
+
}
|
|
56
|
+
return { branch, path: wtPath };
|
|
57
|
+
}
|
|
58
|
+
export function listWorktrees(repoRoot) {
|
|
59
|
+
try {
|
|
60
|
+
return git(repoRoot, ["worktree", "list", "--porcelain"])
|
|
61
|
+
.split(/\r?\n/)
|
|
62
|
+
.filter((l) => l.startsWith("worktree "))
|
|
63
|
+
// git prints C:/x/y on Windows; resolve() makes it C:\x\y, like path.join does.
|
|
64
|
+
.map((l) => path.resolve(l.slice("worktree ".length)));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Best-effort cleanup. Windows can't delete a file another process still has
|
|
72
|
+
* open (an agent that is just exiting, an editor, a virus scanner), so git
|
|
73
|
+
* fails with "Permission denied" / EBUSY: retry a few times, then delete the
|
|
74
|
+
* folder ourselves and let `git worktree prune` forget it.
|
|
75
|
+
*/
|
|
76
|
+
export function removeWorktree(repoRoot, wtPath, tries = process.platform === "win32" ? 4 : 1) {
|
|
77
|
+
for (let i = 0; i < tries; i++) {
|
|
78
|
+
try {
|
|
79
|
+
git(repoRoot, ["worktree", "remove", "--force", wtPath]);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
if (i < tries - 1)
|
|
84
|
+
sleepMs(250 * (i + 1));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (tries <= 1)
|
|
88
|
+
return;
|
|
89
|
+
try {
|
|
90
|
+
fs.rmSync(wtPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
/* still locked: prune below won't drop it, a later run's sweep will */
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
git(repoRoot, ["worktree", "prune"]);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* best effort */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function sleepMs(ms) {
|
|
103
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The merge engine, step 1: commit what the agent produced, run the test gate,
|
|
107
|
+
* and rebase onto the latest main. Nothing lands yet — so a human can look
|
|
108
|
+
* first in manual mode. Tests are the referee: nothing merges on agent vibes.
|
|
109
|
+
*/
|
|
110
|
+
export function prepareMerge(repoRoot, wt, testCmd, excludes = [], message = `huddle: ${wt.branch}`) {
|
|
111
|
+
// Commit — minus Huddle's own Plug wiring files, which must never reach history.
|
|
112
|
+
try {
|
|
113
|
+
// An exclusion for a path that's already ignored (e.g. `huddle connect` put
|
|
114
|
+
// it in .git/info/exclude) makes `git add` fail outright — skip those.
|
|
115
|
+
const live = excludes.filter((e) => spawnSyncOk("git", ["check-ignore", "-q", e], wt.path) === false);
|
|
116
|
+
git(wt.path, ["add", "-A", "--", ".", ...live.map((e) => `:!${e}`)]);
|
|
117
|
+
// Only commit if something is staged (delegated branches arrive committed).
|
|
118
|
+
const staged = spawnSyncOk("git", ["diff", "--cached", "--quiet"], wt.path) === false;
|
|
119
|
+
if (staged)
|
|
120
|
+
git(wt.path, ["commit", "-m", message]);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* nothing to commit */
|
|
124
|
+
}
|
|
125
|
+
if (testCmd) {
|
|
126
|
+
try {
|
|
127
|
+
// sh on macOS/Linux; cmd.exe on Windows, where `npm test` and friends run as-is.
|
|
128
|
+
const sh = shellCommand(testCmd, "cmd");
|
|
129
|
+
// (execFileSync passes windowsVerbatimArguments through to spawn; its types just don't list it)
|
|
130
|
+
execFileSync(sh.file, sh.args, { cwd: wt.path, stdio: "pipe", ...{ windowsVerbatimArguments: sh.verbatim } });
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
// The tail of what the tests printed: what the author needs to fix it.
|
|
134
|
+
const out = `${e?.stdout ?? ""}\n${e?.stderr ?? ""}`.trim();
|
|
135
|
+
return { ok: false, reason: `tests failed: ${String(e?.message ?? e).slice(0, 200)}`, diffstat: "", testOutput: out.slice(-3000) };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const mainBranch = git(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
|
|
139
|
+
if (!isAncestor(wt.path, mainBranch, "HEAD")) {
|
|
140
|
+
// A branch whose conflict was resolved carries a merge commit: replaying it
|
|
141
|
+
// with rebase would raise the same conflict again, so bring main in by merge.
|
|
142
|
+
const resolvedBefore = git(wt.path, ["rev-list", "--merges", `${mainBranch}..HEAD`]) !== "";
|
|
143
|
+
try {
|
|
144
|
+
if (resolvedBefore)
|
|
145
|
+
git(wt.path, ["merge", "--no-edit", mainBranch]);
|
|
146
|
+
else
|
|
147
|
+
git(wt.path, ["rebase", mainBranch]);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
spawnSyncOk("git", [resolvedBefore ? "merge" : "rebase", "--abort"], wt.path);
|
|
151
|
+
return { ok: false, conflict: true, reason: "conflict on rebase — needs resolver", diffstat: "" };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const diffstat = git(repoRoot, ["diff", "--stat", `${mainBranch}...${wt.branch}`]);
|
|
155
|
+
return { ok: true, reason: testCmd ? "tests green" : "no test command found", diffstat };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Write one file at the repo root and commit just that path on the current
|
|
159
|
+
* branch (what every task worktree branches off). Anything else the user has
|
|
160
|
+
* staged stays staged and out of this commit. false = it couldn't (the run goes on).
|
|
161
|
+
*/
|
|
162
|
+
export function commitFile(repoRoot, rel, content, message) {
|
|
163
|
+
const file = path.join(repoRoot, rel);
|
|
164
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file) : null;
|
|
165
|
+
// A Huddle doc commit, not the user's: their hooks (lint, commitlint, a test
|
|
166
|
+
// run) don't apply, and a signing prompt or slow hook can't hang the run.
|
|
167
|
+
const run = (args) => execFileSync("git", args, { cwd: repoRoot, stdio: "ignore", timeout: 30_000 });
|
|
168
|
+
try {
|
|
169
|
+
fs.writeFileSync(file, content);
|
|
170
|
+
run(["add", "--", rel]);
|
|
171
|
+
if (spawnSyncOk("git", ["diff", "--cached", "--quiet", "--", rel], repoRoot))
|
|
172
|
+
return true; // unchanged: already committed
|
|
173
|
+
run(["-c", "commit.gpgsign=false", "commit", "-q", "--no-verify", "--only", "-m", message, "--", rel]);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Leave the checkout as we found it: no half-staged file, no stray file.
|
|
178
|
+
try {
|
|
179
|
+
run(["reset", "-q", "--", rel]);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* no HEAD yet, or not a repo */
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
if (before)
|
|
186
|
+
fs.writeFileSync(file, before);
|
|
187
|
+
else
|
|
188
|
+
fs.rmSync(file, { force: true });
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* best effort */
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** The merge engine, step 2: fast-forward main to the prepared branch. */
|
|
197
|
+
export function landMerge(repoRoot, wt) {
|
|
198
|
+
const mainBranch = git(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
|
|
199
|
+
try {
|
|
200
|
+
git(repoRoot, ["merge", "--ff-only", wt.branch]);
|
|
201
|
+
return { merged: true, reason: "merged" };
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
// Usually: the user has uncommitted edits in the main checkout on the same files.
|
|
205
|
+
return { merged: false, reason: `couldn't fast-forward ${mainBranch}: ${String(e?.message ?? e).split(/\r?\n/)[0]}` };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** The prepared branch's changes vs main, for a reviewer to read. Capped. */
|
|
209
|
+
export function branchDiff(repoRoot, wt, maxChars = 24_000) {
|
|
210
|
+
const mainBranch = git(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
|
|
211
|
+
try {
|
|
212
|
+
const d = git(repoRoot, ["diff", `${mainBranch}...${wt.branch}`]);
|
|
213
|
+
return d.length > maxChars ? d.slice(0, maxChars) + "\n… (diff truncated)" : d;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** Lockfiles: listed as changed, but their diff would bury the real code in a capped prompt. */
|
|
220
|
+
const LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "Cargo.lock", "poetry.lock", "Gemfile.lock", "go.sum"];
|
|
221
|
+
/**
|
|
222
|
+
* Work in progress in a task's worktree, against where it branched off main:
|
|
223
|
+
* commits, staged and unstaged edits, and new untracked files (minus the Plug
|
|
224
|
+
* wiring in `excludes`). What a mid-task buddy check and a pairing helper read.
|
|
225
|
+
* Never throws: a broken worktree is just "no work yet".
|
|
226
|
+
*/
|
|
227
|
+
export function wipDiff(repoRoot, worktree, excludes = [], maxChars = 24_000) {
|
|
228
|
+
// Raw bytes for names (unicode/spaces stay readable), and room for a big WIP: execFileSync's 1MB default would throw.
|
|
229
|
+
const run = (cwd, args) => execFileSync("git", ["-c", "core.quotePath=false", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 });
|
|
230
|
+
const names = (out) => out.split("\0").filter(Boolean);
|
|
231
|
+
// Untracked symlinks (a linked node_modules, say) aren't work.
|
|
232
|
+
const isFile = (p) => {
|
|
233
|
+
try {
|
|
234
|
+
return fs.lstatSync(p).isFile();
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
try {
|
|
241
|
+
if (!fs.existsSync(worktree))
|
|
242
|
+
return { diff: "", files: [] };
|
|
243
|
+
const mainBranch = git(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
|
|
244
|
+
const base = git(worktree, ["merge-base", "HEAD", mainBranch]);
|
|
245
|
+
const ex = excludes.map((x) => `:(exclude)${x}`);
|
|
246
|
+
const skip = (f) => excludes.some((x) => f === x || f.startsWith(x.replace(/\/+$/, "") + "/"));
|
|
247
|
+
const tracked = names(run(worktree, ["diff", "--name-only", "-z", base, "--", ".", ...ex]));
|
|
248
|
+
const untracked = names(run(worktree, ["ls-files", "-z", "--others", "--exclude-standard"])).filter((f) => !skip(f) && isFile(path.join(worktree, f)));
|
|
249
|
+
let diff = run(worktree, ["diff", base, "--", ".", ...ex, ...LOCKFILES.map((l) => `:(exclude,glob)**/${l}`)]).trim();
|
|
250
|
+
for (const f of untracked) {
|
|
251
|
+
if (diff.length > maxChars)
|
|
252
|
+
break;
|
|
253
|
+
if (LOCKFILES.includes(path.basename(f)))
|
|
254
|
+
continue;
|
|
255
|
+
let body;
|
|
256
|
+
try {
|
|
257
|
+
const st = fs.statSync(path.join(worktree, f));
|
|
258
|
+
if (st.size > 256 * 1024)
|
|
259
|
+
body = `(${Math.round(st.size / 1024)} KB, not shown)`;
|
|
260
|
+
else {
|
|
261
|
+
const buf = fs.readFileSync(path.join(worktree, f));
|
|
262
|
+
body = buf.includes(0) ? "(binary)" : buf.toString("utf8").slice(0, 6000);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
diff += `\n--- /dev/null\n+++ b/${f} (new, uncommitted)\n` + body.split("\n").map((l) => "+" + l).join("\n");
|
|
269
|
+
}
|
|
270
|
+
diff = diff.trim();
|
|
271
|
+
return { diff: diff.length > maxChars ? diff.slice(0, maxChars) + "\n… (diff truncated)" : diff, files: [...new Set([...tracked, ...untracked])] };
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return { diff: "", files: [] };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const MARKER = /^(<{7}|>{7}|\|{7})( |$)/m;
|
|
278
|
+
function mergeInProgress(wtPath) {
|
|
279
|
+
return spawnSyncOk("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], wtPath);
|
|
280
|
+
}
|
|
281
|
+
function unmergedFiles(wtPath) {
|
|
282
|
+
return git(wtPath, ["diff", "--name-only", "--diff-filter=U"]).split(/\r?\n/).filter(Boolean);
|
|
283
|
+
}
|
|
284
|
+
/** A file's content at a commit, or null if it doesn't exist there. */
|
|
285
|
+
function showAt(cwd, rev, file) {
|
|
286
|
+
try {
|
|
287
|
+
return execFileSync("git", ["show", `${rev}:${file}`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Start merging main into the task branch; stops on conflicts with markers in
|
|
295
|
+
* the files. `files` empty = it merged cleanly (main moved on to something
|
|
296
|
+
* compatible) and the merge is already committed.
|
|
297
|
+
*/
|
|
298
|
+
export function beginConflictMerge(repoRoot, wt, message) {
|
|
299
|
+
const mainBranch = git(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
|
|
300
|
+
if (mergeInProgress(wt.path))
|
|
301
|
+
spawnSyncOk("git", ["merge", "--abort"], wt.path);
|
|
302
|
+
const ours = git(wt.path, ["rev-parse", "HEAD"]);
|
|
303
|
+
const theirs = git(repoRoot, ["rev-parse", mainBranch]);
|
|
304
|
+
const base = git(wt.path, ["merge-base", ours, theirs]);
|
|
305
|
+
try {
|
|
306
|
+
git(wt.path, ["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, theirs]);
|
|
307
|
+
return { files: [], ours, theirs, base };
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
const files = unmergedFiles(wt.path);
|
|
311
|
+
if (!files.length) {
|
|
312
|
+
// Not a content conflict (e.g. an untracked file in the way): undo and say so.
|
|
313
|
+
spawnSyncOk("git", ["merge", "--abort"], wt.path);
|
|
314
|
+
return { error: `couldn't merge ${mainBranch} into ${wt.branch}: ${String(e?.stderr ?? e?.message ?? e).trim().split(/\r?\n/)[0]}` };
|
|
315
|
+
}
|
|
316
|
+
return { files, ours, theirs, base };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Both sides of a conflict, for the resolver's prompt. Capped. */
|
|
320
|
+
export function conflictBrief(wt, c, maxPerFile = 6_000) {
|
|
321
|
+
const cap = (s, n) => (s.length > n ? s.slice(0, n) + "\n… (truncated)" : s);
|
|
322
|
+
const marked = c.files
|
|
323
|
+
.map((f) => {
|
|
324
|
+
const p = path.join(wt.path, f);
|
|
325
|
+
return `--- ${f}\n${fs.existsSync(p) ? cap(fs.readFileSync(p, "utf8"), maxPerFile) : "(deleted on one side)"}`;
|
|
326
|
+
})
|
|
327
|
+
.join("\n\n");
|
|
328
|
+
const diff = (to) => {
|
|
329
|
+
try {
|
|
330
|
+
return cap(git(wt.path, ["diff", c.base, to, "--", ...c.files]), maxPerFile * 2);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return "";
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
const theirCommits = git(wt.path, ["log", "--format=%s", `${c.base}..${c.theirs}`, "--", ...c.files]).split(/\r?\n/).filter(Boolean);
|
|
337
|
+
return { marked, oursDiff: diff(c.ours), theirsDiff: diff(c.theirs), theirCommits };
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Check the resolver's work and commit the merge. Refuses if markers remain,
|
|
341
|
+
* files are still unmerged, or a file is byte-identical to one side — taking
|
|
342
|
+
* one side wholesale drops the other agent's change.
|
|
343
|
+
*/
|
|
344
|
+
export function finishConflictMerge(wt, c, excludes = []) {
|
|
345
|
+
for (const f of c.files) {
|
|
346
|
+
const p = path.join(wt.path, f);
|
|
347
|
+
if (fs.existsSync(p) && MARKER.test(fs.readFileSync(p, "utf8")))
|
|
348
|
+
return { ok: false, reason: `conflict markers left in ${f}` };
|
|
349
|
+
}
|
|
350
|
+
if (mergeInProgress(wt.path)) {
|
|
351
|
+
git(wt.path, ["add", "-A", "--", ".", ...excludes.map((e) => `:!${e}`)]);
|
|
352
|
+
const left = unmergedFiles(wt.path);
|
|
353
|
+
if (left.length)
|
|
354
|
+
return { ok: false, reason: `still unmerged: ${left.join(", ")}` };
|
|
355
|
+
}
|
|
356
|
+
// The resolver may have committed on its own; judge what the worktree holds either way.
|
|
357
|
+
// Line endings don't count: with Windows' core.autocrlf the worktree has CRLF, `git show` LF.
|
|
358
|
+
const eol = (s) => (s === null ? null : s.replace(/\r\n/g, "\n"));
|
|
359
|
+
for (const f of c.files) {
|
|
360
|
+
const p = path.join(wt.path, f);
|
|
361
|
+
const now = eol(fs.existsSync(p) ? fs.readFileSync(p, "utf8") : null);
|
|
362
|
+
if (now === eol(showAt(wt.path, c.theirs, f)))
|
|
363
|
+
return { ok: false, reason: `${f} is just main's version — this task's change was dropped` };
|
|
364
|
+
if (now === eol(showAt(wt.path, c.ours, f)))
|
|
365
|
+
return { ok: false, reason: `${f} is just this task's version — main's change was dropped` };
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
if (mergeInProgress(wt.path))
|
|
369
|
+
git(wt.path, ["commit", "--no-edit"]);
|
|
370
|
+
else {
|
|
371
|
+
// Committed by the resolver: anything still uncommitted belongs with it.
|
|
372
|
+
git(wt.path, ["add", "-A", "--", ".", ...excludes.map((e) => `:!${e}`)]);
|
|
373
|
+
if (!spawnSyncOk("git", ["diff", "--cached", "--quiet"], wt.path))
|
|
374
|
+
git(wt.path, ["commit", "-m", "huddle: finish conflict resolution"]);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch (e) {
|
|
378
|
+
return { ok: false, reason: `couldn't commit the resolution: ${String(e?.message ?? e).split(/\r?\n/)[0]}` };
|
|
379
|
+
}
|
|
380
|
+
if (!isAncestor(wt.path, c.theirs, "HEAD") || !isAncestor(wt.path, c.ours, "HEAD"))
|
|
381
|
+
return { ok: false, reason: "the merge was abandoned (main's or this task's commits are missing from the branch)" };
|
|
382
|
+
return { ok: true, reason: `resolved ${c.files.join(", ")}` };
|
|
383
|
+
}
|
|
384
|
+
/** Give up on a resolution: the branch goes back to exactly the task's own commits. */
|
|
385
|
+
export function abortConflictMerge(wt, c) {
|
|
386
|
+
if (mergeInProgress(wt.path))
|
|
387
|
+
spawnSyncOk("git", ["merge", "--abort"], wt.path);
|
|
388
|
+
// A bad resolution the resolver committed itself: drop it (it stays in the reflog).
|
|
389
|
+
if (git(wt.path, ["rev-parse", "HEAD"]) !== c.ours)
|
|
390
|
+
spawnSyncOk("git", ["reset", "--hard", c.ours], wt.path);
|
|
391
|
+
}
|
|
392
|
+
//# sourceMappingURL=worktree.js.map
|
package/dist/types.js
ADDED