@bridge4dev/runner 0.13.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude.d.ts +15 -7
- package/dist/adapters/claude.js +1024 -70
- package/dist/adapters/codex.d.ts +18 -3
- package/dist/adapters/codex.js +224 -65
- package/dist/adapters/questions.d.ts +42 -0
- package/dist/adapters/questions.js +86 -0
- package/dist/adapters/types.d.ts +200 -4
- package/dist/attachments.d.ts +8 -1
- package/dist/attachments.js +22 -4
- package/dist/auth-relay.d.ts +33 -3
- package/dist/auth-relay.js +199 -16
- package/dist/auto-resume.d.ts +18 -0
- package/dist/auto-resume.js +104 -0
- package/dist/commit-message.d.ts +51 -0
- package/dist/commit-message.js +224 -0
- package/dist/config.d.ts +29 -6
- package/dist/config.js +15 -0
- package/dist/crash-note.d.ts +54 -0
- package/dist/crash-note.js +105 -0
- package/dist/environment.d.ts +171 -0
- package/dist/environment.js +409 -0
- package/dist/git.d.ts +81 -0
- package/dist/git.js +301 -15
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +715 -8
- package/dist/paths.d.ts +35 -0
- package/dist/paths.js +45 -0
- package/dist/policy.d.ts +63 -0
- package/dist/policy.js +412 -10
- package/dist/protocol.d.ts +382 -60
- package/dist/protocol.js +104 -1
- package/dist/recipe-schema.d.ts +310 -0
- package/dist/recipe-schema.js +103 -0
- package/dist/recipe.d.ts +94 -0
- package/dist/recipe.js +238 -0
- package/dist/self-update.d.ts +21 -0
- package/dist/self-update.js +73 -1
- package/dist/service-unit.d.ts +61 -2
- package/dist/service-unit.js +150 -14
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1045 -57
- package/dist/verify-queue.d.ts +17 -0
- package/dist/verify-queue.js +100 -0
- package/dist/verify.d.ts +203 -0
- package/dist/verify.js +788 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/git.js
CHANGED
|
@@ -2,33 +2,122 @@ 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 { worktreesDir } from './paths.js';
|
|
5
|
+
import { previewsDir, worktreesDir } from './paths.js';
|
|
6
|
+
import { firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
const GIT_TIMEOUT_MS = 30_000;
|
|
8
9
|
async function git(cwd, ...args) {
|
|
9
10
|
const { stdout } = await execFileAsync('git', args, { cwd, timeout: GIT_TIMEOUT_MS });
|
|
10
11
|
return stdout.trim();
|
|
11
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Can this runner actually work in this directory — as the user it runs as?
|
|
15
|
+
*
|
|
16
|
+
* Everything here answers with the fix rather than the symptom. Binding a
|
|
17
|
+
* project is the moment the two legitimate install choices (root / dedicated
|
|
18
|
+
* user) start to differ, and until now the second one failed by forwarding
|
|
19
|
+
* git's own words to a dashboard the person may have no shell behind:
|
|
20
|
+
* «git check failed: fatal: detected dubious ownership in repository at
|
|
21
|
+
* '/opt/ids'». That sentence is true and unactionable.
|
|
22
|
+
*/
|
|
12
23
|
export async function validateWorkspacePath(workspacePath) {
|
|
13
|
-
|
|
14
|
-
|
|
24
|
+
const me = runnerIdentity();
|
|
25
|
+
const dir = inspectPath(workspacePath);
|
|
26
|
+
// «Cannot look» and «is not there» are opposite instructions to whoever reads
|
|
27
|
+
// this, and both arrive as a thrown `statSync`. A dedicated-user install hits
|
|
28
|
+
// the first one constantly — usually on a directory ABOVE the project, which
|
|
29
|
+
// is why the blocking one is named rather than the one that was asked about.
|
|
30
|
+
if (dir.unreachable) {
|
|
31
|
+
const blocked = firstUnreachableAncestor(workspacePath) ?? workspacePath;
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
exists: true,
|
|
35
|
+
isGitRepo: false,
|
|
36
|
+
error: `The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot reach ${workspacePath}. ` +
|
|
37
|
+
`Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
|
|
38
|
+
`or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (!dir.exists || !dir.isDirectory) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
exists: dir.exists,
|
|
45
|
+
isGitRepo: false,
|
|
46
|
+
error: dir.exists
|
|
47
|
+
? `${workspacePath} is not a directory`
|
|
48
|
+
: `${workspacePath} does not exist on this server`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// A directory we cannot even enter: as a non-root runner this is the most
|
|
52
|
+
// common outcome of pointing at somebody else's project, and git's error for
|
|
53
|
+
// it says nothing about permissions.
|
|
54
|
+
if (!dir.readable) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
exists: true,
|
|
58
|
+
isGitRepo: false,
|
|
59
|
+
error: `The runner runs as ${me.user} and cannot read ${workspacePath}. ` +
|
|
60
|
+
`Give that user access (for example \`setfacl -R -m u:${me.user}:rX ${workspacePath}\`, ` +
|
|
61
|
+
`or \`chown -R ${me.user} ${workspacePath}\` if the directory is meant to be theirs), then try again.`,
|
|
62
|
+
};
|
|
15
63
|
}
|
|
16
64
|
try {
|
|
17
65
|
const inside = await git(workspacePath, 'rev-parse', '--is-inside-work-tree');
|
|
18
66
|
if (inside !== 'true') {
|
|
19
67
|
return { ok: false, exists: true, isGitRepo: false, error: 'Not a git work tree' };
|
|
20
68
|
}
|
|
21
|
-
const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
22
|
-
return { ok: true, exists: true, isGitRepo: true, branch };
|
|
23
69
|
}
|
|
24
70
|
catch (error) {
|
|
71
|
+
const message = String(error instanceof Error ? error.message : error);
|
|
72
|
+
if (looksLikeDubiousOwnership(message)) {
|
|
73
|
+
// The single most common failure of a dedicated-user install, and the one
|
|
74
|
+
// whose fix is one line — which is why we print the line.
|
|
75
|
+
const owner = dir.ownerUid >= 0 ? ` (it belongs to uid ${dir.ownerUid})` : '';
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
exists: true,
|
|
79
|
+
isGitRepo: false,
|
|
80
|
+
error: `Git refuses to use ${workspacePath} because the runner runs as ${me.user} and does not own it${owner}. ` +
|
|
81
|
+
`Run this on the server as ${me.user}: ${safeDirectoryCommand(workspacePath)} — ` +
|
|
82
|
+
`or run \`devbridge-runner doctor --fix\`, which does it for every bound project. ` +
|
|
83
|
+
`Note: if you instead hand the directory over with \`chown\`, add the same line for its previous owner, ` +
|
|
84
|
+
`who will otherwise lose git in that repository.`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
25
87
|
return {
|
|
26
88
|
ok: false,
|
|
27
89
|
exists: true,
|
|
28
90
|
isGitRepo: false,
|
|
29
|
-
error: `git check failed: ${
|
|
91
|
+
error: `git check failed: ${message.slice(0, 300)}`,
|
|
30
92
|
};
|
|
31
93
|
}
|
|
94
|
+
// Sessions write here: a worktree registers itself inside `.git/worktrees`,
|
|
95
|
+
// and a DIRECT-mode session commits in the tree itself. Read-only access
|
|
96
|
+
// binds fine and then fails on the first session, which is the worst place
|
|
97
|
+
// to learn about it.
|
|
98
|
+
const gitDir = await resolveGitDir(workspacePath);
|
|
99
|
+
const gitAccess = gitDir ? inspectPath(gitDir) : null;
|
|
100
|
+
if (gitAccess && gitAccess.exists && !gitAccess.writable) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
exists: true,
|
|
104
|
+
isGitRepo: true,
|
|
105
|
+
error: `The runner runs as ${me.user} and cannot write to ${gitDir}, so it could not create a branch or a worktree here. ` +
|
|
106
|
+
`Give that user write access to the repository (for example \`chown -R ${me.user} ${workspacePath}\`), then try again.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
110
|
+
return { ok: true, exists: true, isGitRepo: true, branch };
|
|
111
|
+
}
|
|
112
|
+
/** The real `.git` directory of a work tree (a linked worktree has a file there). */
|
|
113
|
+
async function resolveGitDir(workspacePath) {
|
|
114
|
+
try {
|
|
115
|
+
const common = await git(workspacePath, 'rev-parse', '--git-common-dir');
|
|
116
|
+
return path.resolve(workspacePath, common);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
32
121
|
}
|
|
33
122
|
export function sessionShortId(sessionId) {
|
|
34
123
|
return sessionId.replace(/-/g, '').slice(0, 8);
|
|
@@ -92,7 +181,8 @@ export async function ensureSessionWorktree(workspacePath, sessionId, branchHint
|
|
|
92
181
|
// A ticket group gets a branch named after its tickets so the work reads as
|
|
93
182
|
// one thing in git history. The worktree DIRECTORY stays id-derived — it is
|
|
94
183
|
// the collision guard below, and two sessions must never share one.
|
|
95
|
-
const
|
|
184
|
+
const planned = options.plan ? sanitizeBranch(options.plan.branch) : null;
|
|
185
|
+
const branch = planned ?? sanitizeBranch(branchHint) ?? `devbridge/s-${short}`;
|
|
96
186
|
const worktreePath = sessionWorktreePath(sessionId);
|
|
97
187
|
if (fs.existsSync(path.join(worktreePath, '.git'))) {
|
|
98
188
|
// Runner restart — reuse, but verify the worktree really is ours: a
|
|
@@ -109,19 +199,198 @@ export async function ensureSessionWorktree(workspacePath, sessionId, branchHint
|
|
|
109
199
|
const branchExists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', branch)
|
|
110
200
|
.then(() => true)
|
|
111
201
|
.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;
|
|
206
|
+
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`);
|
|
208
|
+
}
|
|
209
|
+
if (plan?.source === 'CONTINUE' && !branchExists) {
|
|
210
|
+
throw new Error(`branch ${branch} no longer exists in ${workspacePath}`);
|
|
211
|
+
}
|
|
112
212
|
if (branchExists) {
|
|
213
|
+
// Git refuses to check one branch out in two worktrees. Saying which
|
|
214
|
+
// worktree holds it beats the raw «is already checked out» from git.
|
|
215
|
+
const holder = await worktreeHolding(workspacePath, branch);
|
|
216
|
+
if (holder) {
|
|
217
|
+
throw new Error(`branch ${branch} is already checked out in ${holder}`);
|
|
218
|
+
}
|
|
113
219
|
await git(workspacePath, 'worktree', 'add', worktreePath, branch);
|
|
220
|
+
return { branch, worktreePath };
|
|
114
221
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
222
|
+
if (options.requireExistingBranch) {
|
|
223
|
+
// The caller is restoring a session that already produced work. Creating
|
|
224
|
+
// a fresh branch off HEAD here would look like success while quietly
|
|
225
|
+
// hiding every commit the agent made — fail loudly instead.
|
|
226
|
+
throw new Error(`session branch ${branch} no longer exists in ${workspacePath}`);
|
|
227
|
+
}
|
|
228
|
+
// Fork point: the sha the API pinned, then the branch it named, then HEAD.
|
|
229
|
+
// A pinned sha that is not in this repository is not worth guessing around —
|
|
230
|
+
// branching off the wrong commit is the failure this whole plan removes.
|
|
231
|
+
const startPoint = await resolveStartPoint(workspacePath, plan);
|
|
232
|
+
// What HEAD points at, read BEFORE the worktree is added. This is the only
|
|
233
|
+
// chance to learn the fork point for a session created while the runner was
|
|
234
|
+
// offline: the API had nobody to ask, so `branchPlan.baseBranch` is empty and
|
|
235
|
+
// without this the base is never pinned at all — which switches off the drift
|
|
236
|
+
// guard for that session's whole life (QA-107).
|
|
237
|
+
const headBranch = plan?.baseBranch
|
|
238
|
+
? plan.baseBranch
|
|
239
|
+
: await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD')
|
|
240
|
+
.then((name) => (name && name !== 'HEAD' ? name : undefined))
|
|
241
|
+
.catch(() => undefined);
|
|
242
|
+
await git(workspacePath, 'worktree', 'add', '-b', branch, worktreePath, startPoint);
|
|
243
|
+
const baseSha = await git(workspacePath, 'rev-parse', startPoint).catch(() => undefined);
|
|
244
|
+
return {
|
|
245
|
+
branch,
|
|
246
|
+
worktreePath,
|
|
247
|
+
...(headBranch ? { baseBranch: headBranch } : {}),
|
|
248
|
+
...(baseSha ? { baseSha } : {}),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* DIRECT mode (session 16): the session's workplace IS the project folder.
|
|
253
|
+
*
|
|
254
|
+
* Nothing is created and nothing is moved. The folder stays on the branch it is
|
|
255
|
+
* on, and that branch is the session's branch — which is the whole point: the
|
|
256
|
+
* work the agent does is already where the person expects to find it, with no
|
|
257
|
+
* «Apply» step in between and nothing to lose if the session is never applied.
|
|
258
|
+
*
|
|
259
|
+
* The two refusals are both about NOT guessing:
|
|
260
|
+
*
|
|
261
|
+
* - not a git work tree — every git surface downstream would fail one call at
|
|
262
|
+
* a time instead of once, here, with a sentence that names the folder;
|
|
263
|
+
* - a detached HEAD — commits would land on no branch at all and be reachable
|
|
264
|
+
* only by sha. A person who checked out a tag to look at something must not
|
|
265
|
+
* discover an agent committed onto it.
|
|
266
|
+
*
|
|
267
|
+
* The path returned is the repository ROOT, not necessarily the folder that was
|
|
268
|
+
* configured. Every path in the Source Control panel is repo-root-relative
|
|
269
|
+
* because that is what `git status` prints, so the root is the only place the
|
|
270
|
+
* paths and the commands agree — and it is also the confinement root layer 1
|
|
271
|
+
* hands the agent.
|
|
272
|
+
*/
|
|
273
|
+
export async function prepareDirectWorkspace(workspacePath) {
|
|
274
|
+
const inside = await git(workspacePath, 'rev-parse', '--is-inside-work-tree').catch(() => null);
|
|
275
|
+
if (inside !== 'true') {
|
|
276
|
+
throw new Error(`${workspacePath} is not a git work tree`);
|
|
277
|
+
}
|
|
278
|
+
const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
|
|
279
|
+
if (!branch || branch === 'HEAD') {
|
|
280
|
+
throw new Error('The project folder is not on a branch (detached HEAD) — check a branch out there before starting a session');
|
|
281
|
+
}
|
|
282
|
+
const top = await git(workspacePath, 'rev-parse', '--show-toplevel').catch(() => null);
|
|
283
|
+
const baseSha = await git(workspacePath, 'rev-parse', 'HEAD').catch(() => undefined);
|
|
284
|
+
return {
|
|
285
|
+
branch,
|
|
286
|
+
worktreePath: top || workspacePath,
|
|
287
|
+
baseBranch: branch,
|
|
288
|
+
...(baseSha ? { baseSha } : {}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
async function resolveStartPoint(workspacePath, plan) {
|
|
292
|
+
if (plan?.baseSha && /^[0-9a-f]{7,64}$/i.test(plan.baseSha)) {
|
|
293
|
+
const exists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', `${plan.baseSha}^{commit}`)
|
|
294
|
+
.then(() => true)
|
|
295
|
+
.catch(() => false);
|
|
296
|
+
if (exists)
|
|
297
|
+
return plan.baseSha;
|
|
298
|
+
throw new Error(`base commit ${plan.baseSha} is not in ${workspacePath}`);
|
|
299
|
+
}
|
|
300
|
+
const baseBranch = plan?.baseBranch ? sanitizeBranch(plan.baseBranch) : null;
|
|
301
|
+
if (baseBranch) {
|
|
302
|
+
const exists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', baseBranch)
|
|
303
|
+
.then(() => true)
|
|
304
|
+
.catch(() => false);
|
|
305
|
+
if (exists)
|
|
306
|
+
return baseBranch;
|
|
307
|
+
throw new Error(`base branch ${baseBranch} is not in ${workspacePath}`);
|
|
308
|
+
}
|
|
309
|
+
return 'HEAD';
|
|
310
|
+
}
|
|
311
|
+
/** Which worktree, if any, currently has `branch` checked out. */
|
|
312
|
+
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
|
+
}
|
|
121
325
|
}
|
|
122
|
-
await git(workspacePath, 'worktree', 'add', '-b', branch, worktreePath, 'HEAD');
|
|
123
326
|
}
|
|
124
|
-
return
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
// ─── Preview worktree (session 14) ───────────────────────────────────
|
|
330
|
+
/**
|
|
331
|
+
* One preview checkout per repository, and never the project folder itself.
|
|
332
|
+
*
|
|
333
|
+
* The honest constraint behind «show me branch B while branch A is running»:
|
|
334
|
+
* the docker build context IS the project folder, so a rebuild there replaces
|
|
335
|
+
* the single running copy. A second worktree is the only way to have both — and
|
|
336
|
+
* the runner NEVER switches the branch in the project folder, because that
|
|
337
|
+
* silently moves the base, and the target of «Apply», for every session of the
|
|
338
|
+
* project at once.
|
|
339
|
+
*
|
|
340
|
+
* Checked out DETACHED at a sha rather than on the branch: git refuses to have
|
|
341
|
+
* one branch checked out twice, and a preview is a snapshot of a commit, not a
|
|
342
|
+
* place anybody commits.
|
|
343
|
+
*/
|
|
344
|
+
export function previewWorktreePath(workspaceKey) {
|
|
345
|
+
const safe = workspaceKey.replace(/[^A-Za-z0-9_-]/g, '-').slice(-60) || 'preview';
|
|
346
|
+
return path.join(previewsDir(), safe);
|
|
347
|
+
}
|
|
348
|
+
export async function ensurePreviewWorktree(input) {
|
|
349
|
+
const branch = sanitizeBranch(input.branch);
|
|
350
|
+
if (!branch)
|
|
351
|
+
throw new Error('Invalid branch name');
|
|
352
|
+
const sha = await git(input.workspacePath, 'rev-parse', `${branch}^{commit}`).catch(() => null);
|
|
353
|
+
if (!sha)
|
|
354
|
+
throw new Error(`Branch ${branch} is not in this repository`);
|
|
355
|
+
const worktreePath = previewWorktreePath(input.workspaceKey);
|
|
356
|
+
fs.mkdirSync(previewsDir(), { recursive: true, mode: 0o700 });
|
|
357
|
+
await git(input.workspacePath, 'worktree', 'prune').catch(() => undefined);
|
|
358
|
+
if (fs.existsSync(path.join(worktreePath, '.git'))) {
|
|
359
|
+
// Reuse the slot: a preview is a place, not a history. `--detach` keeps it
|
|
360
|
+
// out of the branch namespace, so moving it cannot collide with a session.
|
|
361
|
+
await git(worktreePath, 'checkout', '--detach', sha);
|
|
362
|
+
await git(worktreePath, 'reset', '--hard', sha);
|
|
363
|
+
await git(worktreePath, 'clean', '-fd').catch(() => undefined);
|
|
364
|
+
return { worktreePath, branch, sha };
|
|
365
|
+
}
|
|
366
|
+
await git(input.workspacePath, 'worktree', 'add', '--detach', worktreePath, sha);
|
|
367
|
+
return { worktreePath, branch, sha };
|
|
368
|
+
}
|
|
369
|
+
/** Give the slot back. The branch is untouched — it was never checked out. */
|
|
370
|
+
export async function removePreviewWorktree(workspaceKey) {
|
|
371
|
+
const worktreePath = previewWorktreePath(workspaceKey);
|
|
372
|
+
if (!fs.existsSync(worktreePath))
|
|
373
|
+
return false;
|
|
374
|
+
const gitFile = path.join(worktreePath, '.git');
|
|
375
|
+
let mainRepo = null;
|
|
376
|
+
if (fs.existsSync(gitFile) && fs.statSync(gitFile).isFile()) {
|
|
377
|
+
const pointer = fs
|
|
378
|
+
.readFileSync(gitFile, 'utf8')
|
|
379
|
+
.match(/^gitdir:\s*(.+)$/m)?.[1]
|
|
380
|
+
?.trim();
|
|
381
|
+
if (pointer) {
|
|
382
|
+
const dotGit = path.resolve(pointer, '..', '..');
|
|
383
|
+
if (path.basename(dotGit) === '.git')
|
|
384
|
+
mainRepo = path.dirname(dotGit);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (mainRepo) {
|
|
388
|
+
await git(mainRepo, 'worktree', 'remove', '--force', worktreePath);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
392
|
+
}
|
|
393
|
+
return true;
|
|
125
394
|
}
|
|
126
395
|
/**
|
|
127
396
|
* Drop a session branch after its worktree is gone. Only ever called when the
|
|
@@ -133,6 +402,23 @@ export async function deleteSessionBranch(workspacePath, branch) {
|
|
|
133
402
|
const safe = sanitizeBranch(branch);
|
|
134
403
|
if (!safe)
|
|
135
404
|
throw new Error(`refusing to delete an unsafe branch name: ${branch}`);
|
|
405
|
+
// Session 16: a DIRECT session's «session branch» is the branch the PERSON is
|
|
406
|
+
// working on, and deleting a session must never be able to delete that. The
|
|
407
|
+
// API already refuses to ask, and git itself refuses to drop a branch that is
|
|
408
|
+
// checked out — but this is the line that would run the command, so it is the
|
|
409
|
+
// line that checks. Three cheap reads against an operation with no undo.
|
|
410
|
+
// The worktree this branch lived in was removed a moment ago; without a prune
|
|
411
|
+
// its stale registration would answer «still checked out» and leave a branch
|
|
412
|
+
// behind on every ordinary purge.
|
|
413
|
+
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}`);
|
|
421
|
+
}
|
|
136
422
|
await git(workspacePath, 'branch', '-D', safe);
|
|
137
423
|
}
|
|
138
424
|
/**
|