@ctrl-spc/cs 0.7.1 → 0.7.2

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.
@@ -5,9 +5,18 @@
5
5
  * this machine keeps the folder in `codebase-paths.json`. Nothing falls back to
6
6
  * a project folder or a machine-wide working copy, because either could put an
7
7
  * agent in a different repository while the record names this one.
8
+ *
9
+ * ═══ AND SINCE worktrees-8, THE FOLDER A RUN IS GIVEN IS NOT THAT ONE. ═══ The
10
+ * located checkout is the repository a copy is MADE FROM; the copy is the card's
11
+ * own worktree on the card's own branch. Both questions are answered here rather
12
+ * than in a module beside this one: "which folder does this codebase resolve to"
13
+ * and "which folder does this CARD resolve to" are one question asked twice, and
14
+ * two owners of it is how two answers are born.
8
15
  */
9
- import { statSync } from 'node:fs';
10
- import { readCodebasePaths } from '../config.js';
16
+ import { execFileSync } from 'node:child_process';
17
+ import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { configDir, readCodebasePaths } from '../config.js';
11
20
  function isDirectory(path) {
12
21
  try {
13
22
  return statSync(path).isDirectory();
@@ -27,3 +36,514 @@ export function checkoutForCodebase(codebase, machineName) {
27
36
  return located;
28
37
  throw new Error(`Open the companion on ${machineName} and locate ${codebase.name} before sending code work there.`);
29
38
  }
39
+ // ---------------------------------------------------------------------------
40
+ // GIT.
41
+ //
42
+ // ═══ THREE LINES RATHER THAN A NAME ON THE ISOLATION LIST. ═══ `folders.ts:59`
43
+ // is this repo's idiom for `git -C <folder> …` and CANNOT be imported:
44
+ // `PANEL3_MAY_REACH` in `panel3-isolation.contract.test.mjs` is an exhaustive
45
+ // list of seven specifiers and `../folders.js` is not one of them. Adding a name
46
+ // there for one helper would widen a deliberate seam for a convenience, so the
47
+ // wrapper lives here, knowingly duplicated and three lines long.
48
+ //
49
+ // ═══ AND ITS FAILURE MAY NEVER BE HANDED ON AS IT ARRIVES. ═══ `execFile`'s own
50
+ // message begins `Command failed: git -C <absolute path> …`, and the callers of
51
+ // these functions write their failure to `panel3_runs.failed_because`, which is
52
+ // a cloud column a browser renders. Every throw below is composed from the
53
+ // codebase's name and the branch, never from git's own text.
54
+ /** A failure whose text was composed here and is therefore safe to write to the
55
+ * cloud. Git's own text never is, so the wrapper below can tell them apart by
56
+ * something better than matching on its wording. */
57
+ const pathFree = (message) => Object.assign(new Error(message), { pathFree: true });
58
+ function git(folder, args) {
59
+ return execFileSync('git', ['-C', folder, ...args], {
60
+ encoding: 'utf8',
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ }).trim();
63
+ }
64
+ /**
65
+ * Remove a worktree, standing in ITS REPOSITORY rather than in the folder being
66
+ * removed.
67
+ *
68
+ * ═══ NEVER `-C` THE FOLDER BEING REMOVED. ═══ `git -C X` makes git's own
69
+ * current directory X, and Windows refuses to delete a directory that is any
70
+ * live process's current directory: `failed to delete '<path>': Permission
71
+ * denied`, every time, on a worktree nothing else has open. POSIX unlinks it
72
+ * happily, which is the only reason this was invisible on the Mac.
73
+ *
74
+ * The parent is NOT the answer: these copies live under a plain directory that
75
+ * belongs to no repository, so `-C parent` either finds nothing or finds some
76
+ * unrelated repository the home directory happens to be, and says the worktree
77
+ * "is not a working tree". The worktree knows its own repository, so it is asked
78
+ * first, while it still exists, and the removal is run from there.
79
+ */
80
+ function removeWorktree(folder) {
81
+ const repo = git(folder, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
82
+ git(repo, ['worktree', 'remove', '--force', folder]);
83
+ }
84
+ /** Whether this exact folder is a repository root, rather than merely sitting
85
+ * somewhere beneath one. `--show-prefix` is empty only at the root, so a plain
86
+ * folder inside a repository answers false here where `--git-dir` said true. */
87
+ function isNotRepositoryRoot(folder) {
88
+ try {
89
+ return git(folder, ['rev-parse', '--show-prefix']) !== '';
90
+ }
91
+ catch {
92
+ return true;
93
+ }
94
+ }
95
+ /** Whether a git command succeeds, for the questions whose answer IS the exit
96
+ * code: does this ref exist, is this folder a worktree. */
97
+ function gitOk(folder, args) {
98
+ try {
99
+ git(folder, args);
100
+ return true;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ /** Everything under `<config dir>/worktrees`, which is the one place on this
107
+ * machine the product makes folders of its own. Machine-local by construction:
108
+ * a card resumed on another machine finds nothing here and says so. */
109
+ export function worktreeRoot() {
110
+ return join(configDir(), 'worktrees');
111
+ }
112
+ const folderSlug = (branch) => branch.replace(/\//g, '-');
113
+ /** Where one card's copy lives, derived from the two facts the run row carries.
114
+ * Pure, so the sweep can ask it of a row without resolving a codebase. */
115
+ export function worktreeFolder(codebaseId, branch) {
116
+ return join(worktreeRoot(), codebaseId, folderSlug(branch));
117
+ }
118
+ /** The pattern a codebase nobody configured gets, and the one the mock renders.
119
+ * `{task}` is the only token. */
120
+ export const DEFAULT_BRANCH_PATTERN = 'agent/{task}';
121
+ /**
122
+ * ═══ THE BRANCH IS DERIVED FROM THE CARD ID, AND THE TITLE IS DECORATION. ═══
123
+ *
124
+ * Immutable, pure, and identical for every run of one card, so two cards cannot
125
+ * collide on one branch and a resumed run needs no lookup to find its own. The
126
+ * id half is load-bearing and the title half is not: two cards may carry the
127
+ * same title, and a branch named only from the title would put both of them in
128
+ * one worktree, which is the whole of what this slice exists to prevent.
129
+ */
130
+ export function branchNameFor(cardId, cardTitle,
131
+ /* ═══ THE TEAM'S PATTERN, AND `{task}` IS THE ONLY TOKEN. ═══ A team with a
132
+ naming convention has it for a reason and will not adopt a tool that
133
+ ignores it. There is deliberately no `{card}`: a card is the name of a thing
134
+ on screen, and what the person sent is a task. */
135
+ pattern = null) {
136
+ const slug = cardTitle
137
+ .toLowerCase()
138
+ .replace(/[^a-z0-9]+/g, '-')
139
+ .replace(/^-+|-+$/g, '')
140
+ .slice(0, 40)
141
+ .replace(/-+$/, '');
142
+ const short = cardId.replace(/-/g, '').slice(0, 6);
143
+ const task = slug ? `${short}-${slug}` : short;
144
+ const named = (pattern ?? DEFAULT_BRANCH_PATTERN).replace(/\{task\}/g, task);
145
+ /* A PATTERN THAT NAMED NO TASK WOULD PUT EVERY CARD ON ONE BRANCH, which is
146
+ the defect this whole feature exists to prevent, so the task is appended
147
+ rather than the setting being refused: a person who typed `release/` gets
148
+ `release/<task>` and their work stays separated. */
149
+ return named.includes(task) ? named : `${named.replace(/\/+$/, '')}/${task}`;
150
+ }
151
+ /**
152
+ * WHAT WORK STARTS FROM, when nobody has said.
153
+ *
154
+ * `origin/HEAD` is written by `git clone` and by nothing else, so a checkout
155
+ * made with `git init` plus a remote has no such ref and the obvious answer
156
+ * would fail without the second step. Exactly one of `main`/`master` is that
157
+ * answer; both, or neither, is genuinely ambiguous and is a failure rather than
158
+ * a guess. Slice B replaces this throw with the stored setting.
159
+ */
160
+ function baseBranchOf(folder, codebase,
161
+ /* WHAT THE TEAM SET, WHICH BEATS EVERY DETECTION BELOW. Null when nobody has
162
+ said, which is the ordinary case and the one detection is for. */
163
+ stored = null) {
164
+ if (stored) {
165
+ if (gitOk(folder, ['rev-parse', '--verify', `refs/heads/${stored}`])
166
+ || gitOk(folder, ['rev-parse', '--verify', `refs/remotes/origin/${stored}`])) {
167
+ return stored;
168
+ }
169
+ throw pathFree(`${codebase.name} has no branch called ${stored} on this machine, and Project settings says `
170
+ + 'work starts from it. Fetch it, or change the main branch in Project settings.');
171
+ }
172
+ try {
173
+ const head = git(folder, ['symbolic-ref', 'refs/remotes/origin/HEAD']);
174
+ const named = head.replace(/^refs\/remotes\/origin\//, '');
175
+ if (named && named !== head)
176
+ return named;
177
+ }
178
+ catch { /* no origin/HEAD: fall through to the one obvious local answer */ }
179
+ const candidates = ['main', 'master']
180
+ .filter((name) => gitOk(folder, ['rev-parse', '--verify', `refs/heads/${name}`]));
181
+ if (candidates.length === 1)
182
+ return candidates[0];
183
+ throw pathFree(`Set the main branch for ${codebase.name} in Project settings: this machine cannot tell which `
184
+ + 'branch work should start from.');
185
+ }
186
+ /**
187
+ * Which worktree of this repository has `<branch>` checked out, or null when
188
+ * nothing does.
189
+ *
190
+ * One parse of `git worktree list --porcelain`, two questions asked of it: the
191
+ * base lock asks whether the base is held anywhere, and the landing asks WHERE,
192
+ * because that is the working tree the merge has to happen in.
193
+ */
194
+ function worktreeHolding(folder, branch) {
195
+ const listed = git(folder, ['worktree', 'list', '--porcelain']);
196
+ let at = null;
197
+ for (const raw of listed.split('\n')) {
198
+ const line = raw.trim();
199
+ if (line.startsWith('worktree '))
200
+ at = line.slice('worktree '.length);
201
+ else if (line === `branch refs/heads/${branch}`)
202
+ return at;
203
+ }
204
+ return null;
205
+ }
206
+ /**
207
+ * ═══ THE BASE BRANCH IS HELD IN A WORKTREE THE PRODUCT OWNS, AND THAT IS WHAT
208
+ * MAKES THE REFUSAL REAL. ═══
209
+ *
210
+ * Git refuses to check out a branch that is already checked out in another
211
+ * worktree of the same repository. So with the base held, no agent inside a
212
+ * card's copy can check the base out at all, and therefore cannot commit to it:
213
+ * the refusal is git's own, it happens before any commit exists, and
214
+ * `git commit --no-verify` has nothing to bypass because there was never a hook.
215
+ *
216
+ * ═══ AND WHEN THE PERSON IS STANDING ON IT, THERE IS NOTHING TO DO. ═══ Their
217
+ * own checkout is usually on the base branch, which already satisfies the
218
+ * invariant, and `git worktree add` would refuse for exactly the reason the
219
+ * invariant exists. So the lock is created only when the base is checked out
220
+ * nowhere, which is the honest reading of "held somewhere the product owns or
221
+ * somewhere it can see".
222
+ */
223
+ function holdBaseBranch(source, codebaseId, base) {
224
+ if (worktreeHolding(source, base) !== null)
225
+ return;
226
+ const lock = join(worktreeRoot(), codebaseId, 'base');
227
+ mkdirSync(join(worktreeRoot(), codebaseId), { recursive: true });
228
+ git(source, ['worktree', 'prune']);
229
+ git(source, ['worktree', 'add', lock, base]);
230
+ }
231
+ /**
232
+ * ═══ WHETHER THIS MACHINE CAN SETTLE THE BASE BRANCH ON ITS OWN, AND WHAT IT
233
+ * WOULD OFFER IF IT CANNOT. ═══
234
+ *
235
+ * The coordinator is the only thing that can ask a person, and it has to know
236
+ * before it dispatches, so this answers the question WITHOUT starting any work:
237
+ * settled and the name, or unsettled and the branches to offer as options.
238
+ *
239
+ * A codebase this machine has not located is neither: it returns null, because
240
+ * "we cannot tell" is not "it is ambiguous", and asking a person to choose a
241
+ * branch on a repository nobody has is a question with no right answer.
242
+ */
243
+ export function baseBranchState(codebase, stored) {
244
+ if (!hasCheckoutForCodebase(codebase))
245
+ return null;
246
+ const folder = readCodebasePaths()[codebase.gitRemoteUrl];
247
+ try {
248
+ return { settled: baseBranchOf(folder, codebase, stored) };
249
+ }
250
+ catch {
251
+ /* THE REPOSITORY'S OWN BRANCHES, which is what story 3 offers as the
252
+ options: a person who does not know git can still recognise the name of
253
+ the branch their team works on. Local heads and remote branches alike,
254
+ because a fresh clone has one and a long-lived checkout has both. */
255
+ try {
256
+ return {
257
+ candidates: [...new Set(git(folder, ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes/origin'])
258
+ .split('\n')
259
+ .map((name) => name.trim().replace(/^origin\//, ''))
260
+ .filter((name) => name.length > 0 && name !== 'HEAD'))].sort(),
261
+ };
262
+ }
263
+ catch {
264
+ return null;
265
+ }
266
+ }
267
+ }
268
+ /**
269
+ * ═══ WHETHER THE BASE BRANCH IS PROTECTED ON THE REMOTE, ANSWERED BY THE
270
+ * MACHINE THAT HOLDS THE CHECKOUT. ═══
271
+ *
272
+ * The browser cannot answer this: it has no platform credential and the product
273
+ * stores none. The developer's own `gh` is already authenticated, already
274
+ * installed on the machines this runs on, and needs nothing this product keeps.
275
+ * So no token reaches a row, no API client is written, and the web gains no
276
+ * platform dependency.
277
+ *
278
+ * ═══ NULL MEANS UNKNOWN, AND UNKNOWN IS NOT FALSE. ═══ `gh` may be absent,
279
+ * signed out, or the remote may not be GitHub. The one wrong answer available
280
+ * here is `false`, because false is the answer that bypasses a review, so
281
+ * everything that is not a clear yes or a clear no is null. That is why the
282
+ * repository is asked about FIRST: `gh api …/protection` answers 404 both for a
283
+ * branch that is not protected and for a repository this account cannot see,
284
+ * and only the first of those is a `false`.
285
+ */
286
+ export function detectBaseProtection(folder, base) {
287
+ const gh = (args) => {
288
+ try {
289
+ return {
290
+ ok: true,
291
+ out: execFileSync('gh', args, {
292
+ cwd: folder,
293
+ encoding: 'utf8',
294
+ stdio: ['ignore', 'pipe', 'pipe'],
295
+ timeout: 15_000,
296
+ }),
297
+ };
298
+ }
299
+ catch (error) {
300
+ return { ok: false, out: `${error?.stderr ?? ''}` };
301
+ }
302
+ };
303
+ if (!gh(['api', 'repos/{owner}/{repo}', '--jq', '.name']).ok)
304
+ return null;
305
+ const protection = gh(['api', 'repos/{owner}/{repo}/branches/' + base + '/protection', '--jq', '.url']);
306
+ if (protection.ok)
307
+ return true;
308
+ return /not protected|404/i.test(protection.out) ? false : null;
309
+ }
310
+ /**
311
+ * ═══ ONE CARD, ONE COPY, ONE BRANCH. ═══
312
+ *
313
+ * The located checkout is the repository this is made FROM and is never handed
314
+ * to an agent as a cwd: it is the person's own, and no product action may move
315
+ * its branch, index or working tree.
316
+ *
317
+ * `stamped` is what this card's earlier runs already recorded. When the branch
318
+ * is given and no such branch exists here, this FAILS rather than cutting a new
319
+ * empty branch of the same name: the branch and the copy are local to one
320
+ * machine and one config dir, and a card resumed elsewhere has to say so rather
321
+ * than silently starting again from the base with the work left behind.
322
+ *
323
+ * ═══ AND A STAMPED BASE IS THE BASE, WITHOUT DETECTION. ═══ The stored setting
324
+ * is any member's to change at any time, so a card whose copy was cut from
325
+ * `develop` on Monday must not be told on Tuesday that it goes back to `main`.
326
+ * The branch already follows that rule and the base now follows it beside.
327
+ */
328
+ export function worktreeForCard(codebase, cardId, cardTitle, stamped, machineName,
329
+ /* ═══ WHAT THE TEAM DECIDED, OR NOTHING AT ALL. ═══ Both are nullable and both
330
+ are correct empty: a codebase nobody configured detects its base branch and
331
+ names branches the product's own way, which is contract point 10. */
332
+ settings = {}) {
333
+ const source = checkoutForCodebase(codebase, machineName);
334
+ const branch = stamped.branch ?? branchNameFor(cardId, cardTitle, settings.branchPattern ?? null);
335
+ const folder = worktreeFolder(codebase.id, branch);
336
+ try {
337
+ /* ASKED FIRST, SO THE FAILURE NAMES THE RIGHT THING. Without it a located
338
+ folder that is not a repository at all comes back as "set the main
339
+ branch", which is a true sentence about a machine that has no branches
340
+ and useless advice to the person reading it.
341
+
342
+ ═══ THE ROOT, NOT ANY DESCENDANT OF ONE. ═══ `rev-parse --git-dir`
343
+ answers for the nearest repository ABOVE the folder, so every folder
344
+ beneath one passed this guard: a person whose projects sit under a
345
+ git-managed home directory located a plain folder and was told to go and
346
+ set a main branch. `--show-prefix` is the question actually meant here,
347
+ because it is empty at a repository root and the path down from it
348
+ anywhere else. */
349
+ if (isNotRepositoryRoot(source)) {
350
+ throw pathFree(`${codebase.name} on ${machineName} is not a git repository. Locate it again in the `
351
+ + 'companion, or point it at the repository itself.');
352
+ }
353
+ const base = stamped.base ?? baseBranchOf(source, codebase, settings.baseBranch ?? null);
354
+ /* BROUGHT UP TO DATE, AND ITS FAILURE IS NOT THE CARD'S. A machine with no
355
+ remote, no network or no credentials can still do every part of this that
356
+ matters; only the freshness of the start point is lost, and refusing the
357
+ card over it would make working offline impossible. */
358
+ try {
359
+ git(source, ['fetch', 'origin', base]);
360
+ }
361
+ catch { /* best effort */ }
362
+ holdBaseBranch(source, codebase.id, base);
363
+ const exists = gitOk(source, ['rev-parse', '--verify', `refs/heads/${branch}`]);
364
+ if (stamped.branch !== null && !exists) {
365
+ throw pathFree(`${codebase.name} on ${machineName} does not have the branch ${branch} this card's work is `
366
+ + 'on. That work is on the machine the card started on; resume it there.');
367
+ }
368
+ /* THE SECOND VISIT TO A CARD IS ALWAYS THE EXISTING-BRANCH CASE, because
369
+ cleanup removes worktrees and never removes branches. A worktree's `.git`
370
+ is a FILE pointing back at the repository, not a directory. */
371
+ if (existsSync(join(folder, '.git')))
372
+ return { folder, branch, base, source };
373
+ /* ═══ A FOLDER THAT IS THERE AND IS NOT A WORKTREE IS NAMED FOR WHAT IT IS.
374
+ ═══ Found by walking C1. `git worktree add` refuses a directory that
375
+ exists and is not empty, so this card failed every poll with the generic
376
+ sentence below, which says the codebase is not a git repository. That is
377
+ untrue and sends the person to look at the wrong thing. What is really
378
+ there is the wreckage of a copy: a build output an agent wrote, or an add
379
+ that failed after making its directory. The product does not delete it,
380
+ because it never successfully made it. */
381
+ if (existsSync(folder)) {
382
+ throw pathFree(`${codebase.name}'s copy of this card on branch ${branch} is on this machine but is not a `
383
+ + 'working copy any more. Delete that folder and the product will make it again; `cs show` '
384
+ + 'names where it is.');
385
+ }
386
+ mkdirSync(join(worktreeRoot(), codebase.id), { recursive: true });
387
+ git(source, ['worktree', 'prune']);
388
+ /* `origin/<base>` when there is one, the local base when there is not: a
389
+ repository with a remote it cannot reach, or none at all, still branches
390
+ from the only base it has. */
391
+ const startPoint = gitOk(source, ['rev-parse', '--verify', `refs/remotes/origin/${base}`])
392
+ ? `origin/${base}`
393
+ : base;
394
+ if (exists)
395
+ git(source, ['worktree', 'add', folder, branch]);
396
+ else
397
+ git(source, ['worktree', 'add', '-b', branch, folder, startPoint]);
398
+ return { folder, branch, base, source };
399
+ }
400
+ catch (error) {
401
+ /* ═══ PATH-FREE, BECAUSE THE CALLER WRITES THIS TO THE CLOUD. ═══ What was
402
+ composed here says so and passes through; anything from git carries
403
+ `-C <absolute path>` in its first line and is replaced outright. */
404
+ if (error?.pathFree)
405
+ throw error;
406
+ throw pathFree(`This machine could not make ${codebase.name}'s working copy for this card on branch `
407
+ + `${branch}. Check that ${codebase.name} is a git repository on ${machineName}.`);
408
+ }
409
+ }
410
+ /**
411
+ * ═══ THE CARD IS OVER: COMMIT WHAT IS THERE, THEN TAKE THE COPY AWAY. ═══
412
+ *
413
+ * The commit comes first and is unconditional on there being something to
414
+ * commit, so removing the copy cannot lose work. The BRANCH is never touched:
415
+ * work that never landed survives until a person deals with it.
416
+ *
417
+ * It takes the folder alone, and that is deliberate: a worktree knows its own
418
+ * repository, so the sweep needs neither the located checkout nor the codebase
419
+ * row to clean one up.
420
+ */
421
+ /**
422
+ * ═══ EVERYTHING IN A CARD'S COPY, ON ITS BRANCH. ═══
423
+ *
424
+ * `settleCardWorktree`'s own first three lines, extracted rather than rewritten,
425
+ * because the landing needs the commit BEFORE the merge and the sweep needs it
426
+ * before the removal. Committing in two places with two messages is how one card
427
+ * comes to have two ideas of what its work is.
428
+ *
429
+ * ═══ ITS FAILURE IS COMPOSED, WHICH THE SWEEP NEVER NEEDED. ═══ The sweep
430
+ * writes to stderr, so git's own text was harmless there. On the landing path a
431
+ * failure reaches a person through an agent, and `execFileSync`'s message begins
432
+ * `Command failed: git -C <absolute path>`. So this wraps in `worktreeForCard`'s
433
+ * shape: what was composed here passes through, anything else is replaced.
434
+ */
435
+ export function commitCardWork(folder,
436
+ /* WHAT THE COPY IS, IN WORDS AND NEVER AS A PATH. The sweep has only the
437
+ folder and says so; the landing has the codebase and the branch and names
438
+ both, which is what a person reading the failure needs. */
439
+ describe) {
440
+ try {
441
+ git(folder, ['add', '-A']);
442
+ if (git(folder, ['status', '--porcelain']).length > 0) {
443
+ git(folder, ['commit', '--no-verify', '-m', 'Work from this card']);
444
+ }
445
+ }
446
+ catch (error) {
447
+ if (error?.pathFree)
448
+ throw error;
449
+ throw pathFree(`This machine could not commit what is in ${describe}. Nothing was merged and the work is `
450
+ + 'still where it was.');
451
+ }
452
+ }
453
+ /**
454
+ * ═══ THE CARD'S BRANCH, ONTO THE BASE IT WAS CUT FROM, WHERE THE BASE ALREADY
455
+ * LIVES. ═══
456
+ *
457
+ * The base is checked out in exactly one working tree: the lock the product
458
+ * holds, or the person's own checkout when they are standing on it. "Merge into
459
+ * the worktree that holds the base" resolves to whichever it is, so there is one
460
+ * rule here rather than two cases.
461
+ *
462
+ * A FAST-FORWARD IS ALLOWED. No requirement names a merge commit, and `--no-ff`
463
+ * would be a shape invented for the history.
464
+ */
465
+ export function mergeIntoBase(source, branch, base, codebaseName) {
466
+ const holder = worktreeHolding(source, base);
467
+ if (holder === null) {
468
+ throw pathFree(`${codebaseName}'s ${base} is not checked out anywhere on this machine, so there is nowhere `
469
+ + `to put this work. Nothing was merged and it is still on ${branch}.`);
470
+ }
471
+ /* ═══ A MERGE OF THEIR OWN, IN PROGRESS, IS NOT THIS PRODUCT'S TO ABORT. ═══
472
+ The worktree holding the base may be the person's own checkout, and the
473
+ step below would abort on failure. So a conflicted merge already sitting
474
+ there stops this before anything is attempted. */
475
+ if (existsSync(join(holder, '.git', 'MERGE_HEAD')) || existsSync(join(holder, 'MERGE_HEAD'))) {
476
+ throw pathFree(`A merge is already in progress in the copy of ${codebaseName} holding ${base} on this `
477
+ + `machine. Nothing was done, and this card's work is still on ${branch}.`);
478
+ }
479
+ try {
480
+ git(holder, ['merge', '--no-edit', branch]);
481
+ }
482
+ catch {
483
+ /* ITS OWN TRY, SO A FAILED ABORT CANNOT REPLACE THE COMPOSED FAILURE with
484
+ git's own text, which names a folder. */
485
+ try {
486
+ git(holder, ['merge', '--abort']);
487
+ }
488
+ catch { /* nothing to abort */ }
489
+ throw pathFree(`This card's work will not go back onto ${base} in ${codebaseName} cleanly. Nothing was `
490
+ + `merged, ${base} is where it was, and the work is still on ${branch}.`);
491
+ }
492
+ }
493
+ export function settleCardWorktree(folder) {
494
+ commitCardWork(folder, 'this card\'s working copy');
495
+ /* FORCE, AFTER THE COMMIT. Everything tracked is now committed on the branch,
496
+ so what `--force` overrides is only ignored files: an installed
497
+ `node_modules`, a build output, which the next copy makes again. */
498
+ removeWorktree(folder);
499
+ }
500
+ /**
501
+ * Every card copy this machine currently holds, as `<codebase id>/<folder>`
502
+ * pairs. The filesystem is asked first so a machine with no copies asks the
503
+ * database nothing at all.
504
+ *
505
+ * ═══ A FOLDER THAT IS NOT A WORKTREE IS NOT A COPY. ═══ Found by walking C1: a
506
+ * `git worktree add` that failed after its directory existed, or a copy whose
507
+ * `.git` file was removed underneath the product, leaves a directory here that
508
+ * belongs to no repository. `settleCardWorktree` cannot commit it or remove it,
509
+ * so the sweep failed on it EVERY POLL, forever, saying the same sentence to
510
+ * stderr. The filter is what stops the loop: the product cleans up the copies it
511
+ * really made and leaves anything else on disk for a person, rather than
512
+ * retrying something that can never succeed.
513
+ */
514
+ export function worktreesOnThisMachine() {
515
+ const root = worktreeRoot();
516
+ if (!isDirectory(root))
517
+ return [];
518
+ return readdirSync(root, { withFileTypes: true })
519
+ .filter((entry) => entry.isDirectory())
520
+ .flatMap((codebase) => readdirSync(join(root, codebase.name), { withFileTypes: true })
521
+ .filter((entry) => entry.isDirectory() && entry.name !== 'base')
522
+ .map((entry) => ({
523
+ codebaseId: codebase.name,
524
+ slug: entry.name,
525
+ folder: join(root, codebase.name, entry.name),
526
+ }))
527
+ .filter((one) => existsSync(join(one.folder, '.git'))));
528
+ }
529
+ /** Whether a run row's branch names this folder. The folder is the branch with
530
+ * its slashes flattened, and the comparison lives beside the derivation so the
531
+ * two cannot disagree. */
532
+ export const folderIsBranch = (slug, branch) => folderSlug(branch) === slug;
533
+ /** The base-branch lock, once the codebase has no card copies left. Never while
534
+ * one survives: it is what makes their refusal real. */
535
+ export function releaseBaseBranch(codebaseId) {
536
+ const codebaseRoot = join(worktreeRoot(), codebaseId);
537
+ const lock = join(codebaseRoot, 'base');
538
+ if (!isDirectory(lock))
539
+ return;
540
+ /* REAL COPIES ONLY, for the reason `worktreesOnThisMachine` gives: a directory
541
+ that belongs to no repository is not a card's copy, and letting one hold the
542
+ base-branch lock would hold it forever. */
543
+ const remaining = readdirSync(codebaseRoot, { withFileTypes: true })
544
+ .filter((entry) => entry.isDirectory() && entry.name !== 'base')
545
+ .filter((entry) => existsSync(join(codebaseRoot, entry.name, '.git')));
546
+ if (remaining.length > 0)
547
+ return;
548
+ removeWorktree(lock);
549
+ }