@bridge4dev/runner 0.52.0 → 0.54.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/dist/adapters/claude.js +111 -33
- package/dist/adapters/codex-protocol.d.ts +11 -0
- package/dist/adapters/codex-protocol.js +41 -3
- package/dist/adapters/codex.js +14 -26
- package/dist/adapters/types.d.ts +45 -1
- package/dist/adapters/types.js +53 -0
- package/dist/checkpoints.d.ts +62 -0
- package/dist/checkpoints.js +50 -1
- package/dist/git.d.ts +64 -0
- package/dist/git.js +487 -36
- package/dist/gitops.d.ts +5 -0
- package/dist/gitops.js +7 -8
- package/dist/host-load.d.ts +156 -0
- package/dist/host-load.js +223 -0
- package/dist/index.js +190 -40
- package/dist/policy.d.ts +38 -0
- package/dist/policy.js +228 -7
- package/dist/process-priority.d.ts +55 -0
- package/dist/process-priority.js +99 -0
- package/dist/protocol.d.ts +42 -20
- package/dist/recipe-schema.d.ts +6 -6
- package/dist/self-update.js +43 -2
- package/dist/service-unit.d.ts +232 -10
- package/dist/service-unit.js +372 -43
- package/dist/session-cage.d.ts +297 -0
- package/dist/session-cage.js +755 -0
- package/dist/supervisor.d.ts +156 -3
- package/dist/supervisor.js +351 -32
- package/dist/systemd-memory.d.ts +35 -0
- package/dist/systemd-memory.js +115 -0
- package/dist/verify.js +28 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/git.js
CHANGED
|
@@ -6,10 +6,94 @@ import { previewsDir, worktreesDir } from './paths.js';
|
|
|
6
6
|
import { firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
8
|
const GIT_TIMEOUT_MS = 30_000;
|
|
9
|
+
/**
|
|
10
|
+
* Laying out a worktree is the one git command here that is bounded by DISK,
|
|
11
|
+
* not by git (#360). Thirty seconds is the right budget for a question; it is
|
|
12
|
+
* the wrong budget for writing 2693 files on a machine whose swap is full, and
|
|
13
|
+
* the SIGTERM that ended it was read as «git failed» by everyone downstream.
|
|
14
|
+
* Ninety seconds is the owner's number (plan §9, 06.09.2026).
|
|
15
|
+
*/
|
|
16
|
+
const WORKTREE_ADD_TIMEOUT_MS = 90_000;
|
|
9
17
|
async function git(cwd, ...args) {
|
|
10
18
|
const { stdout } = await execFileAsync('git', args, { cwd, timeout: GIT_TIMEOUT_MS });
|
|
11
19
|
return stdout.trim();
|
|
12
20
|
}
|
|
21
|
+
async function gitSlow(cwd, timeoutMs, ...args) {
|
|
22
|
+
const { stdout } = await execFileAsync('git', args, { cwd, timeout: timeoutMs });
|
|
23
|
+
return stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Why git stopped, in a sentence a person can act on.
|
|
27
|
+
*
|
|
28
|
+
* `git worktree add` prints its progress to stderr as carriage-return-overwritten
|
|
29
|
+
* frames, so a process killed mid-checkout leaves «Updating files: 1% (51/2693)»
|
|
30
|
+
* as the last thing anybody sees — a number, with no verb. The real cause lives
|
|
31
|
+
* on the error object (`killed`, `signal`, `code`) and nobody was reading it
|
|
32
|
+
* (#360). Keep the last progress frame, because «how far did it get» is the one
|
|
33
|
+
* useful thing in it, and put the cause in front of it.
|
|
34
|
+
*/
|
|
35
|
+
export function describeGitFailure(error, context = {}) {
|
|
36
|
+
const err = error;
|
|
37
|
+
const raw = String(err?.stderr || err?.message || error || '');
|
|
38
|
+
const lines = raw
|
|
39
|
+
.split('\n')
|
|
40
|
+
.map((line) => {
|
|
41
|
+
// Only the last frame of a \r-overwritten line was ever on screen.
|
|
42
|
+
const frames = line
|
|
43
|
+
.split('\r')
|
|
44
|
+
.map((frame) => frame.trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
return frames.at(-1) ?? '';
|
|
47
|
+
})
|
|
48
|
+
// `Command failed: git …` is Node's own wrapper line, not git's answer, and
|
|
49
|
+
// it repeats the command that is already in the log next to this text.
|
|
50
|
+
.filter((line) => line.length > 0 && !line.startsWith('Command failed'));
|
|
51
|
+
// Two lines and 180 characters, not three and 300: this text is wrapped in a
|
|
52
|
+
// sentence that says what was cleaned up and what to press, and the whole
|
|
53
|
+
// thing passes through a 500-character mask on its way to the feed. Git's
|
|
54
|
+
// hint lines are the least useful part of it, and they were crowding out the
|
|
55
|
+
// only part a person can act on.
|
|
56
|
+
const tail = lines.slice(-2).join('; ').slice(0, 180);
|
|
57
|
+
let cause;
|
|
58
|
+
if (err?.killed && context.timeoutMs) {
|
|
59
|
+
cause = `git was stopped after ${Math.round(context.timeoutMs / 1000)} s`;
|
|
60
|
+
}
|
|
61
|
+
else if (err?.killed) {
|
|
62
|
+
cause = 'git was stopped by the runner';
|
|
63
|
+
}
|
|
64
|
+
else if (err?.signal) {
|
|
65
|
+
cause = `git was killed by ${err.signal} (the machine most likely ran out of memory)`;
|
|
66
|
+
}
|
|
67
|
+
else if (typeof err?.code === 'number') {
|
|
68
|
+
cause = `git exited with code ${err.code}`;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
cause = 'git failed';
|
|
72
|
+
}
|
|
73
|
+
return tail ? `${cause}: ${tail}` : cause;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A preparation failure that already knows its own fork point.
|
|
77
|
+
*
|
|
78
|
+
* The fork point matters precisely BECAUSE preparation failed: without it the
|
|
79
|
+
* API keeps `base_sha = null` for the session's whole life, and «Continue» can
|
|
80
|
+
* never prove that the branch it is about to adopt is the empty one this very
|
|
81
|
+
* session created a moment ago (#360, plan Р6).
|
|
82
|
+
*/
|
|
83
|
+
export class WorktreePrepareError extends Error {
|
|
84
|
+
code;
|
|
85
|
+
baseBranch;
|
|
86
|
+
baseSha;
|
|
87
|
+
constructor(message, code, base = {}) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = 'WorktreePrepareError';
|
|
90
|
+
this.code = code;
|
|
91
|
+
if (base.baseBranch)
|
|
92
|
+
this.baseBranch = base.baseBranch;
|
|
93
|
+
if (base.baseSha)
|
|
94
|
+
this.baseSha = base.baseSha;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
13
97
|
/**
|
|
14
98
|
* Can this runner actually work in this directory — as the user it runs as?
|
|
15
99
|
*
|
|
@@ -107,7 +191,54 @@ export async function validateWorkspacePath(workspacePath) {
|
|
|
107
191
|
};
|
|
108
192
|
}
|
|
109
193
|
const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
110
|
-
|
|
194
|
+
// Binding is the one moment we are guaranteed to be looking at this repository
|
|
195
|
+
// with somebody waiting for the answer, so it is where the main branch is
|
|
196
|
+
// learned (ADR 0004). `branch` above is «what the folder is on right now» — a
|
|
197
|
+
// drifting value, and the reason #361 exists; these two must not be confused.
|
|
198
|
+
const defaultBranch = await remoteDefaultBranch(workspacePath);
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
exists: true,
|
|
202
|
+
isGitRepo: true,
|
|
203
|
+
branch,
|
|
204
|
+
...(defaultBranch ? { defaultBranch } : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The repository's main branch, read locally (ADR 0004).
|
|
209
|
+
*
|
|
210
|
+
* `origin/HEAD` is the honest answer and it is already on disk — set by `clone`,
|
|
211
|
+
* refreshed by `git remote set-head`. Never asked over the network: this runs
|
|
212
|
+
* inside a 4-second probe budget, and a repository whose origin is unreachable
|
|
213
|
+
* must still bind.
|
|
214
|
+
*/
|
|
215
|
+
export async function remoteDefaultBranch(workspacePath) {
|
|
216
|
+
const symbolic = await git(workspacePath, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD')
|
|
217
|
+
.then((value) => value.replace(/^origin\//, '').trim())
|
|
218
|
+
.catch(() => '');
|
|
219
|
+
if (symbolic)
|
|
220
|
+
return sanitizeBranch(symbolic);
|
|
221
|
+
// No origin (or no HEAD recorded for it): fall back to the conventional names,
|
|
222
|
+
// but only if they actually exist here. Guessing a branch that is not in the
|
|
223
|
+
// repository would be worse than answering «unknown».
|
|
224
|
+
return firstConventionalBranch(workspacePath);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* `main`, then `master`, and only if the repository really has one.
|
|
228
|
+
*
|
|
229
|
+
* The order and the list are one decision, not two: `gitops.ts` asks the same
|
|
230
|
+
* question when «Apply» has no pinned base, and two copies would drift the day
|
|
231
|
+
* somebody adds `develop` to one of them (ADR 0004).
|
|
232
|
+
*/
|
|
233
|
+
export async function firstConventionalBranch(workspacePath) {
|
|
234
|
+
for (const candidate of ['main', 'master']) {
|
|
235
|
+
const exists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', candidate)
|
|
236
|
+
.then(() => true)
|
|
237
|
+
.catch(() => false);
|
|
238
|
+
if (exists)
|
|
239
|
+
return candidate;
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
111
242
|
}
|
|
112
243
|
/** The real `.git` directory of a work tree (a linked worktree has a file there). */
|
|
113
244
|
async function resolveGitDir(workspacePath) {
|
|
@@ -184,14 +315,31 @@ export async function ensureSessionWorktree(workspacePath, sessionId, branchHint
|
|
|
184
315
|
const planned = options.plan ? sanitizeBranch(options.plan.branch) : null;
|
|
185
316
|
const branch = planned ?? sanitizeBranch(branchHint) ?? `devbridge/s-${short}`;
|
|
186
317
|
const worktreePath = sessionWorktreePath(sessionId);
|
|
318
|
+
// With a plan, both mismatches are failures worth stopping for. Without one
|
|
319
|
+
// (an API from before session 13) the historical guesswork is kept exactly as
|
|
320
|
+
// it was, so an old server does not start failing after a runner update.
|
|
321
|
+
const plan = planned ? options.plan : undefined;
|
|
187
322
|
if (fs.existsSync(path.join(worktreePath, '.git'))) {
|
|
188
323
|
// Runner restart — reuse, but verify the worktree really is ours: a
|
|
189
324
|
// short-id collision would silently share a worktree between sessions.
|
|
190
325
|
const head = await git(worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
|
|
191
|
-
if (head
|
|
326
|
+
if (head === branch)
|
|
327
|
+
return { branch, worktreePath };
|
|
328
|
+
// Gotcha 175 named two states, «not created yet» and «already gone». This is
|
|
329
|
+
// the third: created halfway. A worktree whose `git worktree add` was
|
|
330
|
+
// SIGKILLed mid-checkout keeps its registration, a detached HEAD and a lock
|
|
331
|
+
// reading `initializing` — so «the registration is alive and HEAD answers»
|
|
332
|
+
// is TRUE for it, and the old check read that as «somebody else's worktree»
|
|
333
|
+
// and refused forever (#360).
|
|
334
|
+
const halfCreated = await looksHalfCreated(workspacePath, worktreePath, head, branch);
|
|
335
|
+
if (!halfCreated) {
|
|
192
336
|
throw new Error(`Worktree ${worktreePath} is on branch ${head ?? 'unknown'}, expected ${branch}`);
|
|
193
337
|
}
|
|
194
|
-
|
|
338
|
+
const discarded = await discardWorktreeFolder(workspacePath, worktreePath);
|
|
339
|
+
if (!discarded.ok) {
|
|
340
|
+
throw new WorktreePrepareError(`The working folder ${worktreePath} was left half-created by an earlier attempt and could not be cleaned up: ${discarded.leftovers.join('; ')}. ` +
|
|
341
|
+
`Remove it on the server (\`git -C ${workspacePath} worktree unlock ${worktreePath}; git -C ${workspacePath} worktree remove --force ${worktreePath}\`), then press "Continue".`, 'worktree_cleanup_failed');
|
|
342
|
+
}
|
|
195
343
|
}
|
|
196
344
|
fs.mkdirSync(worktreesDir(), { recursive: true, mode: 0o700 });
|
|
197
345
|
// Repair stale registrations left by a deleted worktree dir.
|
|
@@ -199,12 +347,24 @@ export async function ensureSessionWorktree(workspacePath, sessionId, branchHint
|
|
|
199
347
|
const branchExists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', branch)
|
|
200
348
|
.then(() => true)
|
|
201
349
|
.catch(() => false);
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
350
|
+
// Set only when the branch we are about to attach is provably the empty
|
|
351
|
+
// leftover of THIS session's own failed start. It is the one case in which
|
|
352
|
+
// the branch tip and the fork point are the same commit — everywhere else
|
|
353
|
+
// reporting the tip as a fork point would move the base of «Apply» onto the
|
|
354
|
+
// work itself.
|
|
355
|
+
let adoptedOwnEmpty = false;
|
|
206
356
|
if (plan?.source === 'NEW' && branchExists) {
|
|
207
|
-
|
|
357
|
+
// «Continue» onto our OWN empty branch. The refusal below is right and stays
|
|
358
|
+
// (QA-107: a new session must not sit down on a stranger's commits), but the
|
|
359
|
+
// stranger it was protecting against was, in #360, this same session's own
|
|
360
|
+
// leftover — a branch created by a `worktree add -b` that then died laying
|
|
361
|
+
// out files. Provable, not guessed: the name is the planned one, the API
|
|
362
|
+
// pinned a fork point, the branch tip is exactly that fork point, so it
|
|
363
|
+
// carries no commits, and nobody has it checked out.
|
|
364
|
+
const adoption = await canAdoptOwnEmptyBranch(workspacePath, branch, plan);
|
|
365
|
+
if (!adoption.ok)
|
|
366
|
+
throw adoption.error;
|
|
367
|
+
adoptedOwnEmpty = true;
|
|
208
368
|
}
|
|
209
369
|
if (plan?.source === 'CONTINUE' && !branchExists) {
|
|
210
370
|
throw new Error(`branch ${branch} no longer exists in ${workspacePath}`);
|
|
@@ -216,8 +376,32 @@ export async function ensureSessionWorktree(workspacePath, sessionId, branchHint
|
|
|
216
376
|
if (holder) {
|
|
217
377
|
throw new Error(`branch ${branch} is already checked out in ${holder}`);
|
|
218
378
|
}
|
|
219
|
-
|
|
220
|
-
|
|
379
|
+
try {
|
|
380
|
+
// Laying out files, so the long budget — the same work `worktree add -b`
|
|
381
|
+
// was doing when #360's session died at 1%.
|
|
382
|
+
await gitSlow(workspacePath, WORKTREE_ADD_TIMEOUT_MS, 'worktree', 'add', worktreePath, branch);
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
throw await cleanUpAfterFailure(workspacePath, worktreePath, {
|
|
386
|
+
error,
|
|
387
|
+
branch,
|
|
388
|
+
// The branch was here before us — the ladder must not touch it.
|
|
389
|
+
branchWasOurs: false,
|
|
390
|
+
...(plan?.baseBranch ? { baseBranch: plan.baseBranch } : {}),
|
|
391
|
+
...(plan?.baseSha ? { startSha: plan.baseSha } : {}),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
branch,
|
|
396
|
+
worktreePath,
|
|
397
|
+
// Nothing new is learned by attaching a branch that already existed: its
|
|
398
|
+
// tip is where the WORK is, not where it forked, and reporting it as the
|
|
399
|
+
// base would move the target of «Apply» onto the work itself. The one
|
|
400
|
+
// exception is the leftover we just proved empty, where the two commits
|
|
401
|
+
// are the same one and the API may still be missing it.
|
|
402
|
+
...(adoptedOwnEmpty && plan?.baseBranch ? { baseBranch: plan.baseBranch } : {}),
|
|
403
|
+
...(adoptedOwnEmpty && plan?.baseSha ? { baseSha: plan.baseSha } : {}),
|
|
404
|
+
};
|
|
221
405
|
}
|
|
222
406
|
if (options.requireExistingBranch) {
|
|
223
407
|
// The caller is restoring a session that already produced work. Creating
|
|
@@ -229,6 +413,11 @@ export async function ensureSessionWorktree(workspacePath, sessionId, branchHint
|
|
|
229
413
|
// A pinned sha that is not in this repository is not worth guessing around —
|
|
230
414
|
// branching off the wrong commit is the failure this whole plan removes.
|
|
231
415
|
const startPoint = await resolveStartPoint(workspacePath, plan);
|
|
416
|
+
// Resolved to a full sha BEFORE anything is written. Two reasons: a DIRECT
|
|
417
|
+
// neighbour can move HEAD between these lines, and the fork point has to be
|
|
418
|
+
// known even if the next step dies — it is what makes the leftover branch
|
|
419
|
+
// provably empty afterwards (#360).
|
|
420
|
+
const startSha = await git(workspacePath, 'rev-parse', '--verify', `${startPoint}^{commit}`);
|
|
232
421
|
// What HEAD points at, read BEFORE the worktree is added. This is the only
|
|
233
422
|
// chance to learn the fork point for a session created while the runner was
|
|
234
423
|
// offline: the API had nobody to ask, so `branchPlan.baseBranch` is empty and
|
|
@@ -239,15 +428,294 @@ export async function ensureSessionWorktree(workspacePath, sessionId, branchHint
|
|
|
239
428
|
: await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD')
|
|
240
429
|
.then((name) => (name && name !== 'HEAD' ? name : undefined))
|
|
241
430
|
.catch(() => undefined);
|
|
242
|
-
|
|
243
|
-
|
|
431
|
+
// Files first, branch second (#360, plan Р6). `git worktree add -b` does both
|
|
432
|
+
// in one command and in the opposite order: it registers the branch, THEN
|
|
433
|
+
// spends minutes writing files — so every death in between left a branch that
|
|
434
|
+
// no worktree held and that the next start refused to touch. Split in two, the
|
|
435
|
+
// expensive half creates nothing that can outlive it, and `checkout -b` at the
|
|
436
|
+
// end is instant. (`checkout -b`, not `switch -c`: `switch` needs git ≥ 2.23
|
|
437
|
+
// and the runner never checks the git version.)
|
|
438
|
+
try {
|
|
439
|
+
await addDetachedWorktree(workspacePath, worktreePath, startSha);
|
|
440
|
+
await git(worktreePath, 'checkout', '-b', branch);
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
throw await cleanUpAfterFailure(workspacePath, worktreePath, {
|
|
444
|
+
error,
|
|
445
|
+
branch,
|
|
446
|
+
branchWasOurs: true,
|
|
447
|
+
startSha,
|
|
448
|
+
baseBranch: headBranch,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
244
451
|
return {
|
|
245
452
|
branch,
|
|
246
453
|
worktreePath,
|
|
247
454
|
...(headBranch ? { baseBranch: headBranch } : {}),
|
|
248
|
-
|
|
455
|
+
baseSha: startSha,
|
|
249
456
|
};
|
|
250
457
|
}
|
|
458
|
+
/**
|
|
459
|
+
* Is this branch the empty leftover of this very session's failed start (#360)?
|
|
460
|
+
*
|
|
461
|
+
* Every part of the answer is checked against the repository right now, not
|
|
462
|
+
* remembered: this decides whether a `branch -D` is allowed later, and the cost
|
|
463
|
+
* of being wrong is somebody's commits.
|
|
464
|
+
*/
|
|
465
|
+
async function canAdoptOwnEmptyBranch(workspacePath, branch, plan) {
|
|
466
|
+
const tip = await git(workspacePath, 'rev-parse', `${branch}^{commit}`).catch(() => null);
|
|
467
|
+
const refuse = (why, hint) => ({
|
|
468
|
+
ok: false,
|
|
469
|
+
error: new WorktreePrepareError(`Branch ${branch} already exists in ${workspacePath}${tip ? ` (tip ${tip.slice(0, 12)})` : ''} and ${why}, ` +
|
|
470
|
+
`so this session will not adopt it. ${hint}`, 'branch_taken', { baseBranch: plan.baseBranch, baseSha: plan.baseSha }),
|
|
471
|
+
});
|
|
472
|
+
if (!plan.baseSha) {
|
|
473
|
+
// A session created while the runner was unreachable by an API older than
|
|
474
|
+
// stage 1: nothing pinned the fork point, so emptiness cannot be proven at
|
|
475
|
+
// all. Say so, and say what to type.
|
|
476
|
+
return refuse('this session never recorded a fork point, so the runner cannot prove it is empty', `If it is not anybody's work, delete it there (\`git -C ${workspacePath} branch -D ${branch}\`) and press "Continue".`);
|
|
477
|
+
}
|
|
478
|
+
// The API may ship a shortened sha (protocol.ts) — resolve both sides.
|
|
479
|
+
const pinned = await git(workspacePath, 'rev-parse', `${plan.baseSha}^{commit}`).catch(() => null);
|
|
480
|
+
if (!tip || !pinned) {
|
|
481
|
+
return refuse('its tip could not be read', `Check it on the server, and press "Continue" once it is gone.`);
|
|
482
|
+
}
|
|
483
|
+
if (tip !== pinned) {
|
|
484
|
+
return refuse(`has commits of its own (fork point ${pinned.slice(0, 12)})`, `If it is not anybody's work, delete it there (\`git -C ${workspacePath} branch -D ${branch}\`) and press "Continue".`);
|
|
485
|
+
}
|
|
486
|
+
const holder = await worktreeHolding(workspacePath, branch);
|
|
487
|
+
if (holder) {
|
|
488
|
+
return refuse(`is checked out in ${holder}`, `Free that worktree, or delete the branch, and press "Continue".`);
|
|
489
|
+
}
|
|
490
|
+
return { ok: true };
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* `git worktree add --detach` — shared by sessions and by the preview slot.
|
|
494
|
+
*
|
|
495
|
+
* The one command in this file bounded by disk rather than by git, which is why
|
|
496
|
+
* it has a budget of its own.
|
|
497
|
+
*/
|
|
498
|
+
async function addDetachedWorktree(workspacePath, worktreePath, sha) {
|
|
499
|
+
await gitSlow(workspacePath, WORKTREE_ADD_TIMEOUT_MS, 'worktree', 'add', '--detach', worktreePath, sha);
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* The cleanup ladder (#360, plan Р6). Every rung is «undo what may exist», and
|
|
503
|
+
* the whole ladder runs before the original error is re-thrown — a failure to
|
|
504
|
+
* lay out files must not also leave a folder and a branch behind.
|
|
505
|
+
*/
|
|
506
|
+
async function cleanUpAfterFailure(workspacePath, worktreePath, input) {
|
|
507
|
+
const cause = describeGitFailure(input.error, { timeoutMs: WORKTREE_ADD_TIMEOUT_MS });
|
|
508
|
+
const base = { baseBranch: input.baseBranch, baseSha: input.startSha };
|
|
509
|
+
const discarded = await discardWorktreeFolder(workspacePath, worktreePath);
|
|
510
|
+
const leftovers = [...discarded.leftovers];
|
|
511
|
+
let branchRemoved = false;
|
|
512
|
+
if (input.branchWasOurs && input.startSha) {
|
|
513
|
+
const dropped = await dropEmptySessionBranch(workspacePath, input.branch, input.startSha);
|
|
514
|
+
branchRemoved = dropped.removed;
|
|
515
|
+
if (!dropped.removed && dropped.leftover)
|
|
516
|
+
leftovers.push(dropped.leftover);
|
|
517
|
+
}
|
|
518
|
+
if (leftovers.length > 0) {
|
|
519
|
+
return new WorktreePrepareError(`Could not prepare the working folder for this session: ${cause}. ` +
|
|
520
|
+
`Cleaning up after it did not finish either: ${leftovers.join('; ')}. ` +
|
|
521
|
+
`Clear that on the server, then press "Continue".`, 'worktree_cleanup_failed', base);
|
|
522
|
+
}
|
|
523
|
+
return new WorktreePrepareError(`Could not prepare the working folder for this session: ${cause}. ` +
|
|
524
|
+
(branchRemoved
|
|
525
|
+
? `The branch ${input.branch} it had just created was removed and nothing of yours was touched. `
|
|
526
|
+
: `Nothing was left behind and nothing of yours was touched. `) +
|
|
527
|
+
`Press "Continue" to try again.`, 'worktree_prepare_failed', base);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Delete a session branch that provably carries no commits (#360).
|
|
531
|
+
*
|
|
532
|
+
* Shares its safety rungs with {@link deleteSessionBranch} — prune, then the
|
|
533
|
+
* folder's own branch, then the holder — and adds the one this caller needs:
|
|
534
|
+
* the tip must still be exactly the fork point we started from. `-D`, not `-d`,
|
|
535
|
+
* because `-d` asks «is it merged into the branch THIS folder is on», which is
|
|
536
|
+
* a different question and answers «no» for a branch that is empty.
|
|
537
|
+
*/
|
|
538
|
+
export async function dropEmptySessionBranch(workspacePath, branch, expectedSha) {
|
|
539
|
+
const safe = sanitizeBranch(branch);
|
|
540
|
+
if (!safe)
|
|
541
|
+
return { removed: false, leftover: `unsafe branch name ${branch}` };
|
|
542
|
+
await git(workspacePath, 'worktree', 'prune').catch(() => undefined);
|
|
543
|
+
// `--verify --quiet` so that «no such ref» is exit 1 with no output. Without
|
|
544
|
+
// it a missing branch exits 128 with a fatal, which is indistinguishable from
|
|
545
|
+
// git being unable to look at all — and those two need opposite answers.
|
|
546
|
+
const tip = await git(workspacePath, 'rev-parse', '--verify', '--quiet', `${safe}^{commit}`).then((value) => ({ ok: true, value }), (error) => ({ ok: false, code: error.code }));
|
|
547
|
+
// Exit 1 with no output is «there is no such ref» — the good case, nothing to
|
|
548
|
+
// delete. Anything else is git failing to LOOK, and reporting that as «clean»
|
|
549
|
+
// is how a leftover branch gets announced as tidied up (QA #255).
|
|
550
|
+
if (!tip.ok) {
|
|
551
|
+
return tip.code === 1
|
|
552
|
+
? { removed: false }
|
|
553
|
+
: {
|
|
554
|
+
removed: false,
|
|
555
|
+
leftover: `could not check whether branch ${safe} was left behind`,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
const expected = await git(workspacePath, 'rev-parse', `${expectedSha}^{commit}`).catch(() => null);
|
|
559
|
+
if (!expected || tip.value !== expected) {
|
|
560
|
+
return {
|
|
561
|
+
removed: false,
|
|
562
|
+
leftover: `branch ${safe} was left in place (it is not at ${expectedSha.slice(0, 12)})`,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
const blocked = await branchInUse(workspacePath, safe);
|
|
566
|
+
if (blocked)
|
|
567
|
+
return { removed: false, leftover: `branch ${safe} was left in place (${blocked})` };
|
|
568
|
+
try {
|
|
569
|
+
await git(workspacePath, 'branch', '-D', safe);
|
|
570
|
+
return { removed: true };
|
|
571
|
+
}
|
|
572
|
+
catch (error) {
|
|
573
|
+
return {
|
|
574
|
+
removed: false,
|
|
575
|
+
leftover: `branch ${safe} could not be deleted (${describeGitFailure(error)})`,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/** Why `branch` must not be deleted right now, or `null` if nothing objects. */
|
|
580
|
+
async function branchInUse(workspacePath, safe) {
|
|
581
|
+
const current = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
|
|
582
|
+
if (current === safe)
|
|
583
|
+
return 'the project folder is on it';
|
|
584
|
+
const holder = await worktreeHolding(workspacePath, safe);
|
|
585
|
+
if (holder)
|
|
586
|
+
return `it is checked out in ${holder}`;
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
/** Read `git worktree list --porcelain`, treating a failure as «no answer». */
|
|
590
|
+
async function listWorktrees(workspacePath) {
|
|
591
|
+
return parseWorktrees(await git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => ''));
|
|
592
|
+
}
|
|
593
|
+
/** Parse `git worktree list --porcelain` into one record per worktree. */
|
|
594
|
+
function parseWorktrees(raw) {
|
|
595
|
+
const entries = [];
|
|
596
|
+
let current = null;
|
|
597
|
+
for (const line of raw.split('\n')) {
|
|
598
|
+
const text = line.trim();
|
|
599
|
+
if (text.startsWith('worktree ')) {
|
|
600
|
+
current = {
|
|
601
|
+
path: text.slice('worktree '.length).trim(),
|
|
602
|
+
branch: null,
|
|
603
|
+
detached: false,
|
|
604
|
+
locked: null,
|
|
605
|
+
};
|
|
606
|
+
entries.push(current);
|
|
607
|
+
}
|
|
608
|
+
else if (!current) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
else if (text.startsWith('branch ')) {
|
|
612
|
+
current.branch = text
|
|
613
|
+
.slice('branch '.length)
|
|
614
|
+
.trim()
|
|
615
|
+
.replace(/^refs\/heads\//, '');
|
|
616
|
+
}
|
|
617
|
+
else if (text === 'detached') {
|
|
618
|
+
current.detached = true;
|
|
619
|
+
}
|
|
620
|
+
else if (text === 'locked' || text.startsWith('locked ')) {
|
|
621
|
+
current.locked = text.slice('locked'.length).trim();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return entries;
|
|
625
|
+
}
|
|
626
|
+
/** The `.git/worktrees/<name>` directory behind a linked worktree, from its `.git` pointer file. */
|
|
627
|
+
function linkedGitDir(worktreePath) {
|
|
628
|
+
const gitFile = path.join(worktreePath, '.git');
|
|
629
|
+
try {
|
|
630
|
+
if (!fs.existsSync(gitFile) || !fs.statSync(gitFile).isFile())
|
|
631
|
+
return null;
|
|
632
|
+
const pointer = fs
|
|
633
|
+
.readFileSync(gitFile, 'utf8')
|
|
634
|
+
.match(/^gitdir:\s*(.+)$/m)?.[1]
|
|
635
|
+
?.trim();
|
|
636
|
+
return pointer ? path.resolve(worktreePath, pointer) : null;
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Did an earlier `git worktree add` die halfway through this folder? (#360)
|
|
644
|
+
*
|
|
645
|
+
* The reliable tell is the lock git itself takes while laying out files: it
|
|
646
|
+
* reads `initializing`, it survives SIGKILL, and neither `worktree remove
|
|
647
|
+
* --force` («cannot remove a locked working tree») nor `worktree prune` clears
|
|
648
|
+
* it. The second tell is a detached HEAD with no branch of ours anywhere — what
|
|
649
|
+
* is left when the crash happened before `checkout -b`.
|
|
650
|
+
*/
|
|
651
|
+
async function looksHalfCreated(workspacePath, worktreePath, head, branch) {
|
|
652
|
+
const entry = (await listWorktrees(workspacePath)).find((candidate) => path.resolve(candidate.path) === path.resolve(worktreePath));
|
|
653
|
+
if (entry && entry.locked !== null)
|
|
654
|
+
return true;
|
|
655
|
+
const gitDir = linkedGitDir(worktreePath);
|
|
656
|
+
if (gitDir && fs.existsSync(path.join(gitDir, 'locked')))
|
|
657
|
+
return true;
|
|
658
|
+
// «git could not answer» is NOT «the folder is detached». What this predicate
|
|
659
|
+
// authorises is `worktree remove --force` on a folder that may hold somebody's
|
|
660
|
+
// uncommitted work, so it only ever runs on a POSITIVE answer. A runner that
|
|
661
|
+
// lost `safe.directory` (a `--user` reinstall, a `--repair`) makes every
|
|
662
|
+
// `rev-parse` here reject; reading that silence as «half-created» would have
|
|
663
|
+
// deleted a live session's worktree on the next restart.
|
|
664
|
+
if (head !== 'HEAD' && entry?.detached !== true)
|
|
665
|
+
return false;
|
|
666
|
+
// The same silence, the other way round: a branch probe that could not run
|
|
667
|
+
// must not be read as «the branch is not there».
|
|
668
|
+
const branchProbe = await git(workspacePath, 'rev-parse', '--verify', '--quiet', branch).then(() => 'exists', (error) =>
|
|
669
|
+
// `--verify --quiet` exits 1 with no output when the ref is simply absent;
|
|
670
|
+
// anything else (128, a signal, a dubious-ownership fatal) is git failing
|
|
671
|
+
// to look, which is a different fact.
|
|
672
|
+
error.code === 1 ? 'absent' : 'unknown');
|
|
673
|
+
return branchProbe === 'absent';
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Take the folder back: unlock, remove, prune. The unlock goes FIRST — a lock
|
|
677
|
+
* left by a killed `worktree add` blocks every other rung of the ladder.
|
|
678
|
+
*/
|
|
679
|
+
async function discardWorktreeFolder(workspacePath, worktreePath) {
|
|
680
|
+
await git(workspacePath, 'worktree', 'unlock', worktreePath).catch(() => undefined);
|
|
681
|
+
const removed = await git(workspacePath, 'worktree', 'remove', '--force', worktreePath)
|
|
682
|
+
.then(() => true)
|
|
683
|
+
.catch(() => false);
|
|
684
|
+
if (!removed) {
|
|
685
|
+
try {
|
|
686
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
// Deliberately NOT `describeGitFailure`: git was never asked. Telling the
|
|
690
|
+
// operator «git failed: EACCES» sends them to look at the wrong program.
|
|
691
|
+
const detail = String(error instanceof Error ? error.message : error).slice(0, 180);
|
|
692
|
+
return {
|
|
693
|
+
ok: false,
|
|
694
|
+
leftovers: [`the folder ${worktreePath} could not be removed (${detail})`],
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
await git(workspacePath, 'worktree', 'prune').catch(() => undefined);
|
|
699
|
+
if (fs.existsSync(worktreePath)) {
|
|
700
|
+
return { ok: false, leftovers: [`the folder ${worktreePath} is still there`] };
|
|
701
|
+
}
|
|
702
|
+
const registrations = await git(workspacePath, 'worktree', 'list', '--porcelain').then((raw) => parseWorktrees(raw), () => null);
|
|
703
|
+
if (registrations === null) {
|
|
704
|
+
// Same rule as `dropEmptySessionBranch`: a check that could not run is not
|
|
705
|
+
// a check that passed.
|
|
706
|
+
return {
|
|
707
|
+
ok: false,
|
|
708
|
+
leftovers: [`could not check whether git still lists ${worktreePath} as a worktree`],
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
if (registrations.some((entry) => path.resolve(entry.path) === path.resolve(worktreePath))) {
|
|
712
|
+
return {
|
|
713
|
+
ok: false,
|
|
714
|
+
leftovers: [`git still lists ${worktreePath} as a worktree (it may still be locked)`],
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
return { ok: true, leftovers: [] };
|
|
718
|
+
}
|
|
251
719
|
/**
|
|
252
720
|
* DIRECT mode (session 16): the session's workplace IS the project folder.
|
|
253
721
|
*
|
|
@@ -310,21 +778,8 @@ async function resolveStartPoint(workspacePath, plan) {
|
|
|
310
778
|
}
|
|
311
779
|
/** Which worktree, if any, currently has `branch` checked out. */
|
|
312
780
|
async function worktreeHolding(workspacePath, branch) {
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
for (const line of raw.split('\n')) {
|
|
316
|
-
if (line.startsWith('worktree '))
|
|
317
|
-
current = line.slice('worktree '.length).trim();
|
|
318
|
-
else if (line.startsWith('branch ') && current) {
|
|
319
|
-
if (line
|
|
320
|
-
.slice('branch '.length)
|
|
321
|
-
.trim()
|
|
322
|
-
.replace(/^refs\/heads\//, '') === branch) {
|
|
323
|
-
return current;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
return null;
|
|
781
|
+
const entry = (await listWorktrees(workspacePath)).find((item) => item.branch === branch);
|
|
782
|
+
return entry ? entry.path : null;
|
|
328
783
|
}
|
|
329
784
|
// ─── Preview worktree (session 14) ───────────────────────────────────
|
|
330
785
|
/**
|
|
@@ -363,7 +818,7 @@ export async function ensurePreviewWorktree(input) {
|
|
|
363
818
|
await git(worktreePath, 'clean', '-fd').catch(() => undefined);
|
|
364
819
|
return { worktreePath, branch, sha };
|
|
365
820
|
}
|
|
366
|
-
await
|
|
821
|
+
await addDetachedWorktree(input.workspacePath, worktreePath, sha);
|
|
367
822
|
return { worktreePath, branch, sha };
|
|
368
823
|
}
|
|
369
824
|
/** Give the slot back. The branch is untouched — it was never checked out. */
|
|
@@ -411,13 +866,9 @@ export async function deleteSessionBranch(workspacePath, branch) {
|
|
|
411
866
|
// its stale registration would answer «still checked out» and leave a branch
|
|
412
867
|
// behind on every ordinary purge.
|
|
413
868
|
await git(workspacePath, 'worktree', 'prune').catch(() => undefined);
|
|
414
|
-
const
|
|
415
|
-
if (
|
|
416
|
-
throw new Error(`refusing to delete ${safe}:
|
|
417
|
-
}
|
|
418
|
-
const holder = await worktreeHolding(workspacePath, safe);
|
|
419
|
-
if (holder) {
|
|
420
|
-
throw new Error(`refusing to delete ${safe}: it is checked out in ${holder}`);
|
|
869
|
+
const blocked = await branchInUse(workspacePath, safe);
|
|
870
|
+
if (blocked) {
|
|
871
|
+
throw new Error(`refusing to delete ${safe}: ${blocked}`);
|
|
421
872
|
}
|
|
422
873
|
await git(workspacePath, 'branch', '-D', safe);
|
|
423
874
|
}
|
package/dist/gitops.d.ts
CHANGED
|
@@ -325,6 +325,11 @@ export interface GitBranchesResult {
|
|
|
325
325
|
/** What the project folder is on right now. */
|
|
326
326
|
currentBranch: string | null;
|
|
327
327
|
currentSha: string | null;
|
|
328
|
+
/**
|
|
329
|
+
* The repository's own main branch (ADR 0004) — not the same question as
|
|
330
|
+
* `currentBranch`, which is «where the folder happens to stand today».
|
|
331
|
+
*/
|
|
332
|
+
defaultBranch: string | null;
|
|
328
333
|
remotes: string[];
|
|
329
334
|
truncated: boolean;
|
|
330
335
|
}
|
package/dist/gitops.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
-
import { sanitizeBranch } from './git.js';
|
|
5
|
+
import { firstConventionalBranch, remoteDefaultBranch, sanitizeBranch } from './git.js';
|
|
6
6
|
import { isSecretPath, maskString } from './policy.js';
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
8
|
const GIT_TIMEOUT_MS = 30_000;
|
|
@@ -250,13 +250,11 @@ async function guessBase(workspacePath, sessionBranch) {
|
|
|
250
250
|
if (current !== sessionBranch && current !== 'HEAD') {
|
|
251
251
|
return { baseBranch: current, baseRef: current };
|
|
252
252
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
return { baseBranch: candidate, baseRef: candidate };
|
|
259
|
-
}
|
|
253
|
+
// One list, one order, one place — the same helper the binding uses to learn
|
|
254
|
+
// the project's main branch (ADR 0004).
|
|
255
|
+
const conventional = await firstConventionalBranch(workspacePath);
|
|
256
|
+
if (conventional)
|
|
257
|
+
return { baseBranch: conventional, baseRef: conventional };
|
|
260
258
|
throw new Error(`Cannot determine the base branch (workspace is on ${current})`);
|
|
261
259
|
}
|
|
262
260
|
/** How far apart two refs are, in one call: `[behind, ahead]`. */
|
|
@@ -1384,6 +1382,7 @@ export async function gitBranches(workspacePath) {
|
|
|
1384
1382
|
branches,
|
|
1385
1383
|
currentBranch: currentBranch === 'HEAD' ? null : currentBranch,
|
|
1386
1384
|
currentSha: await git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
|
|
1385
|
+
defaultBranch: await remoteDefaultBranch(workspacePath).catch(() => null),
|
|
1387
1386
|
remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
|
|
1388
1387
|
truncated,
|
|
1389
1388
|
};
|