@ctrl-spc/cs 0.7.1 → 0.7.3
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/README.md +1 -1
- package/dist/codex-home.js +10 -6
- package/dist/config.js +17 -0
- package/dist/index.js +93 -12
- package/dist/login.js +9 -1
- package/dist/mcp.js +186 -28
- package/dist/panel3/answer.js +12 -12
- package/dist/panel3/checkout.js +522 -2
- package/dist/panel3/cli.js +49 -62
- package/dist/panel3/client.js +53 -16
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/prompt.js +146 -3
- package/dist/panel3/run.js +638 -71
- package/dist/panel3/say.js +10 -10
- package/dist/panel3/show.js +195 -22
- package/dist/panel3/spawn.js +5 -2
- package/dist/panel3/tools.js +326 -5
- package/dist/presence.js +8 -0
- package/dist/skills.js +174 -0
- package/package.json +2 -3
package/dist/panel3/checkout.js
CHANGED
|
@@ -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 {
|
|
10
|
-
import {
|
|
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
|
+
}
|