@bridge4dev/runner 0.51.0 → 0.53.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/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
- return { ok: true, exists: true, isGitRepo: true, branch };
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 !== branch) {
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
- return { branch, worktreePath };
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
- // With a plan, both mismatches are failures worth stopping for. Without one
203
- // (an API from before session 13) the historical guesswork is kept exactly as
204
- // it was, so an old server does not start failing after a runner update.
205
- const plan = planned ? options.plan : undefined;
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
- throw new Error(`branch ${branch} already exists in ${workspacePath} — a new session must not silently continue somebody else's work`);
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
- await git(workspacePath, 'worktree', 'add', worktreePath, branch);
220
- return { branch, worktreePath };
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
- await git(workspacePath, 'worktree', 'add', '-b', branch, worktreePath, startPoint);
243
- const baseSha = await git(workspacePath, 'rev-parse', startPoint).catch(() => undefined);
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
- ...(baseSha ? { baseSha } : {}),
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 raw = await git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => '');
314
- let current = null;
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 git(input.workspacePath, 'worktree', 'add', '--detach', worktreePath, sha);
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 current = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
415
- if (current === safe) {
416
- throw new Error(`refusing to delete ${safe}: the project folder is on it`);
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
- for (const candidate of ['main', 'master']) {
254
- const exists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', candidate)
255
- .then(() => true)
256
- .catch(() => false);
257
- if (exists)
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
  };