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.
- package/AGENTS.md +120 -0
- package/CHANGELOG.md +266 -0
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/README.zh.md +151 -0
- package/SECURITY.md +56 -0
- package/cordis.patch.yml +30 -0
- package/docs/DESIGN.md +783 -0
- package/docs/TASKS.md +164 -0
- package/lib/config.js +126 -0
- package/lib/engine-face.js +328 -0
- package/lib/git-port.js +773 -0
- package/lib/index.js +402 -0
- package/lib/merge-queue.js +832 -0
- package/lib/naming.js +107 -0
- package/lib/repo-gate.js +202 -0
- package/lib/state-store.js +512 -0
- package/lib/tools/worktree-cleanup.js +127 -0
- package/lib/tools/worktree-create.js +212 -0
- package/lib/tools/worktree-list.js +234 -0
- package/lib/tools/worktree-merge.js +396 -0
- package/lib/tools/worktree-queue.js +330 -0
- package/lib/tools/worktree-status.js +194 -0
- package/lib/worktree-service.js +673 -0
- package/package.json +55 -0
- package/scripts/link-harness-dsh-tools.sh +95 -0
- package/scripts/lint.js +142 -0
package/lib/git-port.js
ADDED
|
@@ -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
|
+
}
|