@codedology/code-forge 0.7.7 → 0.9.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/CHANGELOG.md +97 -0
- package/README.md +68 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +390 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +45 -7
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +25 -5
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +19 -2
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +427 -41
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +7 -4
- package/src/cli/review-file.mjs +324 -18
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +91 -3
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +17 -5
- package/src/ledger/write.mjs +7 -1
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +36 -2
- package/src/review/fixloop.mjs +25 -15
- package/src/review/gate-check.mjs +384 -83
- package/src/review/open-findings.mjs +59 -0
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +103 -39
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +51 -0
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +10 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +46 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +230 -24
- package/src/worker/loop.mjs +29 -8
- package/src/worker/queue.mjs +296 -15
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +46 -6
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree isolation (B62a, issue #13 item 4; `proof.isolation: worktree`). Each block gets its own
|
|
3
|
+
* git worktree on its own branch, made by `block open` before the block is recorded and removed,
|
|
4
|
+
* on request, by `block close --remove-worktree` after a successful close. Nothing here pushes or
|
|
5
|
+
* merges, and the only branch it deletes is one a failed open made itself.
|
|
6
|
+
*
|
|
7
|
+
* The settings come from the run's config snapshot (`configInForce`), never from the file on disk:
|
|
8
|
+
* `proof.isolation` and `proof.worktree` are run-fixed (`IMMUTABLE_KEYS`), so `run reload` cannot
|
|
9
|
+
* switch isolation while blocks are open.
|
|
10
|
+
*
|
|
11
|
+
* proof.worktree.dir where the trees go, resolved from the repository top level (not from the
|
|
12
|
+
* workspace, even for a workspace in a subdirectory); default
|
|
13
|
+
* `../<repo-name>-worktrees`, where `<repo-name>` is replaced with the
|
|
14
|
+
* basename of the top level — beside the repository, so the main tree
|
|
15
|
+
* never lists a tree as untracked. The block's tree is `<dir>/<block>`.
|
|
16
|
+
* proof.worktree.branch a template; only `{run}` and `{block}` are substituted, and the result
|
|
17
|
+
* must pass `git check-ref-format --branch`. Default `forge/{run}/{block}`.
|
|
18
|
+
* proof.worktree.base the commit-ish the tree starts from; default the block's base (the
|
|
19
|
+
* `--base` flag of `block open`, else HEAD).
|
|
20
|
+
*
|
|
21
|
+
* Every git call is an argv array (never a shell) with the inherited `GIT_*` environment stripped
|
|
22
|
+
* (`gitEnv`), like the other git calls of the package. It never leaves half a tree behind: a
|
|
23
|
+
* failed `git worktree add` deletes the branch it made, and a tree made for an open that then
|
|
24
|
+
* fails to record is removed again (`rollback`).
|
|
25
|
+
*
|
|
26
|
+
* B62b: {@link blockTree} says where a block with a tree does its work — `gates run --run --block`
|
|
27
|
+
* runs the gates there, `review-file` hashes the content there and the worker reviews it there,
|
|
28
|
+
* and `block close` measures that tree against its base. The run's state (the record, the ledger,
|
|
29
|
+
* the review queue and signed results under the main top level's `.code-forge/`) stays in the
|
|
30
|
+
* main tree, and nothing checks out, pushes or merges: the work stays on the block's branch.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { lstatSync, realpathSync, statSync } from 'node:fs';
|
|
34
|
+
import os from 'node:os';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { configInForce } from '../state/config-snapshot.mjs';
|
|
37
|
+
import { StateError } from '../state/paths.mjs';
|
|
38
|
+
import { exec } from '../util/exec.mjs';
|
|
39
|
+
|
|
40
|
+
/** The default branch template (`proof.worktree.branch`). */
|
|
41
|
+
export const DEFAULT_BRANCH_TEMPLATE = 'forge/{run}/{block}';
|
|
42
|
+
|
|
43
|
+
/** `git worktree add` checks out a whole tree: allow it longer than a read. */
|
|
44
|
+
const ADD_TIMEOUT_MS = 300000;
|
|
45
|
+
const GIT_TIMEOUT_MS = 30000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {{dir: string | null, branch: string, base: string | null}} WorktreeSettings
|
|
49
|
+
* @typedef {{path: string, branch: string, base_sha: string}} WorktreeRecord - what the block entry stores.
|
|
50
|
+
* @typedef {{worktree: WorktreeRecord, created: boolean, rollback: (() => Promise<string | null>) | null}} MadeWorktree
|
|
51
|
+
* `rollback` (only when `created`) removes the tree this call made, and its branch if this call made that too;
|
|
52
|
+
* it resolves to null when both are gone, else a sentence naming each leftover with git's reason.
|
|
53
|
+
* @typedef {{path: string, make: () => Promise<MadeWorktree>}} PlannedWorktree
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The worktree settings of the run, from its config snapshot in force, or null when the run is
|
|
58
|
+
* not isolated by worktree (`proof.isolation` export or lock, absent, or a record from before
|
|
59
|
+
* snapshots).
|
|
60
|
+
* @param {Record<string, any>} record
|
|
61
|
+
* @returns {WorktreeSettings | null}
|
|
62
|
+
* @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
|
|
63
|
+
*/
|
|
64
|
+
export function worktreeSettings(record) {
|
|
65
|
+
const proof = configInForce(record)?.proof;
|
|
66
|
+
if (proof?.isolation !== 'worktree') return null;
|
|
67
|
+
const wt = proof.worktree ?? {};
|
|
68
|
+
return {
|
|
69
|
+
// the schema's default text `../<repo-name>-worktrees` is documentation only, never used as is:
|
|
70
|
+
// an unset dir is null here, and `prepareWorktree` puts the top level's basename in its place
|
|
71
|
+
dir: typeof wt.dir === 'string' ? wt.dir : null,
|
|
72
|
+
branch: typeof wt.branch === 'string' ? wt.branch : DEFAULT_BRANCH_TEMPLATE,
|
|
73
|
+
base: typeof wt.base === 'string' ? wt.base : null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The branch name for a block: `{run}` and `{block}` substituted in one pass (a value that itself
|
|
79
|
+
* contains `{block}` is never substituted again); any other text is kept as written.
|
|
80
|
+
* @param {string} template @param {{run: string, block: string}} values
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function branchName(template, { run, block }) {
|
|
84
|
+
return template.replace(/\{(run|block)\}/g, (_whole, key) => (key === 'run' ? run : block));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `process.env` minus every inherited `GIT_*` (hooks export GIT_DIR…), with no system/global config and no prompt. */
|
|
88
|
+
function gitEnv() {
|
|
89
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
90
|
+
const env = { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull, GIT_TERMINAL_PROMPT: '0' };
|
|
91
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
92
|
+
if (!key.startsWith('GIT_')) env[key] = value;
|
|
93
|
+
}
|
|
94
|
+
return env;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {string[]} argv @param {string} cwd @param {number} [timeoutMs] */
|
|
98
|
+
function git(argv, cwd, timeoutMs = GIT_TIMEOUT_MS) {
|
|
99
|
+
return exec(argv, { cwd, env: gitEnv(), timeoutMs });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Git's own reason for a failed call: its first `fatal:` or `error:` line without the prefix, else
|
|
104
|
+
* the exit code.
|
|
105
|
+
* @param {{stderr: string, code: number | null}} res @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function gitReason(res) {
|
|
108
|
+
const line = res.stderr.split('\n').find((l) => /^(fatal|error): /.test(l));
|
|
109
|
+
return line ? line.replace(/^(fatal|error): /, '').trim() : `git exited ${res.code}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {string} name @param {string} top @param {string} id
|
|
114
|
+
* @throws {StateError} `worktree-failed` when git does not take `name` as a branch name.
|
|
115
|
+
*/
|
|
116
|
+
async function assertBranchName(name, top, id) {
|
|
117
|
+
const refuse = () => {
|
|
118
|
+
throw new StateError('worktree-failed', `block ${id}: proof.worktree.branch gives ${JSON.stringify(name)}, which is not a valid git branch name; nothing was recorded`);
|
|
119
|
+
};
|
|
120
|
+
if (name.startsWith('-')) refuse();
|
|
121
|
+
const res = await git(['git', 'check-ref-format', '--branch', name], top);
|
|
122
|
+
// `--branch` also expands `@{-n}` to a previous branch: the name must come back unchanged
|
|
123
|
+
if (res.result !== 'ok' || res.stdout.trim() !== name) refuse();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {string} branch @param {string} top @returns {Promise<boolean>} */
|
|
127
|
+
async function branchExists(branch, top) {
|
|
128
|
+
const res = await git(['git', 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`], top);
|
|
129
|
+
return res.result === 'ok';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @param {string} p @returns {boolean} whether anything (a dangling symlink included) is at `p`. */
|
|
133
|
+
function occupied(p) {
|
|
134
|
+
try {
|
|
135
|
+
lstatSync(p);
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {string} p @returns {string} the realpath, or `p` itself when it cannot be resolved. */
|
|
143
|
+
function real(p) {
|
|
144
|
+
try {
|
|
145
|
+
return realpathSync(p);
|
|
146
|
+
} catch {
|
|
147
|
+
return p;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The worktree git has registered at `where`: its branch (`refs/heads/<name>` ⇒ `<name>`; null for
|
|
153
|
+
* a detached HEAD) and whether git marks it `prunable` (its directory is gone). Null when no
|
|
154
|
+
* worktree of the repository is registered there.
|
|
155
|
+
* @param {string} where @param {string} top
|
|
156
|
+
* @returns {Promise<{branch: string | null, prunable: boolean} | null>}
|
|
157
|
+
* @throws {StateError} `git-failed` when the list cannot be read.
|
|
158
|
+
*/
|
|
159
|
+
async function registeredAt(where, top) {
|
|
160
|
+
const res = await git(['git', 'worktree', 'list', '--porcelain', '-z'], top);
|
|
161
|
+
if (res.result !== 'ok') throw new StateError('git-failed', `git worktree list failed (${gitReason(res)})`);
|
|
162
|
+
const target = real(where);
|
|
163
|
+
// `-z`: one NUL-terminated line per attribute, an empty line between worktrees
|
|
164
|
+
/** @type {{branch: string | null, prunable: boolean} | null} */
|
|
165
|
+
let current = null;
|
|
166
|
+
/** @type {{branch: string | null, prunable: boolean} | null} */
|
|
167
|
+
let found = null;
|
|
168
|
+
for (const line of res.stdout.split('\0')) {
|
|
169
|
+
if (line.startsWith('worktree ')) {
|
|
170
|
+
current = { branch: null, prunable: false };
|
|
171
|
+
if (real(line.slice('worktree '.length)) === target) found = current;
|
|
172
|
+
} else if (current !== null && line.startsWith('branch refs/heads/')) current.branch = line.slice('branch refs/heads/'.length);
|
|
173
|
+
else if (current !== null && (line === 'prunable' || line.startsWith('prunable '))) current.prunable = true;
|
|
174
|
+
}
|
|
175
|
+
return found;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {string} id
|
|
180
|
+
* @throws {StateError} `worktree-failed` unless `id` is one plain path segment, so `<dir>/<id>` stays inside `dir`.
|
|
181
|
+
*/
|
|
182
|
+
function assertSegment(id) {
|
|
183
|
+
if (id === '' || id === '.' || id === '..' || id.includes('/') || id.includes('\\') || id.includes('\0')) {
|
|
184
|
+
throw new StateError('worktree-failed', `block ${JSON.stringify(id)}: a block id must be one plain path segment (no "/", "\\" or "..") to name its worktree; nothing was recorded`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Remove what a failed open made: the tree at `treePath` (`git worktree remove --force`), then the
|
|
190
|
+
* branch (`git branch -D`); either may be null (nothing to remove). Each result is checked.
|
|
191
|
+
* @param {string} top @param {string | null} treePath @param {string | null} branch
|
|
192
|
+
* @returns {Promise<string[]>} one entry per leftover, with git's reason; empty when all is gone.
|
|
193
|
+
*/
|
|
194
|
+
async function discard(top, treePath, branch) {
|
|
195
|
+
/** @type {string[]} */
|
|
196
|
+
const left = [];
|
|
197
|
+
if (treePath !== null) {
|
|
198
|
+
const res = await git(['git', 'worktree', 'remove', '--force', treePath], top);
|
|
199
|
+
if (res.result !== 'ok') left.push(`the worktree ${treePath} (${gitReason(res)})`);
|
|
200
|
+
}
|
|
201
|
+
if (branch !== null) {
|
|
202
|
+
const res = await git(['git', 'branch', '-D', branch], top);
|
|
203
|
+
if (res.result !== 'ok') left.push(`the branch ${branch} (${gitReason(res)})`);
|
|
204
|
+
}
|
|
205
|
+
return left;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @param {string[]} left - from {@link discard}, not empty. @returns {string} */
|
|
209
|
+
function leftBehind(left) {
|
|
210
|
+
return `left behind: ${left.join('; ')} — remove ${left.length === 1 ? 'it' : 'them'} by hand`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* `git worktree add <treePath> …`. When it fails, whatever it made is removed again so a failed
|
|
215
|
+
* open leaves nothing behind: a tree git registered anyway (after a timeout, say) is removed
|
|
216
|
+
* (`--force`; a registration whose directory is gone is pruned), then a branch it made
|
|
217
|
+
* (`newBranch`) is deleted. Anything that cannot be removed is named in the refusal.
|
|
218
|
+
* @param {string[]} argv @param {string} treePath @param {string} top @param {string} id @param {string | null} newBranch
|
|
219
|
+
* @throws {StateError} `worktree-failed` with git's reason.
|
|
220
|
+
*/
|
|
221
|
+
async function addTree(argv, treePath, top, id, newBranch) {
|
|
222
|
+
const res = await git(argv, top, ADD_TIMEOUT_MS);
|
|
223
|
+
if (res.result === 'ok') return;
|
|
224
|
+
const entry = await registeredAt(treePath, top);
|
|
225
|
+
if (entry?.prunable) await git(['git', 'worktree', 'prune'], top);
|
|
226
|
+
const tree = entry !== null && !entry.prunable ? treePath : null;
|
|
227
|
+
const branch = newBranch !== null && (await branchExists(newBranch, top)) ? newBranch : null;
|
|
228
|
+
const left = await discard(top, tree, branch);
|
|
229
|
+
const outcome = left.length === 0 ? 'nothing was recorded' : `nothing was recorded, but ${leftBehind(left)}`;
|
|
230
|
+
throw new StateError('worktree-failed', `block ${id}: git worktree add failed (${gitReason(res)}); ${outcome}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* `p` with its nearest existing ancestor realpath'd (the rest joined on as written), so a path
|
|
235
|
+
* planned before it exists is spelled as its realpath will be once it does.
|
|
236
|
+
* @param {string} p - absolute. @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
function realAsFarAsExists(p) {
|
|
239
|
+
/** @type {string[]} */
|
|
240
|
+
const rest = [];
|
|
241
|
+
let head = p;
|
|
242
|
+
while (!occupied(head) && path.dirname(head) !== head) {
|
|
243
|
+
rest.unshift(path.basename(head));
|
|
244
|
+
head = path.dirname(head);
|
|
245
|
+
}
|
|
246
|
+
return path.join(real(head), ...rest);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @param {string} ref @param {string} top @param {string} id @returns {Promise<string>} the full commit sha
|
|
251
|
+
* @throws {StateError} `worktree-failed`
|
|
252
|
+
*/
|
|
253
|
+
async function commitOf(ref, top, id) {
|
|
254
|
+
const res = ref.startsWith('-') ? null : await git(['git', 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], top);
|
|
255
|
+
if (res === null || res.result !== 'ok') throw new StateError('worktree-failed', `block ${id}: proof.worktree.base ${JSON.stringify(ref)} is not a commit of the repository; nothing was recorded`);
|
|
256
|
+
return res.stdout.trim();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Plan the block's worktree, then make it (or reuse the one an earlier attempt of the same block
|
|
261
|
+
* made) with `make()`. Both run in `block open` under the run lock BEFORE anything is recorded: the
|
|
262
|
+
* plan first (read-only checks; its `path` feeds the overlap rule), `make()` after the open's
|
|
263
|
+
* other checks. A refusal in either leaves no block, no row, no dispatch, no tree and no branch.
|
|
264
|
+
*
|
|
265
|
+
* Fresh (no `previous`): the branch is `proof.worktree.branch` for this run and block; the tree is
|
|
266
|
+
* `<dir>/<block>` (`dir` resolved from the repository top level); the base is
|
|
267
|
+
* `proof.worktree.base`, else `baseSha`. A block id that is not one path segment, a branch name git
|
|
268
|
+
* refuses, a branch that already exists, something already at the path, a base that is not a
|
|
269
|
+
* commit, or a failing `git worktree add <path> -b <branch> <base>` refuses the open
|
|
270
|
+
* (`worktree-failed`); a failed add deletes the branch it made.
|
|
271
|
+
*
|
|
272
|
+
* Re-open (`previous`, the worktree an earlier entry of the same block recorded): the same path,
|
|
273
|
+
* branch and base. A worktree still registered at that path, on that branch, not `prunable`, with
|
|
274
|
+
* its directory present, is reused as it is (`created` false); one on another branch or a detached
|
|
275
|
+
* HEAD refuses the open. Otherwise a stale registration (the
|
|
276
|
+
* directory deleted by hand, or removed by `block close --remove-worktree`) is pruned
|
|
277
|
+
* (`git worktree prune`) and the tree is made again on the same branch
|
|
278
|
+
* (`git worktree add <path> <branch>`, or `-b <branch> <its base>` when the branch was deleted
|
|
279
|
+
* too); anything else at the path refuses the open.
|
|
280
|
+
*
|
|
281
|
+
* When `created`, `rollback()` undoes `make()` for an open that fails to record afterwards:
|
|
282
|
+
* `git worktree remove --force <path>`, and `git branch -D <branch>` only when this call made the
|
|
283
|
+
* branch (a branch kept from an earlier attempt is kept). Both results are checked: it resolves to
|
|
284
|
+
* a sentence naming what is left behind, or null.
|
|
285
|
+
* @param {{cwd: string, settings: WorktreeSettings, runId: string, id: string, baseSha: string, previous: WorktreeRecord | null}} opts
|
|
286
|
+
* @returns {Promise<PlannedWorktree>} the tree's `path` is absolute (a realpath once made).
|
|
287
|
+
* @throws {StateError} `worktree-failed`, `git-failed`
|
|
288
|
+
*/
|
|
289
|
+
export async function prepareWorktree({ cwd, settings, runId, id, baseSha, previous }) {
|
|
290
|
+
assertSegment(id);
|
|
291
|
+
const top = await repoTopOf(cwd);
|
|
292
|
+
const refuseOccupied = (/** @type {string} */ p) => {
|
|
293
|
+
if (occupied(p)) throw new StateError('worktree-failed', `block ${id}: ${p} already exists and is not this block's worktree — move it away or change proof.worktree.dir; nothing was recorded`);
|
|
294
|
+
};
|
|
295
|
+
/** @param {WorktreeRecord} want @param {string | null} newBranch @returns {MadeWorktree} */
|
|
296
|
+
const made = (want, newBranch) => {
|
|
297
|
+
const worktree = { ...want, path: realpathSync(want.path) };
|
|
298
|
+
const rollback = async () => {
|
|
299
|
+
const left = await discard(top, worktree.path, newBranch);
|
|
300
|
+
return left.length === 0 ? null : `Removing the worktree of the failed open ${leftBehind(left)}.`;
|
|
301
|
+
};
|
|
302
|
+
return { worktree, created: true, rollback };
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
if (previous) {
|
|
306
|
+
const want = { ...previous };
|
|
307
|
+
const make = async () => {
|
|
308
|
+
const entry = await registeredAt(want.path, top);
|
|
309
|
+
if (entry !== null && !entry.prunable && occupied(want.path)) {
|
|
310
|
+
if (entry.branch === want.branch) return { worktree: want, created: false, rollback: null };
|
|
311
|
+
throw new StateError('worktree-failed', `block ${id}: worktree ${want.path} is on ${entry.branch ?? 'a detached HEAD'}, not ${want.branch} — check out ${want.branch} in it; nothing was recorded`);
|
|
312
|
+
}
|
|
313
|
+
if (entry !== null) await git(['git', 'worktree', 'prune'], top);
|
|
314
|
+
refuseOccupied(want.path);
|
|
315
|
+
const attach = await branchExists(want.branch, top);
|
|
316
|
+
const newBranch = attach ? null : want.branch;
|
|
317
|
+
await addTree(attach ? ['git', 'worktree', 'add', want.path, want.branch] : ['git', 'worktree', 'add', want.path, '-b', want.branch, want.base_sha], want.path, top, id, newBranch);
|
|
318
|
+
return made(want, newBranch);
|
|
319
|
+
};
|
|
320
|
+
return { path: want.path, make };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const branch = branchName(settings.branch, { run: runId, block: id });
|
|
324
|
+
await assertBranchName(branch, top, id);
|
|
325
|
+
const refuseBranch = async () => {
|
|
326
|
+
if (await branchExists(branch, top)) throw new StateError('worktree-failed', `block ${id}: branch ${branch} already exists — delete it or change proof.worktree.branch; nothing was recorded`);
|
|
327
|
+
};
|
|
328
|
+
await refuseBranch();
|
|
329
|
+
// spelled as the realpath it will have, so the planned path (the overlap rule) equals the recorded one
|
|
330
|
+
const dir = realAsFarAsExists(path.resolve(top, settings.dir ?? path.join('..', `${path.basename(top)}-worktrees`)));
|
|
331
|
+
const want = { path: path.join(dir, id), branch, base_sha: settings.base === null ? baseSha : await commitOf(settings.base, top, id) };
|
|
332
|
+
refuseOccupied(want.path);
|
|
333
|
+
const make = async () => {
|
|
334
|
+
// checked again: a branch made between the plan and now is someone else's, so the add must not
|
|
335
|
+
// run (its failure would delete that branch)
|
|
336
|
+
await refuseBranch();
|
|
337
|
+
await addTree(['git', 'worktree', 'add', want.path, '-b', want.branch, want.base_sha], want.path, top, id, want.branch);
|
|
338
|
+
return made(want, want.branch);
|
|
339
|
+
};
|
|
340
|
+
return { path: want.path, make };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* `git worktree remove <path>` — never `--force`, so a tree with modified or untracked files is
|
|
345
|
+
* kept. The branch is always kept. A tree whose directory is already gone (git marks it
|
|
346
|
+
* `prunable`) is pruned (`git worktree prune`) and counts as removed.
|
|
347
|
+
* @param {{cwd: string, worktree: WorktreeRecord}} opts
|
|
348
|
+
* @returns {Promise<string | null>} null when the tree was removed, else git's reason.
|
|
349
|
+
*/
|
|
350
|
+
export async function removeWorktree({ cwd, worktree }) {
|
|
351
|
+
const res = await git(['git', 'worktree', 'remove', worktree.path], cwd);
|
|
352
|
+
if (res.result === 'ok') return null;
|
|
353
|
+
/** @type {{branch: string | null, prunable: boolean} | null} */
|
|
354
|
+
let entry;
|
|
355
|
+
try {
|
|
356
|
+
entry = await registeredAt(worktree.path, cwd);
|
|
357
|
+
} catch {
|
|
358
|
+
return gitReason(res);
|
|
359
|
+
}
|
|
360
|
+
if (!entry?.prunable) return gitReason(res);
|
|
361
|
+
const pruned = await git(['git', 'worktree', 'prune'], cwd);
|
|
362
|
+
return pruned.result === 'ok' ? null : gitReason(pruned);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* @param {string} cwd @returns {Promise<string>} the repository top level, realpath'd.
|
|
367
|
+
* @throws {StateError} `git-failed`
|
|
368
|
+
*/
|
|
369
|
+
async function repoTopOf(cwd) {
|
|
370
|
+
const res = await git(['git', 'rev-parse', '--show-toplevel'], cwd);
|
|
371
|
+
if (res.result !== 'ok') throw new StateError('git-failed', `git rev-parse --show-toplevel failed (${gitReason(res)})`);
|
|
372
|
+
return realpathSync(res.stdout.trim());
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* @typedef {{top: string, workspace: string, path: string, branch: string, base_sha: string}} BlockTree
|
|
377
|
+
* `top`: the tree's top level, realpath'd; `workspace`: the run's workspace at its place in the
|
|
378
|
+
* tree (the tree itself, or the same subdirectory of it when the workspace is a subdirectory of
|
|
379
|
+
* the main tree); `path`, `branch`, `base_sha`: as the block entry records them.
|
|
380
|
+
*/
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* B62b: where the block `entry` of the run whose workspace is `workspace` does its work — null for
|
|
384
|
+
* a block without a `worktree` record (export and lock runs, and blocks opened before B62a).
|
|
385
|
+
* @param {string} workspace - the run record's `workspace` (in the main tree).
|
|
386
|
+
* @param {Record<string, any> | undefined} entry - the run record's block entry.
|
|
387
|
+
* The messages name the tree but not the block or what was skipped: each caller adds those.
|
|
388
|
+
* @returns {Promise<BlockTree | null>} null exactly when the entry has no `worktree` record.
|
|
389
|
+
* @throws {StateError} `worktree-malformed` ({@link worktreeRecord}); `worktree-missing` when the
|
|
390
|
+
* recorded tree is gone; `worktree-not-dir` when its path is not a directory;
|
|
391
|
+
* `worktree-branch-mismatch` when the tree is not on its recorded branch (another branch, a
|
|
392
|
+
* detached HEAD, or a plain directory that is no git worktree); `worktree-workspace-missing`
|
|
393
|
+
* when the workspace's subdirectory is not in the tree; `worktree-foreign` when the tree belongs
|
|
394
|
+
* to another repository (its git common dir is not the workspace's); `git-failed` when git
|
|
395
|
+
* cannot run (a timeout, a spawn error) or the main top level cannot be read.
|
|
396
|
+
*/
|
|
397
|
+
export async function blockTree(workspace, entry) {
|
|
398
|
+
if (entry?.worktree === undefined || entry.worktree === null) return null;
|
|
399
|
+
const tree = worktreeRecord(entry.worktree);
|
|
400
|
+
let top;
|
|
401
|
+
try {
|
|
402
|
+
top = realpathSync(tree.path);
|
|
403
|
+
} catch {
|
|
404
|
+
throw new StateError('worktree-missing', `the worktree ${tree.path} is gone (removed or moved)`);
|
|
405
|
+
}
|
|
406
|
+
if (!statSync(top).isDirectory()) throw new StateError('worktree-not-dir', `the worktree ${tree.path} is not a directory`);
|
|
407
|
+
await assertOnBranch(top, tree);
|
|
408
|
+
if ((await commonDirOf(top)) !== (await commonDirOf(workspace))) throw new StateError('worktree-foreign', `the worktree ${tree.path} belongs to another repository`);
|
|
409
|
+
const rel = path.relative(await repoTopOf(workspace), real(workspace));
|
|
410
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) throw new StateError('worktree-workspace-missing', `the run's workspace ${workspace} is not inside its repository, so it has no place in the worktree ${tree.path}`);
|
|
411
|
+
const inTree = rel === '' ? top : path.join(top, rel);
|
|
412
|
+
if (rel !== '' && !isDirectory(inTree)) throw new StateError('worktree-workspace-missing', `the worktree ${tree.path} has no ${rel} directory (the run's workspace)`);
|
|
413
|
+
return { top, workspace: inTree, path: tree.path, branch: tree.branch, base_sha: tree.base_sha };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** A full hex object id: SHA-1 (40 hex digits) or SHA-256 (64). */
|
|
417
|
+
const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
418
|
+
/** A control character (newlines included). */
|
|
419
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* B62b: a block's recorded `worktree`, checked before any of it reaches a path or git: an absolute
|
|
423
|
+
* `path` without control characters, a non-empty `branch` without control characters, and a `base_sha` that is a full hex
|
|
424
|
+
* object id (so it can never be read as a git option).
|
|
425
|
+
* @param {unknown} recorded
|
|
426
|
+
* @returns {{path: string, branch: string, base_sha: string}}
|
|
427
|
+
* @throws {StateError} `worktree-malformed`
|
|
428
|
+
*/
|
|
429
|
+
export function worktreeRecord(recorded) {
|
|
430
|
+
const t = /** @type {Record<string, unknown>} */ (recorded);
|
|
431
|
+
const ok =
|
|
432
|
+
t !== null &&
|
|
433
|
+
typeof t === 'object' &&
|
|
434
|
+
typeof t.path === 'string' &&
|
|
435
|
+
path.isAbsolute(t.path) &&
|
|
436
|
+
!CONTROL.test(t.path) &&
|
|
437
|
+
typeof t.branch === 'string' &&
|
|
438
|
+
t.branch.length > 0 &&
|
|
439
|
+
!CONTROL.test(t.branch) &&
|
|
440
|
+
typeof t.base_sha === 'string' &&
|
|
441
|
+
OBJECT_ID.test(t.base_sha);
|
|
442
|
+
if (!ok) throw new StateError('worktree-malformed', 'malformed worktree record (it needs an absolute path, a branch and a base_sha)');
|
|
443
|
+
return { path: /** @type {string} */ (t.path), branch: /** @type {string} */ (t.branch), base_sha: /** @type {string} */ (t.base_sha) };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The tree at `top` must still be on its recorded branch: a read-only `git symbolic-ref -q HEAD`
|
|
448
|
+
* that does not look above `top`, so a plain directory is never taken for a tree.
|
|
449
|
+
* @param {string} top - realpath'd @param {{path: string, branch: string}} tree
|
|
450
|
+
* @throws {StateError} `worktree-branch-mismatch`; `git-failed` when git cannot run (a timeout, a
|
|
451
|
+
* spawn error, a signal) — that is never read as a branch mismatch.
|
|
452
|
+
*/
|
|
453
|
+
async function assertOnBranch(top, tree) {
|
|
454
|
+
const res = await exec(['git', '-C', top, 'symbolic-ref', '-q', 'HEAD'], { cwd: top, env: { ...gitEnv(), GIT_CEILING_DIRECTORIES: path.dirname(top) }, timeoutMs: GIT_TIMEOUT_MS });
|
|
455
|
+
if (res.timedOut || res.error !== undefined || res.signal !== null) {
|
|
456
|
+
throw new StateError('git-failed', `git symbolic-ref HEAD could not run in the worktree ${tree.path} (${res.timedOut ? 'timed out' : (res.error ?? `killed by ${res.signal}`)})`);
|
|
457
|
+
}
|
|
458
|
+
const head = res.result === 'ok' ? res.stdout.trim() : null;
|
|
459
|
+
if (head === `refs/heads/${tree.branch}`) return;
|
|
460
|
+
const now = head === null ? 'its HEAD is detached, or it is not a git worktree' : `it is on ${head.startsWith('refs/heads/') ? head.slice('refs/heads/'.length) : head}`;
|
|
461
|
+
throw new StateError('worktree-branch-mismatch', `the worktree ${tree.path} is not on its branch ${tree.branch} (${now})`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* @param {string} cwd @returns {Promise<string>} the realpath of the git common dir of the
|
|
466
|
+
* repository at `cwd` (the main `.git`, which all of its worktrees share).
|
|
467
|
+
* @throws {StateError} `git-failed`
|
|
468
|
+
*/
|
|
469
|
+
async function commonDirOf(cwd) {
|
|
470
|
+
const res = await git(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
|
|
471
|
+
if (res.result !== 'ok') throw new StateError('git-failed', `git rev-parse --git-common-dir failed in ${cwd} (${gitReason(res)})`);
|
|
472
|
+
return real(res.stdout.trim());
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** @param {string} p @returns {boolean} whether `p` is a directory (following symlinks). */
|
|
476
|
+
function isDirectory(p) {
|
|
477
|
+
try {
|
|
478
|
+
return statSync(p).isDirectory();
|
|
479
|
+
} catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
}
|