dsh-plugin-worktrees 0.1.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.
@@ -0,0 +1,773 @@
1
+ /**
2
+ * GitPort — the git operations abstraction the WorktreeService and MergeQueue
3
+ * depend on.
4
+ *
5
+ * AGENTS.md hard rule: ALL git commands use argv arrays, NEVER shell strings.
6
+ * The production {@link NodeGitPort} uses `node:child_process` `spawn` with
7
+ * `shell: false`. Every command is bounded by a timeout (15s default) and a
8
+ * capped output buffer.
9
+ *
10
+ * The port returns STRUCTURED results and never throws on a non-zero git exit
11
+ * status — the caller inspects `status` / `ok` to decide how to classify the
12
+ * failure. A throw is reserved for a true spawn failure (binary not found,
13
+ * timeout killed) which the caller surfaces as `git_not_available`.
14
+ *
15
+ * Ported line-for-line from task-weaver `packages/workspaces/src/git-port.ts`
16
+ * (TS type erasure only) with the DESIGN §4.1 rewrites:
17
+ * - class renamed `BunGitPort` → `NodeGitPort` (it never depended on Bun —
18
+ * the source imports `node:child_process`; the rename is honesty, not a
19
+ * behaviour change);
20
+ * - the private mkdtemp `worktreePath()` layout was CUT — the worktree path
21
+ * decision belongs to the caller (WorktreeService's explicit layout);
22
+ * - new integration-queue methods: `listWorktrees` / `isAncestor` /
23
+ * `validateBranch` / `mergeNoFf` / `abortMerge`;
24
+ * - `createGitPort({ spawnImpl })` factory as the spawn injection seam.
25
+ */
26
+ import { spawn } from "node:child_process";
27
+
28
+ /** Default per-command timeout (matches the testkit fixture bound). */
29
+ const DEFAULT_GIT_TIMEOUT_MS = 15_000;
30
+
31
+ /** Cap stdout/stderr capture at 8 MiB to bound memory. */
32
+ const MAX_BUFFER = 8 * 1024 * 1024;
33
+
34
+ /**
35
+ * Raw git command result: stdout/stderr captured verbatim + exit status.
36
+ *
37
+ * @typedef {object} GitCommandResult
38
+ * @property {string} stdout captured stdout (utf8).
39
+ * @property {string} stderr captured stderr (utf8); carries the
40
+ * `[output capped at 8 MiB]` marker when either stream hit the cap.
41
+ * @property {number} status git exit status (0 on success).
42
+ */
43
+
44
+ /**
45
+ * Resolved HEAD of a repository.
46
+ *
47
+ * @typedef {object} GitHead
48
+ * @property {string} commit full commit oid of HEAD.
49
+ * @property {string} branch current branch name (detached HEAD reports
50
+ * `HEAD`).
51
+ * @property {boolean} dirty true when the working tree has uncommitted
52
+ * changes.
53
+ */
54
+
55
+ /**
56
+ * Outcome of a worktree creation.
57
+ *
58
+ * @typedef {object} WorktreeCreation
59
+ * @property {string} path absolute path of the new working tree.
60
+ * @property {string} branch branch the worktree was created on.
61
+ */
62
+
63
+ /**
64
+ * Outcome of a commit operation.
65
+ *
66
+ * @typedef {object} CommitResult
67
+ * @property {string} commit new HEAD commit oid after the commit.
68
+ * @property {readonly string[]} changedFiles files changed in this commit
69
+ * (relative paths).
70
+ * @property {readonly GitStatusEntry[]} statusEntries files changed in this
71
+ * commit with status codes, derived from `git diff-tree --name-status -r`.
72
+ * Each entry's `path` is the post-rename target. Used by the change
73
+ * collector to populate the artifact's `changedFiles[].status` accurately
74
+ * for committed changes (porcelain v1 collapses untracked directories
75
+ * pre-commit).
76
+ */
77
+
78
+ /**
79
+ * A single file change reported by `git status --porcelain`.
80
+ *
81
+ * `statusXY` is the raw 2-char porcelain status code (e.g. `M `, ` A`, `MM`,
82
+ * `R `, `D `); callers usually consume the normalised `status` field.
83
+ *
84
+ * @typedef {object} GitStatusEntry
85
+ * @property {string} path relative path of the changed file (post-rename
86
+ * target for renames).
87
+ * @property {"added"|"modified"|"deleted"|"renamed"|"copied"|"type_changed"|"unknown"} status
88
+ * normalised change status mapped to the artifact `changedFiles[].status`
89
+ * picklist. Falls back to `unknown` for ambiguous combinations.
90
+ * @property {string} statusXY raw 2-char porcelain status code (XY).
91
+ */
92
+
93
+ /**
94
+ * One worktree reported by `git worktree list --porcelain`.
95
+ *
96
+ * @typedef {object} WorktreeListEntry
97
+ * @property {string} path absolute worktree path (git reports the
98
+ * realpath).
99
+ * @property {string} head HEAD commit oid; empty string when git does not
100
+ * report one (e.g. a bare worktree).
101
+ * @property {string} branch short branch name (`refs/heads/` prefix
102
+ * stripped); empty string for detached-HEAD / bare worktrees.
103
+ * @property {boolean} detached true when the worktree is on a detached HEAD.
104
+ */
105
+
106
+ /**
107
+ * Run git in `cwd` with an argv array via `spawn` (shell disabled). Rejects
108
+ * ONLY on spawn failure or timeout; a non-zero exit status is a normal result
109
+ * the caller inspects via `status`.
110
+ *
111
+ * `env` merges over `process.env` so callers can set GIT_AUTHOR_* etc.
112
+ *
113
+ * @param {string} cwd
114
+ * @param {readonly string[]} args
115
+ * @param {{env?: Record<string, string|undefined>, timeoutMs?: number}} [opts]
116
+ * @returns {Promise<GitCommandResult>}
117
+ */
118
+ export function runGit(cwd, args, opts = {}) {
119
+ return new Promise((resolve, reject) => {
120
+ const child = spawn("git", [...args], {
121
+ cwd,
122
+ stdio: ["ignore", "pipe", "pipe"],
123
+ env: { ...process.env, ...opts.env },
124
+ shell: false,
125
+ });
126
+ const stdoutChunks = [];
127
+ const stderrChunks = [];
128
+ let stdoutBytes = 0;
129
+ let stderrBytes = 0;
130
+ let stdoutCapped = false;
131
+ let stderrCapped = false;
132
+ child.stdout?.on("data", (chunk) => {
133
+ if (stdoutCapped) return;
134
+ const remaining = MAX_BUFFER - stdoutBytes;
135
+ if (remaining <= 0) {
136
+ stdoutCapped = true;
137
+ return;
138
+ }
139
+ if (chunk.length <= remaining) {
140
+ stdoutChunks.push(chunk);
141
+ stdoutBytes += chunk.length;
142
+ } else {
143
+ // Admit the partial remainder that still fits instead of dropping it.
144
+ stdoutChunks.push(chunk.subarray(0, remaining));
145
+ stdoutBytes = MAX_BUFFER;
146
+ stdoutCapped = true;
147
+ }
148
+ });
149
+ child.stderr?.on("data", (chunk) => {
150
+ if (stderrCapped) return;
151
+ const remaining = MAX_BUFFER - stderrBytes;
152
+ if (remaining <= 0) {
153
+ stderrCapped = true;
154
+ return;
155
+ }
156
+ if (chunk.length <= remaining) {
157
+ stderrChunks.push(chunk);
158
+ stderrBytes += chunk.length;
159
+ } else {
160
+ stderrChunks.push(chunk.subarray(0, remaining));
161
+ stderrBytes = MAX_BUFFER;
162
+ stderrCapped = true;
163
+ }
164
+ });
165
+ const timer = setTimeout(() => {
166
+ child.kill("SIGKILL");
167
+ }, opts.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS);
168
+ child.on("error", (err) => {
169
+ clearTimeout(timer);
170
+ reject(err);
171
+ });
172
+ child.on("close", (code, signal) => {
173
+ clearTimeout(timer);
174
+ if (signal === "SIGKILL") {
175
+ reject(new Error(`git ${args.join(" ")} timed out`));
176
+ return;
177
+ }
178
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8");
179
+ let stderr = Buffer.concat(stderrChunks).toString("utf8");
180
+ if (stdoutCapped || stderrCapped) {
181
+ stderr += "\n[output capped at 8 MiB]";
182
+ }
183
+ resolve({ stdout, stderr, status: code ?? 0 });
184
+ });
185
+ });
186
+ }
187
+
188
+ /** Trim trailing whitespace from git stdout (oids, branch names). */
189
+ function trim(s) {
190
+ return s.replace(/\s+$/, "");
191
+ }
192
+
193
+ /**
194
+ * Parse `git status --porcelain` output into {@link GitStatusEntry} rows.
195
+ *
196
+ * Porcelain v1 line format: `XY <path>` or `XY <orig> -> <path>` for renames,
197
+ * where XY is the 2-char status code (index state = X, working-tree state = Y).
198
+ * A space in either position means "no change in that area".
199
+ *
200
+ * Status mapping (workspace-and-artifacts.md changedFiles picklist):
201
+ * - `R` (rename, X or Y) → `renamed`
202
+ * - `A` (index add) / `?` (untracked) → `added`
203
+ * - `D` (delete, X or Y) → `deleted`
204
+ * - `M` / `T` / `C` / anything else → `modified`
205
+ *
206
+ * The post-rename target path is what the artifact records (the file's actual
207
+ * location after the commit).
208
+ *
209
+ * @param {string} stdout
210
+ * @returns {GitStatusEntry[]}
211
+ */
212
+ export function parseGitStatusPorcelain(stdout) {
213
+ const out = [];
214
+ const lines = stdout.split("\n");
215
+ for (const raw of lines) {
216
+ if (raw.length === 0) continue;
217
+ // Each porcelain line is exactly: XY<space><path> (3-char prefix minimum).
218
+ if (raw.length < 3) continue;
219
+ const xy = raw.slice(0, 2);
220
+ const body = raw.slice(3);
221
+ const x = xy.charAt(0);
222
+ const y = xy.charAt(1);
223
+
224
+ // Rename/copy lines use the form: <orig> -> <target>
225
+ // M2 fix: also check y (worktree status) for R/C, not just x (index status).
226
+ let path;
227
+ if (x === "R" || x === "C" || y === "R" || y === "C") {
228
+ const arrow = body.indexOf(" -> ");
229
+ path = arrow >= 0 ? body.slice(arrow + 4) : body;
230
+ } else {
231
+ path = body;
232
+ }
233
+ // Strip surrounding quotes (porcelain quotes paths with special chars).
234
+ if (path.startsWith('"') && path.endsWith('"') && path.length >= 2) {
235
+ path = path.slice(1, -1);
236
+ }
237
+ if (path.length === 0) continue;
238
+
239
+ let status;
240
+ if (x === "R" || y === "R") status = "renamed";
241
+ else if (x === "A" || y === "A" || x === "?") status = "added";
242
+ else if (x === "D" || y === "D") status = "deleted";
243
+ else status = "modified";
244
+
245
+ out.push({ path, status, statusXY: xy });
246
+ }
247
+ return out;
248
+ }
249
+
250
+ /**
251
+ * Parse `git diff-tree --name-status -r` (or `git diff --name-status`) output.
252
+ *
253
+ * Line format: `<status>\t<path>` or `R<tens>\t<orig>\t<target>` for renames
254
+ * (the rename-similarity percentage is optional and ignored here). The
255
+ * resulting {@link GitStatusEntry.path} is the post-rename target.
256
+ *
257
+ * Status letters (gitsinks/git-status):
258
+ * - A → added, M → modified, D → deleted, R → renamed, C → copied,
259
+ * T → type_changed. Unrecognised letters fall back to `unknown`.
260
+ *
261
+ * @param {string} stdout
262
+ * @returns {GitStatusEntry[]}
263
+ */
264
+ export function parseGitNameStatus(stdout) {
265
+ const out = [];
266
+ const lines = stdout.split("\n");
267
+ for (const raw of lines) {
268
+ if (raw.length === 0) continue;
269
+ const tab = raw.indexOf("\t");
270
+ if (tab < 0) continue;
271
+ const code = raw.slice(0, tab);
272
+ const rest = raw.slice(tab + 1);
273
+ const letter = code.charAt(0);
274
+
275
+ let path;
276
+ if (letter === "R" || letter === "C") {
277
+ const arrow = rest.indexOf("\t");
278
+ path = arrow >= 0 ? rest.slice(arrow + 1) : rest;
279
+ } else {
280
+ path = rest;
281
+ }
282
+ if (path.length === 0) continue;
283
+
284
+ let status;
285
+ if (letter === "R") status = "renamed";
286
+ else if (letter === "A") status = "added";
287
+ else if (letter === "D") status = "deleted";
288
+ else if (letter === "C") status = "copied";
289
+ else if (letter === "T") status = "type_changed";
290
+ else if (letter === "M") status = "modified";
291
+ else status = "unknown";
292
+
293
+ out.push({ path, status, statusXY: code });
294
+ }
295
+ return out;
296
+ }
297
+
298
+ /**
299
+ * Parse `git worktree list --porcelain` output into {@link WorktreeListEntry}
300
+ * rows (new in dsh-worktrees; consumed by list/status and crash reconcile).
301
+ *
302
+ * Porcelain format: one blank-line-separated block per worktree; each block
303
+ * is a sequence of attribute lines —
304
+ * - `worktree <path>` absolute path (git reports the realpath),
305
+ * - `HEAD <sha>` the worktree's HEAD commit,
306
+ * - `branch refs/heads/<name>` checked-out branch (short name recorded),
307
+ * - `detached` bare marker line for detached-HEAD worktrees.
308
+ * Unknown attribute lines (e.g. `bare`, `prunable ...` on newer gits) are
309
+ * ignored so parsing survives format additions. A block without a `worktree`
310
+ * line is dropped.
311
+ *
312
+ * @param {string} stdout
313
+ * @returns {WorktreeListEntry[]}
314
+ */
315
+ export function parseWorktreeListPorcelain(stdout) {
316
+ const out = [];
317
+ let current = null;
318
+ const flush = () => {
319
+ if (current !== null && current.path.length > 0) out.push(current);
320
+ current = null;
321
+ };
322
+ for (const raw of stdout.split("\n")) {
323
+ if (raw.length === 0) {
324
+ // Blank line terminates the current block.
325
+ flush();
326
+ continue;
327
+ }
328
+ if (current === null) {
329
+ current = { path: "", head: "", branch: "", detached: false };
330
+ }
331
+ if (raw.startsWith("worktree ")) {
332
+ current.path = raw.slice("worktree ".length);
333
+ } else if (raw.startsWith("HEAD ")) {
334
+ current.head = raw.slice("HEAD ".length);
335
+ } else if (raw.startsWith("branch ")) {
336
+ const ref = raw.slice("branch ".length);
337
+ current.branch = ref.startsWith("refs/heads/")
338
+ ? ref.slice("refs/heads/".length)
339
+ : ref;
340
+ } else if (raw === "detached") {
341
+ current.detached = true;
342
+ }
343
+ // Unknown attribute lines are ignored (forward compatibility).
344
+ }
345
+ flush();
346
+ return out;
347
+ }
348
+
349
+ /**
350
+ * Production GitPort backed by `node:child_process` spawn (shell disabled).
351
+ *
352
+ * Uses the SAME argv patterns as the testkit fixture
353
+ * (packages/testkit/src/git-fixture.ts) so behaviour is consistent across the
354
+ * fixture-driven tests and production.
355
+ */
356
+ export class NodeGitPort {
357
+ /**
358
+ * @param {{env?: Record<string, string|undefined>, timeoutMs?: number,
359
+ * spawnImpl?: typeof runGit}} [opts] `spawnImpl` overrides the raw git
360
+ * runner (injection seam; defaults to {@link runGit}).
361
+ */
362
+ constructor(opts = {}) {
363
+ this.env = { ...process.env, GIT_TERMINAL_PROMPT: "0", ...opts.env };
364
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
365
+ this.spawn = opts.spawnImpl ?? runGit;
366
+ }
367
+
368
+ /** True when `cwd` is inside a git repository (`.git` resolves). */
369
+ async isGitRepo(cwd) {
370
+ const r = await this.spawn(cwd, ["rev-parse", "--is-inside-work-tree"], {
371
+ env: this.env,
372
+ timeoutMs: this.timeoutMs,
373
+ }).catch(() => null);
374
+ return r !== null && r.status === 0 && trim(r.stdout) === "true";
375
+ }
376
+
377
+ /** Resolve HEAD commit, current branch, and dirty state for `cwd`.
378
+ * @returns {Promise<GitHead>} */
379
+ async resolveHead(cwd) {
380
+ const head = await this.spawn(cwd, ["rev-parse", "HEAD"], {
381
+ env: this.env,
382
+ timeoutMs: this.timeoutMs,
383
+ });
384
+ const branch = await this.spawn(cwd, ["rev-parse", "--abbrev-ref", "HEAD"], {
385
+ env: this.env,
386
+ timeoutMs: this.timeoutMs,
387
+ });
388
+ const status = await this.spawn(cwd, ["status", "--porcelain"], {
389
+ env: this.env,
390
+ timeoutMs: this.timeoutMs,
391
+ });
392
+ return {
393
+ commit: trim(head.stdout),
394
+ branch: trim(branch.stdout),
395
+ dirty: status.stdout.length > 0,
396
+ };
397
+ }
398
+
399
+ /**
400
+ * Resolve an arbitrary ref (branch, tag, commit oid, or `HEAD`) to its
401
+ * concrete commit oid. Uses an argv array with an explicit end-of-options
402
+ * boundary so a caller-supplied ref can never inject flags. Throws when the
403
+ * ref does not resolve (the caller MUST fail closed).
404
+ */
405
+ async resolveRef(cwd, ref) {
406
+ // End-of-options boundary prevents a caller-supplied ref from injecting
407
+ // flags. rev-parse resolves branches, tags, commit oids and HEAD alike.
408
+ const r = await this.spawn(cwd, ["rev-parse", "--verify", "--end-of-options", ref + "^{commit}"], {
409
+ env: this.env,
410
+ timeoutMs: this.timeoutMs,
411
+ });
412
+ if (r.status !== 0) {
413
+ throw new Error(`git resolveRef failed for "${ref}": ${trim(r.stderr)}`);
414
+ }
415
+ return trim(r.stdout);
416
+ }
417
+
418
+ /**
419
+ * Create a git worktree at an EXPLICIT `path` on a NEW `branch` off
420
+ * `baseCommit` (or HEAD when omitted). The `path` parent MUST already
421
+ * exist; the leaf is created by git.
422
+ *
423
+ * (dsh-worktrees rewrite: the task-weaver original resolved the path itself
424
+ * via a private mkdtemp `worktreePath(branch)` layout — cut per DESIGN §4.1;
425
+ * the path decision belongs to the caller.)
426
+ *
427
+ * @returns {Promise<WorktreeCreation>}
428
+ */
429
+ async createWorktree(repoRoot, path, branch, baseCommit) {
430
+ // git worktree add -b <branch> <path> [<base-commit>]
431
+ // The caller resolves a concrete absolute path BEFORE calling; the leaf
432
+ // directory is created by git. We pass the path verbatim (no shell quoting).
433
+ const args = ["worktree", "add", "-b", branch, path];
434
+ if (baseCommit !== undefined && baseCommit.length > 0) args.push(baseCommit);
435
+ const r = await this.spawn(repoRoot, args, { env: this.env, timeoutMs: this.timeoutMs });
436
+ if (r.status !== 0) {
437
+ throw new Error(
438
+ `git worktree add failed (status ${r.status}): ${r.stderr || r.stdout}`,
439
+ );
440
+ }
441
+ return { path, branch };
442
+ }
443
+
444
+ /** Remove a worktree (force). Idempotent: a missing worktree is a no-op. */
445
+ async removeWorktree(repoRoot, path) {
446
+ // --force so a dirty/unlocked worktree still removes; idempotent on missing.
447
+ await this.spawn(repoRoot, ["worktree", "remove", "--force", path], {
448
+ env: this.env,
449
+ timeoutMs: this.timeoutMs,
450
+ }).catch(() => {
451
+ // Swallow: a missing worktree is a no-op (the caller wants it gone).
452
+ });
453
+ }
454
+
455
+ /** Stage all changes and commit; returns the new HEAD + changed files.
456
+ * @returns {Promise<CommitResult>} */
457
+ async commitAll(cwd, message) {
458
+ const addR = await this.spawn(cwd, ["add", "-A"], { env: this.env, timeoutMs: this.timeoutMs });
459
+ if (addR.status !== 0) {
460
+ throw new Error(`git add -A failed (status ${addR.status}): ${addR.stderr}`);
461
+ }
462
+ const commitR = await this.spawn(cwd, ["commit", "-m", message], {
463
+ env: this.env,
464
+ timeoutMs: this.timeoutMs,
465
+ });
466
+ if (commitR.status !== 0) {
467
+ // A commit with nothing staged exits non-zero; surface as an empty commit.
468
+ throw new Error(
469
+ `git commit failed (status ${commitR.status}): ${commitR.stderr || commitR.stdout}`,
470
+ );
471
+ }
472
+ const head = await this.spawn(cwd, ["rev-parse", "HEAD"], {
473
+ env: this.env,
474
+ timeoutMs: this.timeoutMs,
475
+ });
476
+ const names = await this.spawn(
477
+ cwd,
478
+ ["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
479
+ { env: this.env, timeoutMs: this.timeoutMs },
480
+ );
481
+ const changedFiles = names.stdout
482
+ .split("\n")
483
+ .map((l) => l.trim())
484
+ .filter((l) => l.length > 0);
485
+ // Per-file status from `--name-status` (R/C lines carry `orig -> target`).
486
+ const statusOut = await this.spawn(
487
+ cwd,
488
+ ["diff-tree", "--no-commit-id", "--name-status", "-r", "HEAD"],
489
+ { env: this.env, timeoutMs: this.timeoutMs },
490
+ );
491
+ const statusEntries = parseGitNameStatus(statusOut.stdout);
492
+ return { commit: trim(head.stdout), changedFiles, statusEntries };
493
+ }
494
+
495
+ /** Checkout `ref` in `cwd`. Throws when the checkout fails. */
496
+ async checkout(cwd, ref) {
497
+ const r = await this.spawn(cwd, ["checkout", ref], { env: this.env, timeoutMs: this.timeoutMs });
498
+ if (r.status !== 0) {
499
+ throw new Error(`git checkout ${ref} failed (status ${r.status}): ${r.stderr}`);
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Cherry-pick `commit` onto the current branch in `cwd`. Resolves on success;
505
+ * rejects on a git failure (a non-zero exit includes conflicts — inspect
506
+ * {@link NodeGitPort#listConflicts} to classify).
507
+ */
508
+ async cherryPick(cwd, commit) {
509
+ // argv-only (AGENTS.md hard rule). A non-zero exit signals a conflict or a
510
+ // real failure — the caller inspects listConflicts() to classify. We do not
511
+ // pass --strategy options so a conflict surfaces the native git state.
512
+ const r = await this.spawn(cwd, ["cherry-pick", commit], {
513
+ env: this.env,
514
+ timeoutMs: this.timeoutMs,
515
+ });
516
+ if (r.status !== 0) {
517
+ throw new Error(
518
+ `git cherry-pick ${commit} failed (status ${r.status}): ${r.stderr || r.stdout}`,
519
+ );
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Resolve the merge-base (common ancestor) of two commits/refs. Returns the
525
+ * commit oid, or throws if git cannot resolve them.
526
+ */
527
+ async mergeBase(cwd, a, b) {
528
+ const r = await this.spawn(cwd, ["merge-base", a, b], {
529
+ env: this.env,
530
+ timeoutMs: this.timeoutMs,
531
+ });
532
+ if (r.status !== 0) {
533
+ throw new Error(
534
+ `git merge-base ${a} ${b} failed (status ${r.status}): ${r.stderr}`,
535
+ );
536
+ }
537
+ return trim(r.stdout);
538
+ }
539
+
540
+ /**
541
+ * List files in conflict in `cwd` via `git diff --name-only --diff-filter=U`.
542
+ * Returns the relative paths of unmerged files (empty when the tree is clean
543
+ * or the cherry-pick/merge succeeded).
544
+ */
545
+ async listConflicts(cwd) {
546
+ const r = await this.spawn(
547
+ cwd,
548
+ ["diff", "--name-only", "--diff-filter=U"],
549
+ { env: this.env, timeoutMs: this.timeoutMs },
550
+ );
551
+ if (r.status !== 0) {
552
+ // No conflicts to list is a normal state — surface an empty list rather
553
+ // than throwing so the merge queue can call this defensively.
554
+ return [];
555
+ }
556
+ return r.stdout
557
+ .split("\n")
558
+ .map((l) => l.trim())
559
+ .filter((l) => l.length > 0);
560
+ }
561
+
562
+ /**
563
+ * Add a worktree at an EXPLICIT `path` checking out `ref` (an existing
564
+ * branch). Used to provision the integration worktree at a deterministic
565
+ * path. The parent of `path` MUST exist; the leaf is created by git. Unlike
566
+ * {@link NodeGitPort#createWorktree} this does NOT create a new branch — it
567
+ * checks out an existing one.
568
+ */
569
+ async addWorktreeAt(repoRoot, path, ref) {
570
+ // git worktree add <path> <ref> (ref must already exist as a branch).
571
+ const r = await this.spawn(repoRoot, ["worktree", "add", path, ref], {
572
+ env: this.env,
573
+ timeoutMs: this.timeoutMs,
574
+ });
575
+ if (r.status !== 0) {
576
+ throw new Error(
577
+ `git worktree add ${path} ${ref} failed (status ${r.status}): ${r.stderr || r.stdout}`,
578
+ );
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Ensure `branch` exists in `repoRoot`. When missing, create it at
584
+ * `startPoint` (default HEAD). Used to bootstrap the integration branch on
585
+ * first apply. Callers MUST vet caller-supplied names with
586
+ * {@link NodeGitPort#validateBranch} first (a leading-dash name would land
587
+ * in flag position for `git branch`).
588
+ */
589
+ async ensureBranch(repoRoot, branch, startPoint = "HEAD") {
590
+ // Existence probe uses an explicit end-of-options boundary so a
591
+ // caller-supplied branch name can never inject flags (it is vetted by
592
+ // validateBranch upstream, but the boundary is the same belt-and-braces
593
+ // resolveRef applies — AGENTS.md red line 2).
594
+ const exists = await this.spawn(repoRoot, ["rev-parse", "--verify", "--end-of-options", branch], {
595
+ env: this.env,
596
+ timeoutMs: this.timeoutMs,
597
+ });
598
+ if (exists.status === 0) return;
599
+ const r = await this.spawn(repoRoot, ["branch", branch, startPoint], {
600
+ env: this.env,
601
+ timeoutMs: this.timeoutMs,
602
+ });
603
+ if (r.status !== 0) {
604
+ throw new Error(
605
+ `git branch ${branch} ${startPoint} failed (status ${r.status}): ${r.stderr || r.stdout}`,
606
+ );
607
+ }
608
+ }
609
+
610
+ /**
611
+ * List working-tree changes via `git status --porcelain`. Returns an empty
612
+ * array when the tree is clean.
613
+ */
614
+ async status(cwd) {
615
+ const r = await this.spawn(cwd, ["status", "--porcelain"], {
616
+ env: this.env,
617
+ timeoutMs: this.timeoutMs,
618
+ });
619
+ if (r.status !== 0) {
620
+ throw new Error(`git status failed (status ${r.status}): ${r.stderr}`);
621
+ }
622
+ return parseGitStatusPorcelain(r.stdout);
623
+ }
624
+
625
+ // -------------------------------------------------------------------------
626
+ // New in dsh-worktrees (DESIGN §4.1): integration-queue support methods.
627
+ // All argv arrays; caller-controlled names sit after `--` or in an option's
628
+ // value slot, never in flag position.
629
+ // -------------------------------------------------------------------------
630
+
631
+ /**
632
+ * List the repo's worktrees via `git worktree list --porcelain`, parsed by
633
+ * {@link parseWorktreeListPorcelain}. Feeds worktree list/status and the
634
+ * crash reconcile (state file × live worktree comparison).
635
+ *
636
+ * @param {string} repoRoot
637
+ * @returns {Promise<WorktreeListEntry[]>}
638
+ */
639
+ async listWorktrees(repoRoot) {
640
+ const r = await this.spawn(repoRoot, ["worktree", "list", "--porcelain"], {
641
+ env: this.env,
642
+ timeoutMs: this.timeoutMs,
643
+ });
644
+ if (r.status !== 0) {
645
+ throw new Error(`git worktree list failed (status ${r.status}): ${r.stderr}`);
646
+ }
647
+ return parseWorktreeListPorcelain(r.stdout);
648
+ }
649
+
650
+ /**
651
+ * True when commit `a` is an ancestor of (or equal to) commit `b`.
652
+ * `git merge-base --is-ancestor` is a two-state probe: exit 0 = yes,
653
+ * exit 1 = no — both are NORMAL results mapped to a boolean. Any other
654
+ * non-zero exit (e.g. an unresolvable ref) is a real failure and throws,
655
+ * same style as {@link NodeGitPort#mergeBase}. Used by cleanup's
656
+ * unmerged-work protection check.
657
+ */
658
+ async isAncestor(repoRoot, a, b) {
659
+ const r = await this.spawn(repoRoot, ["merge-base", "--is-ancestor", a, b], {
660
+ env: this.env,
661
+ timeoutMs: this.timeoutMs,
662
+ });
663
+ if (r.status === 0) return true;
664
+ if (r.status === 1) return false;
665
+ throw new Error(
666
+ `git merge-base --is-ancestor ${a} ${b} failed (status ${r.status}): ${r.stderr}`,
667
+ );
668
+ }
669
+
670
+ /**
671
+ * True when `name` is a valid branch name (`git check-ref-format --branch`,
672
+ * exit 0 = valid). Front-door guard for model-supplied task slugs before
673
+ * they reach createWorktree/ensureBranch/mergeNoFf — rejects `..`, spaces,
674
+ * leading `-`, control characters, and ambiguous names.
675
+ */
676
+ async validateBranch(repoRoot, name) {
677
+ const r = await this.spawn(repoRoot, ["check-ref-format", "--branch", name], {
678
+ env: this.env,
679
+ timeoutMs: this.timeoutMs,
680
+ });
681
+ return r.status === 0;
682
+ }
683
+
684
+ /**
685
+ * Merge `branch` into the current branch of `cwd` with `--no-ff`, creating
686
+ * a merge commit carrying `message`. Rejects on a non-zero exit (a conflict
687
+ * included) — the caller inspects {@link NodeGitPort#listConflicts} to
688
+ * classify, exactly like cherryPick. The branch sits AFTER the `--`
689
+ * end-of-options terminator so a caller-supplied name can never inject
690
+ * flags (and it is vetted by validateBranch upstream anyway).
691
+ *
692
+ * `opts.timeoutMs` OVERRIDES the port-level per-command budget for this
693
+ * one call (DESIGN §8.1 `mergeTimeoutMs`: merges on big repos legitimately
694
+ * need a budget of their own, separated from the general 15s gitTimeoutMs).
695
+ * The override is passed straight to {@link runGit}, which SIGKILLs the
696
+ * merge process on expiry — the killed merge then surfaces as a non-zero
697
+ * exit here and the caller's normal conflict/hard-failure classification
698
+ * applies (a timed-out merge leaves no conflict state, so it lands on the
699
+ * `failed` path and the integration worktree is cleaned).
700
+ *
701
+ * @param {string} cwd
702
+ * @param {string} branch
703
+ * @param {string} message
704
+ * @param {{timeoutMs?: number}} [opts]
705
+ */
706
+ async mergeNoFf(cwd, branch, message, opts = {}) {
707
+ const r = await this.spawn(cwd, ["merge", "--no-ff", "--no-edit", "-m", message, "--", branch], {
708
+ env: this.env,
709
+ timeoutMs: opts.timeoutMs !== undefined && Number.isFinite(opts.timeoutMs)
710
+ ? opts.timeoutMs
711
+ : this.timeoutMs,
712
+ });
713
+ if (r.status !== 0) {
714
+ throw new Error(
715
+ `git merge --no-ff ${branch} failed (status ${r.status}): ${r.stderr || r.stdout}`,
716
+ );
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Delete a branch (force, `git branch -D`). The branch name sits AFTER
722
+ * the `--` end-of-options terminator so a caller-supplied name can never
723
+ * inject flags (and is vetted by validateBranch upstream anyway). A
724
+ * missing branch exits non-zero (`error: branch 'x' not found`) and
725
+ * THROWS here — callers treat "not found" as the idempotent no-op they
726
+ * choose to swallow; any other failure (e.g. the branch is checked out
727
+ * in another worktree) also throws. Used by worktree cleanup.
728
+ */
729
+ async deleteBranch(repoRoot, branch) {
730
+ const r = await this.spawn(repoRoot, ["branch", "-D", "--", branch], {
731
+ env: this.env,
732
+ timeoutMs: this.timeoutMs,
733
+ });
734
+ if (r.status !== 0) {
735
+ throw new Error(
736
+ `git branch -D ${branch} failed (status ${r.status}): ${r.stderr || r.stdout}`,
737
+ );
738
+ }
739
+ return true;
740
+ }
741
+
742
+ /**
743
+ * Abort an in-progress merge in `cwd` (`git merge --abort`). Hard-failure
744
+ * recovery path only — the conflict-retention path must NOT call this (the
745
+ * conflicted scene stays for out-of-band resolution). Throws when there is
746
+ * no merge in progress; callers use it best-effort (`.catch(() => {})`).
747
+ */
748
+ async abortMerge(cwd) {
749
+ const r = await this.spawn(cwd, ["merge", "--abort"], {
750
+ env: this.env,
751
+ timeoutMs: this.timeoutMs,
752
+ });
753
+ if (r.status !== 0) {
754
+ throw new Error(`git merge --abort failed (status ${r.status}): ${r.stderr || r.stdout}`);
755
+ }
756
+ }
757
+ }
758
+
759
+ /**
760
+ * GitPort factory with a spawn-implementation injection seam. Defaults to the
761
+ * real {@link runGit}; tests (and a future migration onto a host subprocess
762
+ * service) can inject a fake with the same
763
+ * `(cwd, args, opts) => Promise<GitCommandResult>` shape. This is the
764
+ * "self-contained today, swappable tomorrow" seam from DESIGN §4.2 — it adds
765
+ * no current complexity and zero host coupling.
766
+ *
767
+ * @param {{env?: Record<string, string|undefined>, timeoutMs?: number,
768
+ * spawnImpl?: typeof runGit}} [opts]
769
+ * @returns {NodeGitPort}
770
+ */
771
+ export function createGitPort(opts = {}) {
772
+ return new NodeGitPort(opts);
773
+ }