@rungs/cli 0.2.0 → 0.3.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/README.md +28 -15
- package/dist/cli.js +389 -24
- 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/engines2.ts +20 -1
- package/src/selftest.ts +16 -0
package/README.md
CHANGED
|
@@ -138,6 +138,10 @@ reported as diverged and left alone.
|
|
|
138
138
|
| `rungs setup git [path]` | Install the merge drivers `.gitattributes` names |
|
|
139
139
|
| `rungs modules` | List the set and audit the manifests |
|
|
140
140
|
| `rungs backlog archive` | Move finished items to `archive/`, repointing every link that cites them |
|
|
141
|
+
| `rungs session start <branch>` | Cut a branch and worktree from the last **verified** merge, not the tip |
|
|
142
|
+
| `rungs preflight [path]` | Did the integration branch change files *you* changed? |
|
|
143
|
+
| `rungs land <branch>` | Merge → verify the merged tree → advance with a compare-and-swap, or refuse and park it |
|
|
144
|
+
| `rungs worktrees [path]` | Which worktrees are merged, prunable, or merged and still dirty |
|
|
141
145
|
|
|
142
146
|
| Option | Effect |
|
|
143
147
|
| --- | --- |
|
|
@@ -169,6 +173,10 @@ you when you are installing above your level.
|
|
|
169
173
|
| **4** docs that restate each other | `doc-authority` |
|
|
170
174
|
| **5** 5+ concurrent sessions | `concurrency` |
|
|
171
175
|
|
|
176
|
+
The last four commands are the `concurrency` loop. They drive git, which nothing else in rungs
|
|
177
|
+
does — [ADR-0009](docs/decisions/ADR-0009-rungs-drives-git.md) sets the rules they obey: verify
|
|
178
|
+
before you advance, never destroy (only refuse), and never hold the integration branch.
|
|
179
|
+
|
|
172
180
|
`concurrency` refuses to install without `--confirm-threshold`, because below
|
|
173
181
|
five simultaneous sessions every mechanism in it costs more than it returns.
|
|
174
182
|
Selling rung 5 to a rung-1 repo is the most likely way this tool does harm.
|
|
@@ -212,22 +220,27 @@ Four promises that shape everything else:
|
|
|
212
220
|
|
|
213
221
|
## Status
|
|
214
222
|
|
|
215
|
-
**
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
223
|
+
**Current release: v0.3.0** — that number is checked against [`package.json`](package.json) by the
|
|
224
|
+
`docs-version-claims` gate, so this sentence cannot drift from what ships — and the gate fails if
|
|
225
|
+
the sentence is reworded out of existence, because a claim nothing checks any more should not look
|
|
226
|
+
the same as a claim that passes.
|
|
227
|
+
|
|
228
|
+
It deliberately does **not** say "and it is on npm". Whether this version is tagged and published
|
|
229
|
+
is a fact about the registry, not about the manifest, and it was wrong here for two days once
|
|
230
|
+
already.
|
|
220
231
|
|
|
221
|
-
|
|
222
|
-
[the versions page](https://docs.rungscli.com/versions/)
|
|
223
|
-
|
|
224
|
-
|
|
232
|
+
Whether a release is **pending**, and what npm currently serves, live on
|
|
233
|
+
[the versions page](https://docs.rungscli.com/versions/) — the one place that tracks them. This
|
|
234
|
+
file and the roadmap both carried their own copy and both said `v0.1.2` for two days after v0.1.3
|
|
235
|
+
went to npm. Cutting the next one follows the [release runbook](docs/design/release-runbook.md).
|
|
225
236
|
|
|
226
|
-
rungs is installed in its own repo and its gates run on every change —
|
|
227
|
-
0 fail (`rungs check`, 2026-08-17). A clean consumer has
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
237
|
+
rungs is installed in its own repo and its 29 gates run on every change — 29 pass,
|
|
238
|
+
0 fail (`rungs check`, 2026-08-17). A clean consumer has installed from the **public
|
|
239
|
+
registry** and run the binary, and another completed the doctor → init → add → check →
|
|
240
|
+
render → upgrade/eject journey from a packed artifact. Detection is
|
|
241
|
+
[verified against all four source repos](docs/design/detection-verification.md).
|
|
242
|
+
Not yet done: a cross-platform release matrix — only Windows is verified — and writing
|
|
243
|
+
to a repo rungs did not scaffold.
|
|
231
244
|
|
|
232
245
|
Expect module *contents* to move. The command surface is settled.
|
|
233
246
|
|
|
@@ -248,7 +261,7 @@ nobody paid for does not ship.
|
|
|
248
261
|
| [`docs/design/`](docs/design/README.md) | Product brief, module catalogue, verification |
|
|
249
262
|
| [`docs/decisions/`](docs/decisions/README.md) | ADRs |
|
|
250
263
|
| [`modules/`](modules/README.md) | The fifteen modules |
|
|
251
|
-
| [`src/`](src/) | The CLI, ~
|
|
264
|
+
| [`src/`](src/) | The CLI, ~5,100 lines (`wc -l src/*.ts`, 2026-08-17; held within 10% by `docs-version-claims`) |
|
|
252
265
|
|
|
253
266
|
## Licence
|
|
254
267
|
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
5
|
-
import { dirname as
|
|
5
|
+
import { dirname as dirname9, join as join14, resolve as resolve5 } from "node:path";
|
|
6
6
|
|
|
7
7
|
// src/manifest.ts
|
|
8
8
|
import { readdirSync as readdirSync2, readFileSync, statSync as statSync2 } from "node:fs";
|
|
@@ -880,6 +880,11 @@ function build(root, table, fx, input) {
|
|
|
880
880
|
if (typeof input === "string") return [write(targetPath(table), `${input}
|
|
881
881
|
`)];
|
|
882
882
|
if (!fx || typeof fx !== "object") return null;
|
|
883
|
+
if (fx.packages && typeof fx.packages === "object") {
|
|
884
|
+
return Object.entries(fx.packages).map(
|
|
885
|
+
([rel, version]) => write(rel, JSON.stringify({ name: rel.replace(/\W/g, "-"), version }))
|
|
886
|
+
);
|
|
887
|
+
}
|
|
883
888
|
if (Array.isArray(fx.fragments) && typeof fx.version === "string") {
|
|
884
889
|
const dir = fx.dir ?? "changelog.d";
|
|
885
890
|
const written = fx.fragments.map((n) => write(`${dir}/${n}`, `# ${n}
|
|
@@ -929,7 +934,8 @@ var CONTEXT_FREE = /* @__PURE__ */ new Set([
|
|
|
929
934
|
"file-budget",
|
|
930
935
|
"register-schema",
|
|
931
936
|
"file-population",
|
|
932
|
-
"changelog-freshness"
|
|
937
|
+
"changelog-freshness",
|
|
938
|
+
"computed-claim"
|
|
933
939
|
]);
|
|
934
940
|
function deparam(spec, dir) {
|
|
935
941
|
const walk3 = (v) => typeof v === "string" ? v.replace(/\{\{[^}]+\}\}/g, dir) : Array.isArray(v) ? v.map(walk3) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk3(x)])) : v;
|
|
@@ -952,6 +958,10 @@ function runSelfTests(gateId, engine, table, blocks) {
|
|
|
952
958
|
const files = build(root, table, b.fixture, b.input);
|
|
953
959
|
let spec = b.fixture?.opted_in ? Array.isArray(table) ? table.map((s) => ({ ...s, extensions_opted_in: b.fixture.opted_in })) : { ...table, extensions_opted_in: b.fixture.opted_in } : table;
|
|
954
960
|
if (Array.isArray(b.fixture?.fragments)) spec = deparam(spec, b.fixture.dir ?? "changelog.d");
|
|
961
|
+
if (Array.isArray(b.fixture?.exclude)) {
|
|
962
|
+
const ex = b.fixture.exclude;
|
|
963
|
+
spec = Array.isArray(spec) ? spec.map((s) => ({ ...s, exclude: ex })) : { ...spec, exclude: ex };
|
|
964
|
+
}
|
|
955
965
|
if (!files) {
|
|
956
966
|
out.push({ gate: gateId, expect, outcome: "unrun", detail: `no builder for fixture ${JSON.stringify(b.fixture).slice(0, 60)}` });
|
|
957
967
|
continue;
|
|
@@ -1228,11 +1238,11 @@ var crossReference = (t, root, files) => {
|
|
|
1228
1238
|
return { findings, examined: skills.length };
|
|
1229
1239
|
};
|
|
1230
1240
|
function landedWork(root, branch, base) {
|
|
1231
|
-
const
|
|
1241
|
+
const git2 = (cmd2) => execSync(`git ${cmd2}`, { cwd: root, stdio: "pipe" }).toString().trim();
|
|
1232
1242
|
try {
|
|
1233
|
-
const tip =
|
|
1234
|
-
if (tip ===
|
|
1235
|
-
return
|
|
1243
|
+
const tip = git2(`rev-parse ${branch}`);
|
|
1244
|
+
if (tip === git2(`rev-parse ${base}`)) return false;
|
|
1245
|
+
return git2(`log ${base} --merges --format=%P`).split("\n").some((line) => line.trim().split(/\s+/).slice(1).includes(tip));
|
|
1236
1246
|
} catch {
|
|
1237
1247
|
return true;
|
|
1238
1248
|
}
|
|
@@ -1270,8 +1280,10 @@ var computedClaim = (t, root, files) => {
|
|
|
1270
1280
|
let examined = 0;
|
|
1271
1281
|
for (const spec of specs) {
|
|
1272
1282
|
const values = /* @__PURE__ */ new Map();
|
|
1283
|
+
const excluded = (rel) => (spec.exclude ?? []).some((p) => matchAny([rel], p).length > 0);
|
|
1273
1284
|
for (const src of spec.sources ?? []) {
|
|
1274
1285
|
for (const rel of matchAny(files, src.file)) {
|
|
1286
|
+
if (excluded(rel)) continue;
|
|
1275
1287
|
const text = read(root, rel);
|
|
1276
1288
|
let v;
|
|
1277
1289
|
if (src.path && rel.endsWith(".json")) {
|
|
@@ -1290,8 +1302,9 @@ var computedClaim = (t, root, files) => {
|
|
|
1290
1302
|
}
|
|
1291
1303
|
const distinct = new Set(values.values());
|
|
1292
1304
|
if (spec.rule === "all-agree" && distinct.size > 1) {
|
|
1305
|
+
const where = [...values.entries()].map(([rel, v]) => `${rel}=${v}`).join(", ");
|
|
1293
1306
|
findings.push({
|
|
1294
|
-
message: `${spec.id} disagrees across ${values.size} locations: ${
|
|
1307
|
+
message: `${spec.id} disagrees across ${values.size} locations: ${where}` + (spec.autofix ? ` \u2014 run \`${spec.autofix}\`` : "") + (spec.exclude?.length ? "" : ". If one of these is versioned independently, list it in `exclude`.")
|
|
1295
1308
|
});
|
|
1296
1309
|
}
|
|
1297
1310
|
}
|
|
@@ -1825,7 +1838,7 @@ var UnknownTierError = class extends Error {
|
|
|
1825
1838
|
this.declared = declared;
|
|
1826
1839
|
}
|
|
1827
1840
|
};
|
|
1828
|
-
function runGates(repoRoot, tier, now = () => Date.now()) {
|
|
1841
|
+
function runGates(repoRoot, tier, now = () => Date.now(), only) {
|
|
1829
1842
|
const { runner, gates } = loadRegistry(repoRoot);
|
|
1830
1843
|
const runnerTiers = Array.isArray(runner?.tiers) ? runner.tiers : [];
|
|
1831
1844
|
if (tier && runnerTiers.length && !runnerTiers.includes(tier)) {
|
|
@@ -1835,6 +1848,7 @@ function runGates(repoRoot, tier, now = () => Date.now()) {
|
|
|
1835
1848
|
const runs = [];
|
|
1836
1849
|
for (const g of gates) {
|
|
1837
1850
|
if (g.trigger) continue;
|
|
1851
|
+
if (only && !only.has(g.id)) continue;
|
|
1838
1852
|
if (tier && !tierSelects(runnerTiers, tier, g.tier)) continue;
|
|
1839
1853
|
const started = now();
|
|
1840
1854
|
let status = "pass";
|
|
@@ -2286,15 +2300,17 @@ function planArchive(repoRoot, backlogRoot = "docs/backlog") {
|
|
|
2286
2300
|
const files = walk(repoRoot);
|
|
2287
2301
|
const items = files.filter((f) => posix(f).startsWith(posix(relative2(repoRoot, itemsDir)) + "/") && f.endsWith(".md"));
|
|
2288
2302
|
for (const rel of items) {
|
|
2289
|
-
|
|
2303
|
+
const base = posix(rel).split("/").pop();
|
|
2304
|
+
if (/^(README|TEMPLATE)\.md$/i.test(base)) continue;
|
|
2290
2305
|
const text = readFileSync10(join12(repoRoot, rel), "utf8");
|
|
2291
2306
|
const status = field(text, "status");
|
|
2292
2307
|
const id = field(text, "id");
|
|
2293
2308
|
if (!FINISHED.has(status)) continue;
|
|
2294
2309
|
if (field(text, "type") === "epic") {
|
|
2295
2310
|
const children = (text.match(/^children:\s*\[(.*)\]/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
2311
|
+
const archived = files.filter((f) => posix(f).startsWith(posix(relative2(repoRoot, archiveDir)) + "/") && f.endsWith(".md"));
|
|
2296
2312
|
const unfinished = children.filter((c2) => {
|
|
2297
|
-
const f = items.find((i) => i.includes(`${c2}-`));
|
|
2313
|
+
const f = items.find((i) => i.includes(`${c2}-`)) ?? archived.find((i) => i.includes(`${c2}-`));
|
|
2298
2314
|
return !f || !FINISHED.has(field(readFileSync10(join12(repoRoot, f), "utf8"), "status"));
|
|
2299
2315
|
});
|
|
2300
2316
|
if (unfinished.length) {
|
|
@@ -2362,10 +2378,268 @@ function applyArchive(repoRoot, plan) {
|
|
|
2362
2378
|
}
|
|
2363
2379
|
}
|
|
2364
2380
|
|
|
2381
|
+
// src/concurrency.ts
|
|
2382
|
+
import { existsSync as existsSync10, mkdtempSync as mkdtempSync2, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync6, unlinkSync } from "node:fs";
|
|
2383
|
+
import { execFileSync } from "node:child_process";
|
|
2384
|
+
import { hostname } from "node:os";
|
|
2385
|
+
import { join as join13, resolve as resolve4, dirname as dirname8, basename as basename2 } from "node:path";
|
|
2386
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
2387
|
+
function loopParams(root) {
|
|
2388
|
+
const p = installedParams(root).concurrency ?? {};
|
|
2389
|
+
const integration = String(p.integration_branch ?? "main");
|
|
2390
|
+
const greenPrefix = String(p.green_prefix ?? "green/");
|
|
2391
|
+
return {
|
|
2392
|
+
integration,
|
|
2393
|
+
// The green ref marks the last *verified* merge of the integration branch,
|
|
2394
|
+
// so it is prefix + that branch — not prefix + whatever you are cutting.
|
|
2395
|
+
greenRef: `${greenPrefix}${integration}`,
|
|
2396
|
+
integPrefix: String(p.integ_prefix ?? "integ/")
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
function git(root, args2) {
|
|
2400
|
+
return execFileSync("git", args2, { cwd: root, stdio: "pipe", encoding: "utf8" }).trim();
|
|
2401
|
+
}
|
|
2402
|
+
function gitOk(root, args2) {
|
|
2403
|
+
try {
|
|
2404
|
+
git(root, args2);
|
|
2405
|
+
return true;
|
|
2406
|
+
} catch {
|
|
2407
|
+
return false;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
function revParse(root, ref) {
|
|
2411
|
+
try {
|
|
2412
|
+
return git(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
2413
|
+
} catch {
|
|
2414
|
+
return null;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
function sessionStart(root, branch, at, dryRun = false) {
|
|
2418
|
+
const { integration, greenRef } = loopParams(root);
|
|
2419
|
+
const lines = [];
|
|
2420
|
+
if (!branch) return { ok: false, lines: ["a branch name is required: `rungs session start <branch> [path]`"] };
|
|
2421
|
+
if (revParse(root, `refs/heads/${branch}`)) {
|
|
2422
|
+
return { ok: false, lines: [`branch '${branch}' already exists \u2014 pick another name, or check out the worktree that holds it`] };
|
|
2423
|
+
}
|
|
2424
|
+
const green = revParse(root, `refs/heads/${greenRef}`);
|
|
2425
|
+
const base = green ? greenRef : integration;
|
|
2426
|
+
const baseSha = green ?? revParse(root, integration);
|
|
2427
|
+
if (!baseSha) return { ok: false, lines: [`neither '${greenRef}' nor '${integration}' resolves \u2014 is this the right repo?`] };
|
|
2428
|
+
if (green) {
|
|
2429
|
+
lines.push(`base ${greenRef} (${baseSha.slice(0, 8)}) \u2014 the last verified merge`);
|
|
2430
|
+
} else {
|
|
2431
|
+
lines.push(`no ${greenRef} ref yet \u2014 cutting from the tip of ${integration} (${baseSha.slice(0, 8)}) instead.`);
|
|
2432
|
+
lines.push(`That tip has not been verified by a land. The first successful \`rungs land\` creates ${greenRef}.`);
|
|
2433
|
+
}
|
|
2434
|
+
const path = resolve4(at ?? join13(dirname8(root), `${basename2(root)}-${branch.replace(/[^\w.-]+/g, "-")}`));
|
|
2435
|
+
if (existsSync10(path)) return { ok: false, lines: [`${path} already exists \u2014 rungs never writes over a directory it did not create`] };
|
|
2436
|
+
lines.push(`worktree ${path}`);
|
|
2437
|
+
lines.push(`branch ${branch}`);
|
|
2438
|
+
if (dryRun) return { ok: true, lines };
|
|
2439
|
+
try {
|
|
2440
|
+
git(root, ["worktree", "add", "-b", branch, path, baseSha]);
|
|
2441
|
+
} catch (e) {
|
|
2442
|
+
return { ok: false, lines: [...lines, `git refused: ${String(e.stderr ?? e.message).trim().split("\n").slice(-2).join(" ")}`] };
|
|
2443
|
+
}
|
|
2444
|
+
return { ok: true, lines };
|
|
2445
|
+
}
|
|
2446
|
+
function preflight(root) {
|
|
2447
|
+
const { integration } = loopParams(root);
|
|
2448
|
+
if (!revParse(root, integration)) return { ok: false, lines: [`'${integration}' does not resolve \u2014 is this the right repo?`] };
|
|
2449
|
+
let base;
|
|
2450
|
+
try {
|
|
2451
|
+
base = git(root, ["merge-base", "HEAD", integration]);
|
|
2452
|
+
} catch {
|
|
2453
|
+
return { ok: false, lines: [`no merge base between HEAD and ${integration}; nothing to compare`] };
|
|
2454
|
+
}
|
|
2455
|
+
const names = (args2) => new Set(git(root, args2).split("\n").map((s) => s.trim()).filter(Boolean));
|
|
2456
|
+
const theirs = names(["diff", "--name-only", base, integration]);
|
|
2457
|
+
const mine = /* @__PURE__ */ new Set([
|
|
2458
|
+
...names(["diff", "--name-only", base, "HEAD"]),
|
|
2459
|
+
...names(["diff", "--name-only", "HEAD"]),
|
|
2460
|
+
...names(["diff", "--name-only", "--cached"])
|
|
2461
|
+
]);
|
|
2462
|
+
const ahead = Number(git(root, ["rev-list", "--count", `${base}..${integration}`]));
|
|
2463
|
+
const overlap = [...mine].filter((f) => theirs.has(f)).sort();
|
|
2464
|
+
const lines = [
|
|
2465
|
+
`${integration} is ${ahead} commit(s) ahead of your base, touching ${theirs.size} file(s).`,
|
|
2466
|
+
`You have touched ${mine.size} file(s).`
|
|
2467
|
+
];
|
|
2468
|
+
if (!overlap.length) {
|
|
2469
|
+
lines.push("No overlap. The commit count is not the signal \u2014 these two sets not intersecting is.");
|
|
2470
|
+
return { ok: true, lines };
|
|
2471
|
+
}
|
|
2472
|
+
lines.push(`${overlap.length} file(s) changed on both sides:`);
|
|
2473
|
+
for (const f of overlap.slice(0, 20)) lines.push(` ${f}`);
|
|
2474
|
+
if (overlap.length > 20) lines.push(` \u2026and ${overlap.length - 20} more`);
|
|
2475
|
+
lines.push("Merge sooner rather than later. Shared code is a scheduling problem, not a tooling one.");
|
|
2476
|
+
return { ok: true, lines };
|
|
2477
|
+
}
|
|
2478
|
+
function lockPath(root) {
|
|
2479
|
+
return join13(git(root, ["rev-parse", "--git-common-dir"]).replace(/^\.git$/, join13(root, ".git")), "rungs-land.lock");
|
|
2480
|
+
}
|
|
2481
|
+
function alive(pid) {
|
|
2482
|
+
try {
|
|
2483
|
+
process.kill(pid, 0);
|
|
2484
|
+
return true;
|
|
2485
|
+
} catch (e) {
|
|
2486
|
+
return e?.code === "EPERM";
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
function land(root, branch, runner, dryRun = false) {
|
|
2490
|
+
const { integration, greenRef, integPrefix } = loopParams(root);
|
|
2491
|
+
const lines = [];
|
|
2492
|
+
if (!branch) return { ok: false, lines: ["a branch name is required: `rungs land <branch>`"] };
|
|
2493
|
+
const head = revParse(root, `refs/heads/${branch}`);
|
|
2494
|
+
if (!head) return { ok: false, lines: [`branch '${branch}' does not exist`] };
|
|
2495
|
+
const before = revParse(root, `refs/heads/${integration}`);
|
|
2496
|
+
if (!before) return { ok: false, lines: [`'${integration}' does not resolve`] };
|
|
2497
|
+
const lp = lockPath(root);
|
|
2498
|
+
if (existsSync10(lp)) {
|
|
2499
|
+
try {
|
|
2500
|
+
const held = JSON.parse(readFileSync11(lp, "utf8"));
|
|
2501
|
+
if (held.host === hostname() && alive(held.pid)) {
|
|
2502
|
+
return {
|
|
2503
|
+
ok: false,
|
|
2504
|
+
lines: [
|
|
2505
|
+
`another land is in progress: pid ${held.pid} on ${held.host}, landing '${held.branch}' since ${held.started}.`,
|
|
2506
|
+
"Concurrent landing is refused, not silently merged."
|
|
2507
|
+
]
|
|
2508
|
+
};
|
|
2509
|
+
}
|
|
2510
|
+
lines.push(`taking over a stale lock from pid ${held.pid} (${held.started}) \u2014 that process is gone.`);
|
|
2511
|
+
} catch {
|
|
2512
|
+
lines.push("an unreadable lock file was replaced.");
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
if (dryRun) {
|
|
2516
|
+
lines.push(`would merge ${branch} (${head.slice(0, 8)}) onto ${integration} (${before.slice(0, 8)}) via ${integPrefix}${branch}, verify, then advance.`);
|
|
2517
|
+
return { ok: true, lines };
|
|
2518
|
+
}
|
|
2519
|
+
const lock = { pid: process.pid, host: hostname(), started: (/* @__PURE__ */ new Date()).toISOString(), branch };
|
|
2520
|
+
writeFileSync6(lp, JSON.stringify(lock));
|
|
2521
|
+
const scratch = mkdtempSync2(join13(tmpdir2(), "rungs-land-"));
|
|
2522
|
+
const parked = `${integPrefix}${branch}`;
|
|
2523
|
+
try {
|
|
2524
|
+
git(root, ["worktree", "add", "--detach", scratch, before]);
|
|
2525
|
+
try {
|
|
2526
|
+
git(scratch, ["-c", "user.email=rungs@localhost", "-c", "user.name=rungs", "merge", "--no-ff", "-m", `land ${branch}`, head]);
|
|
2527
|
+
} catch (e) {
|
|
2528
|
+
const conflicts = (() => {
|
|
2529
|
+
try {
|
|
2530
|
+
return git(scratch, ["diff", "--name-only", "--diff-filter=U"]).split("\n").filter(Boolean);
|
|
2531
|
+
} catch {
|
|
2532
|
+
return [];
|
|
2533
|
+
}
|
|
2534
|
+
})();
|
|
2535
|
+
lines.push(`merge conflict \u2014 ${integration} is unchanged.`);
|
|
2536
|
+
for (const f of conflicts.slice(0, 15)) lines.push(` ${f}`);
|
|
2537
|
+
lines.push("Reconcile generated artifacts by regenerating, never by merging text.");
|
|
2538
|
+
return { ok: false, lines };
|
|
2539
|
+
}
|
|
2540
|
+
const merged = git(scratch, ["rev-parse", "HEAD"]);
|
|
2541
|
+
const res = runner(scratch);
|
|
2542
|
+
lines.push(`merged tree ${merged.slice(0, 8)} \u2014 ${res.pass} pass \xB7 ${res.failing.length} fail`);
|
|
2543
|
+
if (res.failing.length) {
|
|
2544
|
+
const ids = new Set(res.failing.map((f) => f.id));
|
|
2545
|
+
let base = null;
|
|
2546
|
+
try {
|
|
2547
|
+
git(scratch, ["reset", "--hard", before]);
|
|
2548
|
+
base = runner(scratch, ids);
|
|
2549
|
+
} catch {
|
|
2550
|
+
base = null;
|
|
2551
|
+
}
|
|
2552
|
+
const attributable = base !== null && base.failing.length + base.pass >= ids.size;
|
|
2553
|
+
const baseFindings = new Map((base?.failing ?? []).map((f) => [f.id, new Set(f.findings)]));
|
|
2554
|
+
const introduced = [];
|
|
2555
|
+
const inherited = [];
|
|
2556
|
+
for (const f of res.failing) {
|
|
2557
|
+
const seen = attributable ? baseFindings.get(f.id) ?? /* @__PURE__ */ new Set() : null;
|
|
2558
|
+
const fresh = seen ? f.findings.filter((x) => !seen.has(x)) : f.findings;
|
|
2559
|
+
if (fresh.length) introduced.push({ id: f.id, findings: fresh });
|
|
2560
|
+
else inherited.push(f);
|
|
2561
|
+
}
|
|
2562
|
+
for (const f of inherited) {
|
|
2563
|
+
lines.push(` inherited ${f.id}${f.findings[0] ? ` \u2014 ${f.findings[0]}` : ""}`);
|
|
2564
|
+
}
|
|
2565
|
+
for (const f of introduced) {
|
|
2566
|
+
lines.push(` INTRODUCED ${f.id}${f.findings[0] ? ` \u2014 ${f.findings[0]}` : ""}`);
|
|
2567
|
+
for (const extra of f.findings.slice(1, 4)) lines.push(` ${extra}`);
|
|
2568
|
+
}
|
|
2569
|
+
if (base === null) {
|
|
2570
|
+
lines.push(" The merge base could not be gated, so nothing here is attributable and all of it blocks.");
|
|
2571
|
+
} else if (!attributable) {
|
|
2572
|
+
lines.push(" Some gates could not be attributed against the merge base, so they block. We do not land on an unknown.");
|
|
2573
|
+
}
|
|
2574
|
+
if (introduced.length) {
|
|
2575
|
+
git(root, ["update-ref", `refs/heads/${parked}`, merged]);
|
|
2576
|
+
lines.push(
|
|
2577
|
+
`${introduced.length} introduced by this branch. ${integration} is unchanged, and the merged tree is parked on '${parked}' \u2014 fix it there and land again.`
|
|
2578
|
+
);
|
|
2579
|
+
return { ok: false, lines };
|
|
2580
|
+
}
|
|
2581
|
+
lines.push(
|
|
2582
|
+
`${inherited.length} failure(s), all already red on ${integration} before this branch. Landing anyway \u2014 they are not this branch's to fix, and blocking on them is how a gate gets bypassed.`
|
|
2583
|
+
);
|
|
2584
|
+
git(scratch, ["reset", "--hard", merged]);
|
|
2585
|
+
}
|
|
2586
|
+
try {
|
|
2587
|
+
git(root, ["update-ref", `refs/heads/${integration}`, merged, before]);
|
|
2588
|
+
} catch {
|
|
2589
|
+
git(root, ["update-ref", `refs/heads/${parked}`, merged]);
|
|
2590
|
+
return {
|
|
2591
|
+
ok: false,
|
|
2592
|
+
lines: [
|
|
2593
|
+
...lines,
|
|
2594
|
+
`${integration} moved while this land was verifying, so the advance was refused rather than overwriting it.`,
|
|
2595
|
+
`Your verified merge is parked on '${parked}'. Re-run \`rungs land ${branch}\` to rebuild it on the new tip.`
|
|
2596
|
+
]
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
git(root, ["update-ref", `refs/heads/${greenRef}`, merged]);
|
|
2600
|
+
lines.push(`${integration} \u2192 ${merged.slice(0, 8)}, and ${greenRef} now marks it verified.`);
|
|
2601
|
+
if (revParse(root, `refs/heads/${parked}`)) git(root, ["update-ref", "-d", `refs/heads/${parked}`]);
|
|
2602
|
+
return { ok: true, lines };
|
|
2603
|
+
} finally {
|
|
2604
|
+
try {
|
|
2605
|
+
git(root, ["worktree", "remove", "--force", scratch]);
|
|
2606
|
+
} catch {
|
|
2607
|
+
rmSync2(scratch, { recursive: true, force: true });
|
|
2608
|
+
try {
|
|
2609
|
+
git(root, ["worktree", "prune"]);
|
|
2610
|
+
} catch {
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
try {
|
|
2614
|
+
unlinkSync(lp);
|
|
2615
|
+
} catch {
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
function worktrees(root) {
|
|
2620
|
+
const { integration } = loopParams(root);
|
|
2621
|
+
const out = git(root, ["worktree", "list", "--porcelain"]);
|
|
2622
|
+
const rows = [];
|
|
2623
|
+
for (const block of out.split("\n\n").filter((b) => b.trim())) {
|
|
2624
|
+
const path = block.match(/^worktree (.+)$/m)?.[1];
|
|
2625
|
+
const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1];
|
|
2626
|
+
if (!path || !branch || branch === integration) continue;
|
|
2627
|
+
const merged = gitOk(root, ["merge-base", "--is-ancestor", branch, integration]);
|
|
2628
|
+
let dirty = false;
|
|
2629
|
+
try {
|
|
2630
|
+
dirty = git(path, ["status", "--porcelain"]).length > 0;
|
|
2631
|
+
} catch {
|
|
2632
|
+
dirty = false;
|
|
2633
|
+
}
|
|
2634
|
+
rows.push({ path, branch, merged, dirty });
|
|
2635
|
+
}
|
|
2636
|
+
return { rows, integration };
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2365
2639
|
// src/cli.ts
|
|
2366
|
-
import { existsSync as
|
|
2367
|
-
var HERE =
|
|
2368
|
-
var MODULES2 =
|
|
2640
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
2641
|
+
var HERE = dirname9(fileURLToPath3(import.meta.url));
|
|
2642
|
+
var MODULES2 = join14(HERE, "..", "modules");
|
|
2369
2643
|
var c = {
|
|
2370
2644
|
dim: (s) => `\x1B[2m${s}\x1B[0m`,
|
|
2371
2645
|
bold: (s) => `\x1B[1m${s}\x1B[0m`,
|
|
@@ -2423,7 +2697,7 @@ ${mods.length} modules
|
|
|
2423
2697
|
return issues.length === 0 ? 0 : 1;
|
|
2424
2698
|
}
|
|
2425
2699
|
function cmdDoctor(target, doExplain = false) {
|
|
2426
|
-
const root =
|
|
2700
|
+
const root = resolve5(target);
|
|
2427
2701
|
const mods = loadAllModules(MODULES2);
|
|
2428
2702
|
console.log(c.bold(`
|
|
2429
2703
|
rungs doctor \u2014 ${root}
|
|
@@ -2594,6 +2868,29 @@ function cmdAdd(names, root, dryRun, harnesses, stamp) {
|
|
|
2594
2868
|
}
|
|
2595
2869
|
(overrides[modName] ??= {})[param] = rhs.join("=");
|
|
2596
2870
|
}
|
|
2871
|
+
for (const [modName, vals] of Object.entries(overrides)) {
|
|
2872
|
+
const mod = mods.find((m) => m.name === modName);
|
|
2873
|
+
if (!mod) {
|
|
2874
|
+
console.log(
|
|
2875
|
+
c.red(`
|
|
2876
|
+
--set names a module that does not exist: ${modName}`) + c.dim(`
|
|
2877
|
+
Known: ${mods.map((m) => m.name).join(", ")}
|
|
2878
|
+
`)
|
|
2879
|
+
);
|
|
2880
|
+
return 1;
|
|
2881
|
+
}
|
|
2882
|
+
for (const k of Object.keys(vals)) {
|
|
2883
|
+
if (!(k in mod.params)) {
|
|
2884
|
+
const known = Object.keys(mod.params);
|
|
2885
|
+
console.log(
|
|
2886
|
+
c.red(`
|
|
2887
|
+
--set names a parameter ${modName} does not have: ${k}`) + c.dim(`
|
|
2888
|
+
${known.length ? `${modName} takes: ${known.join(", ")}` : `${modName} takes no parameters`}`) + c.dim("\n `rungs modules --params` lists every parameter and its default.\n")
|
|
2889
|
+
);
|
|
2890
|
+
return 1;
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2597
2894
|
const params = resolveParams(mods, overrides, root);
|
|
2598
2895
|
for (const [m, vals] of Object.entries(overrides)) {
|
|
2599
2896
|
for (const [k, v] of Object.entries(vals)) console.log(c.dim(` set ${m}.${k} = ${v}`));
|
|
@@ -2712,6 +3009,49 @@ rungs render \u2014 ${root}
|
|
|
2712
3009
|
}
|
|
2713
3010
|
return 0;
|
|
2714
3011
|
}
|
|
3012
|
+
function report(r) {
|
|
3013
|
+
console.log();
|
|
3014
|
+
for (const l of r.lines) console.log(` ${r.ok ? l : c.yellow(l)}`);
|
|
3015
|
+
console.log();
|
|
3016
|
+
return r.ok ? 0 : 1;
|
|
3017
|
+
}
|
|
3018
|
+
function landRunner(dir, only) {
|
|
3019
|
+
const runs = runGates(dir, void 0, void 0, only);
|
|
3020
|
+
const failing = runs.filter((r) => r.status === "fail" || r.status === "error");
|
|
3021
|
+
return {
|
|
3022
|
+
pass: runs.filter((r) => r.status === "pass").length,
|
|
3023
|
+
// `file: message`, so the same broken link in the same file is the same
|
|
3024
|
+
// finding across two runs, and a *new* one is visibly not.
|
|
3025
|
+
failing: failing.map((r) => ({
|
|
3026
|
+
id: r.id,
|
|
3027
|
+
findings: r.findings.map((f) => `${f.file ? `${f.file}: ` : ""}${f.message}`)
|
|
3028
|
+
}))
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
function cmdWorktrees(root) {
|
|
3032
|
+
const { rows, integration } = worktrees(root);
|
|
3033
|
+
console.log(c.bold(`
|
|
3034
|
+
rungs worktrees \u2014 merged into ${integration}?
|
|
3035
|
+
`));
|
|
3036
|
+
if (!rows.length) {
|
|
3037
|
+
console.log(c.dim(" no linked worktrees. `rungs session start <branch>` creates one.\n"));
|
|
3038
|
+
return 0;
|
|
3039
|
+
}
|
|
3040
|
+
for (const w of rows) {
|
|
3041
|
+
const state = w.merged && w.dirty ? c.red("merged \xB7 DIRTY") : w.merged ? c.green("merged \xB7 prunable") : c.dim("in flight");
|
|
3042
|
+
console.log(` ${state.padEnd(28)} ${w.branch.padEnd(30)} ${c.dim(w.path)}`);
|
|
3043
|
+
}
|
|
3044
|
+
const risky = rows.filter((w) => w.merged && w.dirty);
|
|
3045
|
+
const prunable = rows.filter((w) => w.merged && !w.dirty);
|
|
3046
|
+
console.log();
|
|
3047
|
+
if (risky.length) {
|
|
3048
|
+
console.log(c.red(` ${risky.length} worktree(s) hold uncommitted work on a branch that already landed.`));
|
|
3049
|
+
console.log(c.dim(" That is where work actually gets lost. Commit it somewhere or decide to drop it."));
|
|
3050
|
+
}
|
|
3051
|
+
if (prunable.length) console.log(c.dim(` ${prunable.length} prunable. Removing a worktree is your call, not this command's.`));
|
|
3052
|
+
console.log();
|
|
3053
|
+
return 0;
|
|
3054
|
+
}
|
|
2715
3055
|
function cmdCheck(root, tier, stamp) {
|
|
2716
3056
|
let runs;
|
|
2717
3057
|
try {
|
|
@@ -2782,7 +3122,7 @@ function cmdBacklogArchive(root, dryRun) {
|
|
|
2782
3122
|
const record = readRecord(root);
|
|
2783
3123
|
const configured = record?.modules["backlog"]?.params?.root;
|
|
2784
3124
|
const backlogRoot = `docs/${configured ?? "backlog"}`;
|
|
2785
|
-
if (!
|
|
3125
|
+
if (!existsSync11(join14(root, ...backlogRoot.split("/"), "items"))) {
|
|
2786
3126
|
console.log(c.red(`
|
|
2787
3127
|
no backlog at ${backlogRoot}/items
|
|
2788
3128
|
`));
|
|
@@ -2907,7 +3247,11 @@ var COMMANDS = [
|
|
|
2907
3247
|
["eject [path]", "materialise the engines; stop depending on rungs"],
|
|
2908
3248
|
["setup git [path]", "install the merge drivers .gitattributes names"],
|
|
2909
3249
|
["modules", "list the module set and audit the manifests"],
|
|
2910
|
-
["backlog archive [path]", "move finished items to archive/, repointing every link"]
|
|
3250
|
+
["backlog archive [path]", "move finished items to archive/, repointing every link"],
|
|
3251
|
+
["session start <branch>", "cut a branch and worktree from the last verified merge"],
|
|
3252
|
+
["preflight [path]", "did the integration branch change files you changed?"],
|
|
3253
|
+
["land <branch>", "merge \u2192 verify the merged tree \u2192 advance, or refuse and park it"],
|
|
3254
|
+
["worktrees [path]", "which worktrees are merged, prunable, or merged and still dirty"]
|
|
2911
3255
|
];
|
|
2912
3256
|
var FLAGS = [
|
|
2913
3257
|
["--dry-run", "report what would happen, write nothing"],
|
|
@@ -2986,22 +3330,29 @@ switch (cmd) {
|
|
|
2986
3330
|
unknown: rungs backlog ${args[0] ?? ""}`) + c.dim("\n The only subcommand is `archive`.\n"));
|
|
2987
3331
|
process.exit(1);
|
|
2988
3332
|
}
|
|
2989
|
-
process.exit(cmdBacklogArchive(
|
|
3333
|
+
process.exit(cmdBacklogArchive(resolve5(args[1] ?? process.cwd()), flags.has("--dry-run")));
|
|
2990
3334
|
}
|
|
2991
3335
|
case "check": {
|
|
2992
3336
|
const tier = args[1] ?? (flags.has("--full") ? "full" : flags.has("--fast") ? "fast" : void 0);
|
|
2993
|
-
process.exit(cmdCheck(
|
|
3337
|
+
process.exit(cmdCheck(resolve5(args[0] ?? process.cwd()), tier, STAMP));
|
|
2994
3338
|
}
|
|
2995
3339
|
case "init": {
|
|
2996
3340
|
const profile = args[1] ?? "tracked";
|
|
2997
|
-
process.exit(cmdInit(
|
|
3341
|
+
process.exit(cmdInit(resolve5(args[0] ?? process.cwd()), profile, flags.has("--dry-run"), HARNESSES, STAMP));
|
|
2998
3342
|
}
|
|
2999
3343
|
case "upgrade":
|
|
3000
|
-
process.exit(cmdUpgrade(
|
|
3344
|
+
process.exit(cmdUpgrade(resolve5(args[0] ?? process.cwd()), flags.has("--apply")));
|
|
3001
3345
|
case "eject":
|
|
3002
|
-
process.exit(cmdEject(
|
|
3346
|
+
process.exit(cmdEject(resolve5(args[0] ?? process.cwd()), flags.has("--dry-run")));
|
|
3003
3347
|
case "setup": {
|
|
3004
|
-
|
|
3348
|
+
if (args[0] !== "git") {
|
|
3349
|
+
console.log(
|
|
3350
|
+
c.red(`
|
|
3351
|
+
unknown: rungs setup ${args[0] ?? ""}`.trimEnd()) + c.dim("\n The only subcommand is `git`, and the path comes after it: `rungs setup git [path]`.\n")
|
|
3352
|
+
);
|
|
3353
|
+
process.exit(1);
|
|
3354
|
+
}
|
|
3355
|
+
const r = setupGit(resolve5(args[1] ?? process.cwd()), flags.has("--dry-run"));
|
|
3005
3356
|
console.log(
|
|
3006
3357
|
r.drivers.length ? `
|
|
3007
3358
|
installed ${r.drivers.length} merge driver(s): ${r.drivers.join(", ")}` + (r.rerere ? c.dim(" \xB7 rerere on") : "") + c.dim("\n Declared drivers were inert until now \u2014 a fresh clone needs this once.\n") : c.dim("\n no rungs merge drivers declared in .gitattributes\n")
|
|
@@ -3009,11 +3360,25 @@ switch (cmd) {
|
|
|
3009
3360
|
process.exit(0);
|
|
3010
3361
|
}
|
|
3011
3362
|
case "render":
|
|
3012
|
-
process.exit(cmdRender(
|
|
3363
|
+
process.exit(cmdRender(resolve5(args[0] ?? process.cwd()), HARNESSES, STAMP));
|
|
3364
|
+
case "session": {
|
|
3365
|
+
if (args[0] !== "start") {
|
|
3366
|
+
console.log(c.red(`
|
|
3367
|
+
unknown: rungs session ${args[0] ?? ""}`.trimEnd()) + c.dim("\n The only subcommand is `start`: `rungs session start <branch> [path]`.\n"));
|
|
3368
|
+
process.exit(1);
|
|
3369
|
+
}
|
|
3370
|
+
process.exit(report(sessionStart(process.cwd(), args[1], args[2], flags.has("--dry-run"))));
|
|
3371
|
+
}
|
|
3372
|
+
case "preflight":
|
|
3373
|
+
process.exit(report(preflight(resolve5(args[0] ?? process.cwd()))));
|
|
3374
|
+
case "land":
|
|
3375
|
+
process.exit(report(land(process.cwd(), args[0], landRunner, flags.has("--dry-run"))));
|
|
3376
|
+
case "worktrees":
|
|
3377
|
+
process.exit(cmdWorktrees(resolve5(args[0] ?? process.cwd())));
|
|
3013
3378
|
case "add": {
|
|
3014
3379
|
const target = flags.has("--into") ? args[args.length - 1] : process.cwd();
|
|
3015
3380
|
const names = flags.has("--into") ? args.slice(0, -1) : args;
|
|
3016
|
-
process.exit(cmdAdd(names,
|
|
3381
|
+
process.exit(cmdAdd(names, resolve5(target), flags.has("--dry-run"), HARNESSES, STAMP));
|
|
3017
3382
|
}
|
|
3018
3383
|
default: {
|
|
3019
3384
|
const wantedHelp = cmd === void 0 || cmd === "help" || cmd === "--help" || cmd === "-h";
|