@rungs/cli 0.2.0 → 0.3.1
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 +28 -15
- package/dist/cli.js +411 -46
- package/dist/cli.js.map +4 -4
- package/modules/ci/files/{{workflow_path}} +9 -1
- package/modules/ci/module.toml +1 -1
- package/modules/concurrency/files/docs/concurrent-sessions.md +11 -5
- package/modules/concurrency/module.toml +1 -1
- package/modules/release/gates/release.toml +29 -4
- package/modules/release/module.toml +1 -1
- package/modules/release/skills/cut-release/SKILL.md +4 -4
- package/package.json +1 -1
- package/src/backlog.ts +17 -2
- package/src/check.ts +9 -2
- package/src/cli.ts +109 -0
- package/src/concurrency.ts +412 -0
- package/src/engines.ts +22 -3
- package/src/engines2.ts +42 -11
- package/src/engines3.ts +4 -3
- package/src/lifecycle.ts +9 -4
- package/src/selftest.ts +16 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The concurrency loop: `session start`, `preflight`, `land`, `worktrees`.
|
|
3
|
+
*
|
|
4
|
+
* These are the four commands the `concurrency` module documented for weeks
|
|
5
|
+
* without any of them existing (F-026). The module is the specification —
|
|
6
|
+
* `modules/concurrency/files/docs/concurrent-sessions.md` — and the rules they
|
|
7
|
+
* obey are [ADR-0009](../docs/decisions/ADR-0009-rungs-drives-git.md):
|
|
8
|
+
*
|
|
9
|
+
* 1. Verify before you advance. `land` merges onto a scratch ref, gates *that*
|
|
10
|
+
* tree, and only then moves the branch, with a compare-and-swap.
|
|
11
|
+
* 2. Never destroy, only refuse. Nothing here deletes a branch, a worktree or
|
|
12
|
+
* a commit; a refusal parks its work rather than discarding it.
|
|
13
|
+
* 3. Never hold the integration branch. Everything runs from a throwaway
|
|
14
|
+
* worktree, which the module already gates for.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { hostname } from 'node:os';
|
|
19
|
+
import { join, resolve, dirname, basename } from 'node:path';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { installedParams } from './check.ts';
|
|
22
|
+
|
|
23
|
+
export interface LoopParams {
|
|
24
|
+
integration: string;
|
|
25
|
+
greenRef: string;
|
|
26
|
+
integPrefix: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loopParams(root: string): LoopParams {
|
|
30
|
+
const p = (installedParams(root).concurrency ?? {}) as Record<string, unknown>;
|
|
31
|
+
const integration = String(p.integration_branch ?? 'main');
|
|
32
|
+
const greenPrefix = String(p.green_prefix ?? 'green/');
|
|
33
|
+
return {
|
|
34
|
+
integration,
|
|
35
|
+
// The green ref marks the last *verified* merge of the integration branch,
|
|
36
|
+
// so it is prefix + that branch — not prefix + whatever you are cutting.
|
|
37
|
+
greenRef: `${greenPrefix}${integration}`,
|
|
38
|
+
integPrefix: String(p.integ_prefix ?? 'integ/'),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `git`, never through a shell: branch names are user input and contain slashes. */
|
|
43
|
+
export function git(root: string, args: string[]): string {
|
|
44
|
+
return execFileSync('git', args, { cwd: root, stdio: 'pipe', encoding: 'utf8' }).trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function gitOk(root: string, args: string[]): boolean {
|
|
48
|
+
try {
|
|
49
|
+
git(root, args);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function revParse(root: string, ref: string): string | null {
|
|
57
|
+
try {
|
|
58
|
+
return git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface Result {
|
|
65
|
+
ok: boolean;
|
|
66
|
+
lines: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── session start ─────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Cut a branch and a worktree from the last **verified** merge.
|
|
73
|
+
*
|
|
74
|
+
* Falling back to the tip is allowed and is always **stated**. A silent fallback
|
|
75
|
+
* would put the session on top of an unverified merge, which is the one thing
|
|
76
|
+
* the green ref exists to prevent — and the operator would never know.
|
|
77
|
+
*/
|
|
78
|
+
export function sessionStart(root: string, branch: string, at?: string, dryRun = false): Result {
|
|
79
|
+
const { integration, greenRef } = loopParams(root);
|
|
80
|
+
const lines: string[] = [];
|
|
81
|
+
|
|
82
|
+
if (!branch) return { ok: false, lines: ['a branch name is required: `rungs session start <branch> [path]`'] };
|
|
83
|
+
if (revParse(root, `refs/heads/${branch}`)) {
|
|
84
|
+
return { ok: false, lines: [`branch '${branch}' already exists — pick another name, or check out the worktree that holds it`] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const green = revParse(root, `refs/heads/${greenRef}`);
|
|
88
|
+
const base = green ? greenRef : integration;
|
|
89
|
+
const baseSha = green ?? revParse(root, integration);
|
|
90
|
+
if (!baseSha) return { ok: false, lines: [`neither '${greenRef}' nor '${integration}' resolves — is this the right repo?`] };
|
|
91
|
+
|
|
92
|
+
if (green) {
|
|
93
|
+
lines.push(`base ${greenRef} (${baseSha.slice(0, 8)}) — the last verified merge`);
|
|
94
|
+
} else {
|
|
95
|
+
// Stated, never silent. See the doc comment above.
|
|
96
|
+
lines.push(`no ${greenRef} ref yet — cutting from the tip of ${integration} (${baseSha.slice(0, 8)}) instead.`);
|
|
97
|
+
lines.push(`That tip has not been verified by a land. The first successful \`rungs land\` creates ${greenRef}.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const path = resolve(at ?? join(dirname(root), `${basename(root)}-${branch.replace(/[^\w.-]+/g, '-')}`));
|
|
101
|
+
if (existsSync(path)) return { ok: false, lines: [`${path} already exists — rungs never writes over a directory it did not create`] };
|
|
102
|
+
|
|
103
|
+
lines.push(`worktree ${path}`);
|
|
104
|
+
lines.push(`branch ${branch}`);
|
|
105
|
+
if (dryRun) return { ok: true, lines };
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
git(root, ['worktree', 'add', '-b', branch, path, baseSha]);
|
|
109
|
+
} catch (e: any) {
|
|
110
|
+
return { ok: false, lines: [...lines, `git refused: ${String(e.stderr ?? e.message).trim().split('\n').slice(-2).join(' ')}`] };
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, lines };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── preflight ─────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Did the integration branch change files *you* changed?
|
|
119
|
+
*
|
|
120
|
+
* The commit count is the number everyone looks at and it predicts nothing: a
|
|
121
|
+
* hundred commits nowhere near your files are irrelevant, and one commit in the
|
|
122
|
+
* file you are rewriting is the whole story.
|
|
123
|
+
*/
|
|
124
|
+
export function preflight(root: string): Result {
|
|
125
|
+
const { integration } = loopParams(root);
|
|
126
|
+
if (!revParse(root, integration)) return { ok: false, lines: [`'${integration}' does not resolve — is this the right repo?`] };
|
|
127
|
+
|
|
128
|
+
let base: string;
|
|
129
|
+
try {
|
|
130
|
+
base = git(root, ['merge-base', 'HEAD', integration]);
|
|
131
|
+
} catch {
|
|
132
|
+
return { ok: false, lines: [`no merge base between HEAD and ${integration}; nothing to compare`] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const names = (args: string[]) => new Set(git(root, args).split('\n').map((s) => s.trim()).filter(Boolean));
|
|
136
|
+
const theirs = names(['diff', '--name-only', base, integration]);
|
|
137
|
+
// Committed *and* uncommitted: work you have not committed still collides.
|
|
138
|
+
const mine = new Set([
|
|
139
|
+
...names(['diff', '--name-only', base, 'HEAD']),
|
|
140
|
+
...names(['diff', '--name-only', 'HEAD']),
|
|
141
|
+
...names(['diff', '--name-only', '--cached']),
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const ahead = Number(git(root, ['rev-list', '--count', `${base}..${integration}`]));
|
|
145
|
+
const overlap = [...mine].filter((f) => theirs.has(f)).sort();
|
|
146
|
+
|
|
147
|
+
const lines = [
|
|
148
|
+
`${integration} is ${ahead} commit(s) ahead of your base, touching ${theirs.size} file(s).`,
|
|
149
|
+
`You have touched ${mine.size} file(s).`,
|
|
150
|
+
];
|
|
151
|
+
if (!overlap.length) {
|
|
152
|
+
lines.push('No overlap. The commit count is not the signal — these two sets not intersecting is.');
|
|
153
|
+
return { ok: true, lines };
|
|
154
|
+
}
|
|
155
|
+
lines.push(`${overlap.length} file(s) changed on both sides:`);
|
|
156
|
+
for (const f of overlap.slice(0, 20)) lines.push(` ${f}`);
|
|
157
|
+
if (overlap.length > 20) lines.push(` …and ${overlap.length - 20} more`);
|
|
158
|
+
lines.push('Merge sooner rather than later. Shared code is a scheduling problem, not a tooling one.');
|
|
159
|
+
return { ok: true, lines };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── land ──────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
interface Lock {
|
|
165
|
+
pid: number;
|
|
166
|
+
host: string;
|
|
167
|
+
started: string;
|
|
168
|
+
branch: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function lockPath(root: string): string {
|
|
172
|
+
return join(git(root, ['rev-parse', '--git-common-dir']).replace(/^\.git$/, join(root, '.git')), 'rungs-land.lock');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function alive(pid: number): boolean {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, 0);
|
|
178
|
+
return true;
|
|
179
|
+
} catch (e: any) {
|
|
180
|
+
return e?.code === 'EPERM';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Merge → verify the merged tree → advance with a compare-and-swap.
|
|
186
|
+
*
|
|
187
|
+
* The order is the guarantee. Merging into the branch and testing afterwards has
|
|
188
|
+
* already moved the branch, so a red result is something you now have to undo;
|
|
189
|
+
* here a refusal leaves the integration branch bit-for-bit unchanged and parks
|
|
190
|
+
* the merged tree on a scratch ref for you to fix.
|
|
191
|
+
*/
|
|
192
|
+
export interface GateOutcome {
|
|
193
|
+
pass: number;
|
|
194
|
+
/**
|
|
195
|
+
* Findings per failing gate, not just the gate id.
|
|
196
|
+
*
|
|
197
|
+
* Attributing by **gate** was the first implementation and it was wrong in a
|
|
198
|
+
* way that mattered: `gates-links-resolve` red at the base made that gate a
|
|
199
|
+
* blind spot, so a branch could add its own broken links and land them as
|
|
200
|
+
* "inherited". Measured — a branch adding `./also-missing.md` on top of an
|
|
201
|
+
* already-red link gate landed clean. Attribution is per finding.
|
|
202
|
+
*/
|
|
203
|
+
failing: { id: string; findings: string[] }[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type LandRunner = (dir: string, only?: ReadonlySet<string>) => GateOutcome;
|
|
207
|
+
|
|
208
|
+
export function land(root: string, branch: string, runner: LandRunner, dryRun = false): Result {
|
|
209
|
+
const { integration, greenRef, integPrefix } = loopParams(root);
|
|
210
|
+
const lines: string[] = [];
|
|
211
|
+
|
|
212
|
+
if (!branch) return { ok: false, lines: ['a branch name is required: `rungs land <branch>`'] };
|
|
213
|
+
const head = revParse(root, `refs/heads/${branch}`);
|
|
214
|
+
if (!head) return { ok: false, lines: [`branch '${branch}' does not exist`] };
|
|
215
|
+
const before = revParse(root, `refs/heads/${integration}`);
|
|
216
|
+
if (!before) return { ok: false, lines: [`'${integration}' does not resolve`] };
|
|
217
|
+
|
|
218
|
+
// A real lock: it names its holder and start time, and is taken over if that
|
|
219
|
+
// holder is gone. A lock nobody can break is a lock somebody deletes.
|
|
220
|
+
const lp = lockPath(root);
|
|
221
|
+
if (existsSync(lp)) {
|
|
222
|
+
try {
|
|
223
|
+
const held = JSON.parse(readFileSync(lp, 'utf8')) as Lock;
|
|
224
|
+
if (held.host === hostname() && alive(held.pid)) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
lines: [`another land is in progress: pid ${held.pid} on ${held.host}, landing '${held.branch}' since ${held.started}.`,
|
|
228
|
+
'Concurrent landing is refused, not silently merged.'],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
lines.push(`taking over a stale lock from pid ${held.pid} (${held.started}) — that process is gone.`);
|
|
232
|
+
} catch {
|
|
233
|
+
lines.push('an unreadable lock file was replaced.');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (dryRun) {
|
|
237
|
+
lines.push(`would merge ${branch} (${head.slice(0, 8)}) onto ${integration} (${before.slice(0, 8)}) via ${integPrefix}${branch}, verify, then advance.`);
|
|
238
|
+
return { ok: true, lines };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const lock: Lock = { pid: process.pid, host: hostname(), started: new Date().toISOString(), branch };
|
|
242
|
+
writeFileSync(lp, JSON.stringify(lock));
|
|
243
|
+
const scratch = mkdtempSync(join(tmpdir(), 'rungs-land-'));
|
|
244
|
+
const parked = `${integPrefix}${branch}`;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
// Rule 3: a throwaway worktree, detached. The integration branch is never
|
|
248
|
+
// checked out — holding it blocks every other session and does not prevent
|
|
249
|
+
// concurrent landing anyway.
|
|
250
|
+
git(root, ['worktree', 'add', '--detach', scratch, before]);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
git(scratch, ['-c', 'user.email=rungs@localhost', '-c', 'user.name=rungs', 'merge', '--no-ff', '-m', `land ${branch}`, head]);
|
|
254
|
+
} catch (e: any) {
|
|
255
|
+
const conflicts = (() => {
|
|
256
|
+
try {
|
|
257
|
+
return git(scratch, ['diff', '--name-only', '--diff-filter=U']).split('\n').filter(Boolean);
|
|
258
|
+
} catch {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
lines.push(`merge conflict — ${integration} is unchanged.`);
|
|
263
|
+
for (const f of conflicts.slice(0, 15)) lines.push(` ${f}`);
|
|
264
|
+
lines.push('Reconcile generated artifacts by regenerating, never by merging text.');
|
|
265
|
+
return { ok: false, lines };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const merged = git(scratch, ['rev-parse', 'HEAD']);
|
|
269
|
+
const res = runner(scratch);
|
|
270
|
+
lines.push(`merged tree ${merged.slice(0, 8)} — ${res.pass} pass · ${res.failing.length} fail`);
|
|
271
|
+
|
|
272
|
+
if (res.failing.length) {
|
|
273
|
+
// **Attribution.** A gate that is red for reasons you did not cause and
|
|
274
|
+
// cannot fix is a gate you learn to bypass, and a bypassed gate reports
|
|
275
|
+
// nothing. So each failure is re-run against the merge base — the same
|
|
276
|
+
// scratch worktree, reset back — and only the ones *this branch* caused
|
|
277
|
+
// block the land.
|
|
278
|
+
//
|
|
279
|
+
// The trade this makes is real and the module states it: a survivable red
|
|
280
|
+
// gate also removes the pressure to fix it. What is supposed to catch that
|
|
281
|
+
// is the ledger's ageing signal, not this command.
|
|
282
|
+
const ids = new Set(res.failing.map((f) => f.id));
|
|
283
|
+
let base: GateOutcome | null = null;
|
|
284
|
+
try {
|
|
285
|
+
git(scratch, ['reset', '--hard', before]);
|
|
286
|
+
base = runner(scratch, ids);
|
|
287
|
+
} catch {
|
|
288
|
+
base = null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// A gate that did not run at the base cannot be attributed at all. We do
|
|
292
|
+
// not land on an unknown, so that blocks.
|
|
293
|
+
const attributable = base !== null && base.failing.length + base.pass >= ids.size;
|
|
294
|
+
const baseFindings = new Map((base?.failing ?? []).map((f) => [f.id, new Set(f.findings)]));
|
|
295
|
+
|
|
296
|
+
const introduced: { id: string; findings: string[] }[] = [];
|
|
297
|
+
const inherited: { id: string; findings: string[] }[] = [];
|
|
298
|
+
for (const f of res.failing) {
|
|
299
|
+
const seen = attributable ? baseFindings.get(f.id) ?? new Set<string>() : null;
|
|
300
|
+
// Per finding: a gate already red at the base does not excuse the new
|
|
301
|
+
// violations of it that this branch brought.
|
|
302
|
+
const fresh = seen ? f.findings.filter((x) => !seen.has(x)) : f.findings;
|
|
303
|
+
if (fresh.length) introduced.push({ id: f.id, findings: fresh });
|
|
304
|
+
else inherited.push(f);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const f of inherited) {
|
|
308
|
+
lines.push(` inherited ${f.id}${f.findings[0] ? ` — ${f.findings[0]}` : ''}`);
|
|
309
|
+
}
|
|
310
|
+
for (const f of introduced) {
|
|
311
|
+
lines.push(` INTRODUCED ${f.id}${f.findings[0] ? ` — ${f.findings[0]}` : ''}`);
|
|
312
|
+
for (const extra of f.findings.slice(1, 4)) lines.push(` ${extra}`);
|
|
313
|
+
}
|
|
314
|
+
if (base === null) {
|
|
315
|
+
lines.push(' The merge base could not be gated, so nothing here is attributable and all of it blocks.');
|
|
316
|
+
} else if (!attributable) {
|
|
317
|
+
lines.push(' Some gates could not be attributed against the merge base, so they block. We do not land on an unknown.');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (introduced.length) {
|
|
321
|
+
// Rule 2: park it, do not discard it. The merge is the expensive part
|
|
322
|
+
// and throwing it away means doing it again to see the same failure.
|
|
323
|
+
git(root, ['update-ref', `refs/heads/${parked}`, merged]);
|
|
324
|
+
lines.push(
|
|
325
|
+
`${introduced.length} introduced by this branch. ${integration} is unchanged, and the merged tree is parked on '${parked}' — fix it there and land again.`,
|
|
326
|
+
);
|
|
327
|
+
return { ok: false, lines };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
lines.push(
|
|
331
|
+
`${inherited.length} failure(s), all already red on ${integration} before this branch. Landing anyway — they are not this branch's to fix, and blocking on them is how a gate gets bypassed.`,
|
|
332
|
+
);
|
|
333
|
+
// The scratch worktree is back at the base, so re-point it at the merged
|
|
334
|
+
// commit before the advance reads it.
|
|
335
|
+
git(scratch, ['reset', '--hard', merged]);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Rule 1: compare-and-swap. If someone else advanced the branch while we
|
|
339
|
+
// verified, this fails and nothing is lost — their merge is not overwritten.
|
|
340
|
+
try {
|
|
341
|
+
git(root, ['update-ref', `refs/heads/${integration}`, merged, before]);
|
|
342
|
+
} catch {
|
|
343
|
+
git(root, ['update-ref', `refs/heads/${parked}`, merged]);
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
lines: [...lines,
|
|
347
|
+
`${integration} moved while this land was verifying, so the advance was refused rather than overwriting it.`,
|
|
348
|
+
`Your verified merge is parked on '${parked}'. Re-run \`rungs land ${branch}\` to rebuild it on the new tip.`],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
git(root, ['update-ref', `refs/heads/${greenRef}`, merged]);
|
|
352
|
+
lines.push(`${integration} → ${merged.slice(0, 8)}, and ${greenRef} now marks it verified.`);
|
|
353
|
+
if (revParse(root, `refs/heads/${parked}`)) git(root, ['update-ref', '-d', `refs/heads/${parked}`]);
|
|
354
|
+
return { ok: true, lines };
|
|
355
|
+
} finally {
|
|
356
|
+
// The scratch worktree is ours and only ours, so removing it is not rule 2's
|
|
357
|
+
// "never destroy" — that is about the operator's branches and worktrees.
|
|
358
|
+
try {
|
|
359
|
+
git(root, ['worktree', 'remove', '--force', scratch]);
|
|
360
|
+
} catch {
|
|
361
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
362
|
+
try {
|
|
363
|
+
git(root, ['worktree', 'prune']);
|
|
364
|
+
} catch {
|
|
365
|
+
/* leaving a stale worktree record is not worth failing a successful land */
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
unlinkSync(lp);
|
|
370
|
+
} catch {
|
|
371
|
+
/* already gone */
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── worktrees ─────────────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
export interface WorktreeRow {
|
|
379
|
+
path: string;
|
|
380
|
+
branch: string;
|
|
381
|
+
merged: boolean;
|
|
382
|
+
dirty: boolean;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* What is finished and prunable — **reports only**.
|
|
387
|
+
*
|
|
388
|
+
* Removing someone else's worktree is not a script's call (ADR-0009 rule 2), and
|
|
389
|
+
* the interesting row is not the clean one. A worktree that is merged *and*
|
|
390
|
+
* dirty holds uncommitted work in a branch that has already landed, which is the
|
|
391
|
+
* shape work actually gets lost in.
|
|
392
|
+
*/
|
|
393
|
+
export function worktrees(root: string): { rows: WorktreeRow[]; integration: string } {
|
|
394
|
+
const { integration } = loopParams(root);
|
|
395
|
+
const out = git(root, ['worktree', 'list', '--porcelain']);
|
|
396
|
+
const rows: WorktreeRow[] = [];
|
|
397
|
+
|
|
398
|
+
for (const block of out.split('\n\n').filter((b) => b.trim())) {
|
|
399
|
+
const path = block.match(/^worktree (.+)$/m)?.[1];
|
|
400
|
+
const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1];
|
|
401
|
+
if (!path || !branch || branch === integration) continue;
|
|
402
|
+
const merged = gitOk(root, ['merge-base', '--is-ancestor', branch, integration]);
|
|
403
|
+
let dirty = false;
|
|
404
|
+
try {
|
|
405
|
+
dirty = git(path, ['status', '--porcelain']).length > 0;
|
|
406
|
+
} catch {
|
|
407
|
+
dirty = false;
|
|
408
|
+
}
|
|
409
|
+
rows.push({ path, branch, merged, dirty });
|
|
410
|
+
}
|
|
411
|
+
return { rows, integration };
|
|
412
|
+
}
|
package/src/engines.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { join, dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { matchAny, walk } from './glob.ts';
|
|
4
5
|
import { parse as parseToml } from 'smol-toml';
|
|
5
6
|
import { runSelfTests } from './selftest.ts';
|
|
6
7
|
import { loadAllModules } from './manifest.ts';
|
|
8
|
+
|
|
7
9
|
import { resolveParams, substitute } from './substitute.ts';
|
|
8
10
|
import {
|
|
9
11
|
computedClaim,
|
|
@@ -17,6 +19,23 @@ import {
|
|
|
17
19
|
} from './engines2.ts';
|
|
18
20
|
import { boardReconcile, changelogFreshness, gitState, mergeDriverCheck, rulePropagation, termOwnership } from './engines3.ts';
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Where the CLI's own `modules/` lives.
|
|
24
|
+
*
|
|
25
|
+
* This was `new URL(import.meta.url).pathname.slice(1)` in three places. The
|
|
26
|
+
* `.slice(1)` strips a leading `/`, which is right on Windows — `/C:/…` becomes
|
|
27
|
+
* `C:/…` — and **wrong everywhere else**, where `/home/runner/…` becomes the
|
|
28
|
+
* relative `home/runner/…`. On Linux and macOS the directory did not resolve,
|
|
29
|
+
* `loadAllModules` found nothing, and three gates silently lost the data they
|
|
30
|
+
* read from the module set: `skills-spec-pure` and `skills-description-routes`
|
|
31
|
+
* reported every opted-in extension as a non-spec key, and
|
|
32
|
+
* `gates-self-tests-both-directions` reported gates that have fixtures as
|
|
33
|
+
* having none. All three passed here and failed on the first Linux run (F-036).
|
|
34
|
+
*
|
|
35
|
+
* `fileURLToPath` is what the rest of the codebase already used.
|
|
36
|
+
*/
|
|
37
|
+
const CLI_MODULES = join(dirname(fileURLToPath(import.meta.url)), '..', 'modules');
|
|
38
|
+
|
|
20
39
|
export interface Finding {
|
|
21
40
|
file?: string;
|
|
22
41
|
message: string;
|
|
@@ -362,7 +381,7 @@ export const gateMeta: Engine = (_t, root) => {
|
|
|
362
381
|
if (!id || kind !== 'declared' || !table) continue;
|
|
363
382
|
examined++;
|
|
364
383
|
// Tables live in the CLI, not the repo, so read them from the module set.
|
|
365
|
-
const tablePath = join(
|
|
384
|
+
const tablePath = join(CLI_MODULES, dirname(table), 'gates', table.split('/').pop()!);
|
|
366
385
|
const src = existsSync(tablePath) ? readFileSync(tablePath, 'utf8') : '';
|
|
367
386
|
const forGate = [...src.matchAll(/\[\[self_test\]\][\s\S]*?(?=\n\[\[|\n\[|$)/g)]
|
|
368
387
|
.map((m) => m[0])
|
|
@@ -426,7 +445,7 @@ function optedInExtensions(rel: string, spec: any): Set<string> {
|
|
|
426
445
|
const name = rel.split('/').slice(-2)[0];
|
|
427
446
|
if (!name) return new Set();
|
|
428
447
|
try {
|
|
429
|
-
const mods = loadAllModules(
|
|
448
|
+
const mods = loadAllModules(CLI_MODULES);
|
|
430
449
|
const owner = mods.find((m) => m.skills?.[name]?.extensions);
|
|
431
450
|
return new Set(Object.keys(owner?.skills?.[name]?.extensions ?? {}));
|
|
432
451
|
} catch {
|
|
@@ -450,7 +469,7 @@ function parseTable(path: string, module: string): any | null {
|
|
|
450
469
|
// and the runner reported the gate broken — a mismatch entirely of the
|
|
451
470
|
// harness's making. A fixture and the table it tests must resolve against
|
|
452
471
|
// the same parameters or neither means anything.
|
|
453
|
-
const mods = loadAllModules(
|
|
472
|
+
const mods = loadAllModules(CLI_MODULES);
|
|
454
473
|
const params = resolveParams(mods, {}, '.');
|
|
455
474
|
return parseToml(substitute(readFileSync(path, 'utf8'), module, params));
|
|
456
475
|
} catch {
|
package/src/engines2.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { matchAny } from './glob.ts';
|
|
5
5
|
import type { Engine, Finding } from './engines.ts';
|
|
@@ -344,12 +344,28 @@ export const crossReference: Engine = (t, root, files) => {
|
|
|
344
344
|
* this repo does not use — it deletes branches on merge — and it is the
|
|
345
345
|
* direction to be wrong in, because the alternative is the daily false positive.
|
|
346
346
|
*/
|
|
347
|
+
/**
|
|
348
|
+
* `git` as an argv array, never a shell string.
|
|
349
|
+
*
|
|
350
|
+
* `--format=%(refname:short)` is a **bash syntax error** — unquoted parentheses —
|
|
351
|
+
* so `backlog-merged-status` threw on every Linux and macOS repo, hit its catch,
|
|
352
|
+
* and reported "cannot read git branches; status not reconciled" as a finding.
|
|
353
|
+
* The gate ships in four of five profiles and had never once worked off Windows,
|
|
354
|
+
* where `execSync` goes through cmd.exe and parentheses are ordinary characters.
|
|
355
|
+
* Found by the CI matrix on its first run (F-033).
|
|
356
|
+
*
|
|
357
|
+
* Branch names come out of work-item frontmatter, so this is also the difference
|
|
358
|
+
* between reading a field and passing it to a shell.
|
|
359
|
+
*/
|
|
360
|
+
const gitArgs = (root: string, args: string[]) =>
|
|
361
|
+
execFileSync('git', args, { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
362
|
+
|
|
347
363
|
function landedWork(root: string, branch: string, base: string): boolean {
|
|
348
|
-
const git = (
|
|
364
|
+
const git = (...args: string[]) => gitArgs(root, args);
|
|
349
365
|
try {
|
|
350
|
-
const tip = git(
|
|
351
|
-
if (tip === git(
|
|
352
|
-
return git(
|
|
366
|
+
const tip = git('rev-parse', branch);
|
|
367
|
+
if (tip === git('rev-parse', base)) return false;
|
|
368
|
+
return git('log', base, '--merges', '--format=%P')
|
|
353
369
|
.split('\n')
|
|
354
370
|
.some((line) => line.trim().split(/\s+/).slice(1).includes(tip));
|
|
355
371
|
} catch {
|
|
@@ -364,11 +380,7 @@ export const gitStatusReconcile: Engine = (t, root, files) => {
|
|
|
364
380
|
let merged: Set<string>;
|
|
365
381
|
try {
|
|
366
382
|
merged = new Set(
|
|
367
|
-
|
|
368
|
-
cwd: root,
|
|
369
|
-
stdio: 'pipe',
|
|
370
|
-
})
|
|
371
|
-
.toString()
|
|
383
|
+
gitArgs(root, ['branch', '--merged', t.integration_branch ?? 'main', '--format=%(refname:short)'])
|
|
372
384
|
.split('\n')
|
|
373
385
|
.map((s) => s.trim())
|
|
374
386
|
.filter(Boolean),
|
|
@@ -403,8 +415,17 @@ export const computedClaim: Engine = (t, root, files) => {
|
|
|
403
415
|
let examined = 0;
|
|
404
416
|
for (const spec of specs) {
|
|
405
417
|
const values = new Map<string, string>();
|
|
418
|
+
// Which files share a version is the repo's judgement, not something to infer
|
|
419
|
+
// (F-023). The default sources glob `*/package.json`, which is right for a
|
|
420
|
+
// monorepo released in lockstep and wrong for a sibling that is deliberately
|
|
421
|
+
// versioned on its own — this repo's docs site sat at 0.0.1 beside a 0.2.0
|
|
422
|
+
// package, correctly, and installing the gate would have failed a healthy
|
|
423
|
+
// layout. So a repo states the exceptions rather than the engine guessing
|
|
424
|
+
// them, and `all-agree` keeps needing no opinion about which file is right.
|
|
425
|
+
const excluded = (rel: string) => (spec.exclude ?? []).some((p: string) => matchAny([rel], p).length > 0);
|
|
406
426
|
for (const src of spec.sources ?? []) {
|
|
407
427
|
for (const rel of matchAny(files, src.file)) {
|
|
428
|
+
if (excluded(rel)) continue;
|
|
408
429
|
const text = read(root, rel);
|
|
409
430
|
let v: string | undefined;
|
|
410
431
|
if (src.path && rel.endsWith('.json')) {
|
|
@@ -424,8 +445,18 @@ export const computedClaim: Engine = (t, root, files) => {
|
|
|
424
445
|
}
|
|
425
446
|
const distinct = new Set(values.values());
|
|
426
447
|
if (spec.rule === 'all-agree' && distinct.size > 1) {
|
|
448
|
+
// Name the file beside its value. The message used to list the distinct
|
|
449
|
+
// values and then say "run `{autofix}`" — which pointed at
|
|
450
|
+
// `rungs release sync-version`, a command that does not exist and never
|
|
451
|
+
// has. Telling someone to run a missing command is worse than telling
|
|
452
|
+
// them nothing, so the finding now carries what they actually need: which
|
|
453
|
+
// file says what. The hint is appended only if a real one is declared.
|
|
454
|
+
const where = [...values.entries()].map(([rel, v]) => `${rel}=${v}`).join(', ');
|
|
427
455
|
findings.push({
|
|
428
|
-
message:
|
|
456
|
+
message:
|
|
457
|
+
`${spec.id} disagrees across ${values.size} locations: ${where}` +
|
|
458
|
+
(spec.autofix ? ` — run \`${spec.autofix}\`` : '') +
|
|
459
|
+
(spec.exclude?.length ? '' : '. If one of these is versioned independently, list it in `exclude`.'),
|
|
429
460
|
});
|
|
430
461
|
}
|
|
431
462
|
}
|
package/src/engines3.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { matchAny } from './glob.ts';
|
|
5
5
|
import type { Engine, Finding } from './engines.ts';
|
|
@@ -213,7 +213,7 @@ export const rulePropagation: Engine = (t, root, files) => {
|
|
|
213
213
|
export const gitState: Engine = (t, root) => {
|
|
214
214
|
let out: string;
|
|
215
215
|
try {
|
|
216
|
-
out =
|
|
216
|
+
out = execFileSync('git', ['worktree', 'list', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
|
|
217
217
|
} catch {
|
|
218
218
|
// Not a git repo, or git unavailable. An unattributable result blocks:
|
|
219
219
|
// we do not land on an unknown.
|
|
@@ -248,7 +248,8 @@ export const mergeDriverCheck: Engine = (t, root) => {
|
|
|
248
248
|
for (const driver of required) {
|
|
249
249
|
let configured = '';
|
|
250
250
|
try {
|
|
251
|
-
|
|
251
|
+
// Driver names come from `.gitattributes`, so they reach this as data.
|
|
252
|
+
configured = execFileSync('git', ['config', '--get', `merge.${driver}.driver`], { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
252
253
|
} catch {
|
|
253
254
|
/* absent config exits non-zero, which is the finding */
|
|
254
255
|
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import {
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { parse } from 'smol-toml';
|
|
6
6
|
import type { Manifest } from './types.ts';
|
|
7
7
|
import { contentHash, emittedFiles, registerGates } from './add.ts';
|
|
@@ -334,8 +334,13 @@ export function setupGit(repoRoot: string, dryRun = false) {
|
|
|
334
334
|
: 'git merge-file -L ours -L base -L theirs %A %O %B';
|
|
335
335
|
if (!dryRun) {
|
|
336
336
|
try {
|
|
337
|
-
|
|
338
|
-
|
|
337
|
+
// argv, not a shell string. The `rungs-generated` driver command carries
|
|
338
|
+
// single quotes, a literal `\n` and `%A %O %B`, and it was being handed
|
|
339
|
+
// to a shell through `JSON.stringify` — quoting that happens to survive
|
|
340
|
+
// cmd.exe and does not survive bash the same way. The same class of bug
|
|
341
|
+
// as F-033, found in the same sweep.
|
|
342
|
+
execFileSync('git', ['config', `merge.${d}.name`, `rungs ${d.replace('rungs-', '')} driver`], { cwd: repoRoot, stdio: 'pipe' });
|
|
343
|
+
execFileSync('git', ['config', `merge.${d}.driver`, cmd], { cwd: repoRoot, stdio: 'pipe' });
|
|
339
344
|
} catch {
|
|
340
345
|
continue;
|
|
341
346
|
}
|
|
@@ -345,7 +350,7 @@ export function setupGit(repoRoot: string, dryRun = false) {
|
|
|
345
350
|
let rerere = false;
|
|
346
351
|
if (!dryRun) {
|
|
347
352
|
try {
|
|
348
|
-
|
|
353
|
+
execFileSync('git', ['config', 'rerere.enabled', 'true'], { cwd: repoRoot, stdio: 'pipe' });
|
|
349
354
|
rerere = true;
|
|
350
355
|
} catch {
|
|
351
356
|
/* not a git repo */
|
package/src/selftest.ts
CHANGED
|
@@ -68,6 +68,14 @@ function build(root: string, table: any, fx: any, input?: string): string[] | nu
|
|
|
68
68
|
if (typeof input === 'string') return [write(targetPath(table), `${input}\n`)];
|
|
69
69
|
if (!fx || typeof fx !== 'object') return null;
|
|
70
70
|
|
|
71
|
+
// A set of manifests and the version each states — the computed-claim shapes.
|
|
72
|
+
// `{ "package.json": "1.2.0", "site/package.json": "1.1.0" }`.
|
|
73
|
+
if (fx.packages && typeof fx.packages === 'object') {
|
|
74
|
+
return Object.entries(fx.packages).map(([rel, version]) =>
|
|
75
|
+
write(rel, JSON.stringify({ name: rel.replace(/\W/g, '-'), version })),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
71
79
|
// Named files in a parameterised directory, plus the version they are judged
|
|
72
80
|
// against — the changelog shapes. `dir` is stated by the fixture rather than
|
|
73
81
|
// assumed here, because the self-test sees the module's *raw* table and a
|
|
@@ -144,6 +152,7 @@ const CONTEXT_FREE: ReadonlySet<string> = new Set([
|
|
|
144
152
|
'register-schema',
|
|
145
153
|
'file-population',
|
|
146
154
|
'changelog-freshness',
|
|
155
|
+
'computed-claim',
|
|
147
156
|
]);
|
|
148
157
|
|
|
149
158
|
/**
|
|
@@ -196,6 +205,13 @@ export function runSelfTests(
|
|
|
196
205
|
// Same bridge, for paths: a fixture that names a parameterised directory
|
|
197
206
|
// has to hand the spec the same literal it wrote the files into.
|
|
198
207
|
if (Array.isArray(b.fixture?.fragments)) spec = deparam(spec, b.fixture.dir ?? 'changelog.d');
|
|
208
|
+
// And for `exclude`, which is the thing under test in half these fixtures:
|
|
209
|
+
// the table ships it empty by default, so a fixture proving exclusion works
|
|
210
|
+
// has to set it, exactly as a repo would.
|
|
211
|
+
if (Array.isArray(b.fixture?.exclude)) {
|
|
212
|
+
const ex = b.fixture.exclude;
|
|
213
|
+
spec = Array.isArray(spec) ? spec.map((s: any) => ({ ...s, exclude: ex })) : { ...spec, exclude: ex };
|
|
214
|
+
}
|
|
199
215
|
if (!files) {
|
|
200
216
|
out.push({ gate: gateId, expect, outcome: 'unrun', detail: `no builder for fixture ${JSON.stringify(b.fixture).slice(0, 60)}` });
|
|
201
217
|
continue;
|