forgedock 1.26.0 → 1.28.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 +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +41 -6
- package/bin/engine/telemetry-seam.test.mjs +35 -0
- package/bin/engine-cli.mjs +616 -54
- package/bin/engine.mjs +13 -5
- package/bin/forgedock.mjs +169 -39
- package/bin/gc.mjs +580 -0
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +24 -5
- package/bin/lean/batch.test.mjs +54 -4
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +235 -37
- package/bin/lean/conformance.test.mjs +143 -5
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +178 -16
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +877 -151
- package/bin/lean/lean.test.mjs +1798 -28
- package/bin/lean/ledger.mjs +92 -0
- package/bin/lean/ledger.test.mjs +100 -0
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +321 -19
- package/bin/lean/prompts.mjs +125 -17
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +25 -6
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +124 -0
- package/bin/lean/verify.test.mjs +118 -0
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +5 -8
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +125 -6
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/review-pr.md +2 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +227 -3
- package/docs/OUTCOME-CODES.md +11 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +321 -0
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +125 -0
- package/scripts/check-devdocs-freshness.test.sh +130 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +16 -5
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +14 -2
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +155 -0
- package/scripts/structure-delta.test.sh +136 -0
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { writeFileSync, mkdirSync, chmodSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { join, delimiter } from "node:path";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
+
import { detectPins, parsePackageManager, nodeSatisfies, nodeWarning, shimScripts, prepareToolchain, toolchainEnv, writeShims, summary } from "./toolchain.mjs";
|
|
10
|
+
|
|
11
|
+
const tmp = (p) => tmpDir(p);
|
|
12
|
+
const repo = (files) => {
|
|
13
|
+
const d = tmp("tc-repo-");
|
|
14
|
+
for (const [f, t] of Object.entries(files)) writeFileSync(join(d, f), typeof t === "string" ? t : JSON.stringify(t));
|
|
15
|
+
return d;
|
|
16
|
+
};
|
|
17
|
+
const exe = (dir, name, body) => { mkdirSync(dir, { recursive: true }); const p = join(dir, name); writeFileSync(p, `#!/bin/sh\n${body}\n`); chmodSync(p, 0o755); return p; };
|
|
18
|
+
/**
|
|
19
|
+
* A host whose PATH has pnpm 11.0.0 and a fake corepack/npx that print the requested version for `--version`
|
|
20
|
+
* (`corepack pnpm@X --version`, `npx -y -p pnpm@X pnpm --version`) without any network.
|
|
21
|
+
*/
|
|
22
|
+
const fakeHost = ({ corepack = true } = {}) => {
|
|
23
|
+
const d = tmp("tc-host-");
|
|
24
|
+
exe(d, "pnpm", 'echo 11.0.0');
|
|
25
|
+
const pick = 'for a in "$@"; do case "$a" in pnpm@*) v="${a#pnpm@}";; esac; done; for a in "$@"; do [ "$a" = "--version" ] && { echo "$v"; exit 0; }; done; exit 0';
|
|
26
|
+
if (corepack) exe(d, "corepack", pick);
|
|
27
|
+
exe(d, "npx", pick);
|
|
28
|
+
return d;
|
|
29
|
+
};
|
|
30
|
+
const sysPath = process.env.PATH ?? "";
|
|
31
|
+
|
|
32
|
+
describe("toolchain pins (#4163)", () => {
|
|
33
|
+
it("parsePackageManager accepts pm@x.y.z (with a +sha suffix) and nothing else", () => {
|
|
34
|
+
assert.deepEqual(parsePackageManager("pnpm@10.4.1+sha512.abc"), { name: "pnpm", version: "10.4.1" });
|
|
35
|
+
assert.deepEqual(parsePackageManager("yarn@4.1.0"), { name: "yarn", version: "4.1.0" });
|
|
36
|
+
for (const bad of ["pnpm@10", "bun@1.0.0", "pnpm@latest", "pnpm@1.0.0;rm -rf /", "", null]) assert.equal(parsePackageManager(bad), null, String(bad));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("detects packageManager and node pins in priority order", async () => {
|
|
40
|
+
const d = repo({ "package.json": { packageManager: "pnpm@10.4.1", engines: { node: ">=18" } }, ".nvmrc": "20.11.0\n", ".node-version": "22\n" });
|
|
41
|
+
const p = await detectPins(d);
|
|
42
|
+
assert.deepEqual(p.pm, { name: "pnpm", version: "10.4.1", source: "package.json packageManager" });
|
|
43
|
+
assert.deepEqual(p.node, { spec: "20.11.0", source: ".nvmrc" });
|
|
44
|
+
const e = await detectPins(repo({ "package.json": { engines: { node: "^20" } } }));
|
|
45
|
+
assert.deepEqual(e.node, { spec: "^20", source: "package.json engines.node" });
|
|
46
|
+
assert.equal(e.pm, null);
|
|
47
|
+
const tv = await detectPins(repo({ ".tool-versions": "# pins\nnodejs 20.1.0\npnpm 9.1.0\n" }));
|
|
48
|
+
assert.deepEqual(tv.pm, { name: "pnpm", version: "9.1.0", source: ".tool-versions" });
|
|
49
|
+
assert.deepEqual(tv.node, { spec: "20.1.0", source: ".tool-versions" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("forge.yaml env.toolchain overrides the pm pin; off disables detection; junk is a note", async () => {
|
|
53
|
+
const d = repo({ "package.json": { packageManager: "pnpm@10.4.1" }, ".nvmrc": "20" });
|
|
54
|
+
assert.deepEqual((await detectPins(d, { override: "pnpm@9.0.0" })).pm, { name: "pnpm", version: "9.0.0", source: "forge.yaml env.toolchain" });
|
|
55
|
+
const off = await detectPins(d, { override: "off" });
|
|
56
|
+
assert.equal(off.pm, null); assert.equal(off.node, null);
|
|
57
|
+
const junk = await detectPins(d, { override: "pnpm" });
|
|
58
|
+
assert.equal(junk.pm.version, "10.4.1");
|
|
59
|
+
assert.match(junk.notes[0], /env\.toolchain "pnpm" is not/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("nodeSatisfies covers exact, partial, x, caret, tilde, comparators and ||", () => {
|
|
63
|
+
const t = [["20", "v20.11.0", true], ["20", "v22.1.0", false], ["20.11", "v20.11.4", true], ["20.x", "v20.0.0", true], ["^20.5", "v20.4.0", false],
|
|
64
|
+
["^20.5", "v20.9.0", true], ["~20.5.1", "v20.6.0", false], [">=18", "v26.7.0", true], [">=18 <21", "v22.0.0", false], ["18 || 20", "v20.1.0", true],
|
|
65
|
+
["< 20", "v20.0.0", false], ["<=20", "v20.9.9", true], [">20", "v20.9.9", false], [">20", "v21.0.0", true], ["v20.11.0", "v20.11.0", true]];
|
|
66
|
+
for (const [spec, v, want] of t) assert.equal(nodeSatisfies(spec, v), want, `${spec} vs ${v}`);
|
|
67
|
+
assert.equal(nodeSatisfies("18 - 20", "v19.0.0"), true);
|
|
68
|
+
assert.equal(nodeSatisfies("18 - 20", "v21.0.0"), false);
|
|
69
|
+
assert.equal(nodeSatisfies("hydrogen", "v19.0.0"), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("an unsatisfied node pin yields a clear warning; a satisfied or alias pin yields none", () => {
|
|
73
|
+
assert.match(nodeWarning({ spec: "18.19.0", source: ".nvmrc" }, "v26.7.0"), /host node v26\.7\.0 does not satisfy the \.nvmrc pin "18\.19\.0"/);
|
|
74
|
+
assert.equal(nodeWarning({ spec: ">=18", source: "package.json engines.node" }, "v26.7.0"), null);
|
|
75
|
+
assert.equal(nodeWarning({ spec: "lts/iron", source: ".nvmrc" }, "v26.7.0"), null);
|
|
76
|
+
assert.match(nodeWarning({ spec: "iron-ish", source: ".nvmrc" }, "v26.7.0"), /cannot evaluate/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("shims: corepack first, npx fallback, yarn berry via @yarnpkg/cli-dist, values quoted", () => {
|
|
80
|
+
const pm = { name: "pnpm", version: "10.4.1" };
|
|
81
|
+
assert.match(shimScripts(pm, { corepack: "/h/corepack", npx: "/h/npx" }).pnpm, /exec '\/h\/corepack' 'pnpm@10\.4\.1' "\$@"/);
|
|
82
|
+
assert.match(shimScripts(pm, { corepack: "/h/corepack" }).pnpx, /'pnpm@10\.4\.1' dlx "\$@"/);
|
|
83
|
+
assert.match(shimScripts(pm, { npx: "/h/npx" }).pnpm, /exec '\/h\/npx' -y -p 'pnpm@10\.4\.1' pnpm "\$@"/);
|
|
84
|
+
assert.match(shimScripts({ name: "yarn", version: "4.1.0" }, { npx: "/h/npx" }).yarn, /-p '@yarnpkg\/cli-dist@4\.1\.0' yarn/);
|
|
85
|
+
assert.match(shimScripts({ name: "yarn", version: "1.22.19" }, { npx: "/h/npx" }).yarn, /-p 'yarn@1\.22\.19' yarn/);
|
|
86
|
+
const npm = shimScripts({ name: "npm", version: "10.2.0" }, { corepack: "/h/c", npx: "/h/npx" });
|
|
87
|
+
assert.deepEqual(Object.keys(npm), ["npm", "npx"]);
|
|
88
|
+
assert.match(npm.npx, /exec '\/h\/npx' -y -p 'npm@10\.2\.0' npx "\$@"/);
|
|
89
|
+
assert.deepEqual(shimScripts(pm, {}), {});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
for (const corepack of [true, false]) {
|
|
93
|
+
it(`R1: with packageManager pnpm@10.4.1 and pnpm 11 on PATH, runner/validate/hook shells resolve pnpm --version to 10.4.1 (${corepack ? "corepack" : "npx"})`, async () => {
|
|
94
|
+
const host = fakeHost({ corepack });
|
|
95
|
+
const basePath = `${host}${delimiter}${sysPath}`;
|
|
96
|
+
assert.equal(execFileSync("sh", ["-c", "pnpm --version"], { env: { ...process.env, PATH: basePath } }).toString().trim(), "11.0.0", "the host pnpm is the wrong one");
|
|
97
|
+
const d = repo({ "package.json": { name: "x", packageManager: "pnpm@10.4.1+sha512.ff" } });
|
|
98
|
+
const binDir = join(tmp("tc-run-"), "toolchain", "bin");
|
|
99
|
+
const tc = await prepareToolchain({ dir: d, binDir, basePath, hostNode: () => "v20.0.0" });
|
|
100
|
+
assert.ok(tc.env && tc.env.PATH.startsWith(binDir + delimiter), "shim dir first on PATH");
|
|
101
|
+
assert.equal(tc.summary, "pnpm@10.4.1 (package.json packageManager)");
|
|
102
|
+
const env = { ...process.env, ...tc.env };
|
|
103
|
+
// runner: the claude CLI child gets this env (bin/runner.mjs applies `env` last); its Bash tool shells inherit it.
|
|
104
|
+
assert.equal(execFileSync("bash", ["-lc", "pnpm --version"], { env, cwd: d }).toString().trim(), "10.4.1", "runner shell");
|
|
105
|
+
// validate: a validate command is a shell command in the worktree.
|
|
106
|
+
assert.equal(execFileSync("sh", ["-c", "cd . && pnpm --version"], { env, cwd: d }).toString().trim(), "10.4.1", "validate command");
|
|
107
|
+
// hooks: git runs pre-commit / commit-msg hooks with the committing shell's PATH.
|
|
108
|
+
const git = (a) => execFileSync("git", a, { env: { ...env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }, cwd: d }).toString();
|
|
109
|
+
git(["init", "-q"]);
|
|
110
|
+
exe(join(d, ".git", "hooks"), "pre-commit", `pnpm --version > "${join(d, "pre.out")}"`);
|
|
111
|
+
exe(join(d, ".git", "hooks"), "commit-msg", `pnpm --version > "${join(d, "msg.out")}"`);
|
|
112
|
+
git(["add", "package.json"]);
|
|
113
|
+
git(["commit", "-q", "-m", "x"]);
|
|
114
|
+
assert.equal(readFileSync(join(d, "pre.out"), "utf8").trim(), "10.4.1", "pre-commit hook");
|
|
115
|
+
assert.equal(readFileSync(join(d, "msg.out"), "utf8").trim(), "10.4.1", "commit-msg hook");
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it("R2: a node pin the host does not satisfy produces a warning (and no shim without a pm pin)", async () => {
|
|
120
|
+
const binDir = join(tmp("tc-run-"), "bin");
|
|
121
|
+
const tc = await prepareToolchain({ dir: repo({ ".nvmrc": "18.19.0" }), binDir, hostNode: () => "v26.7.0" });
|
|
122
|
+
assert.match(tc.warning, /^host node v26\.7\.0 does not satisfy the \.nvmrc pin "18\.19\.0"/);
|
|
123
|
+
assert.equal(tc.summary, "node 18.19.0 (.nvmrc)");
|
|
124
|
+
assert.equal(tc.env, null); assert.equal(existsSync(binDir), false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("R4: a repo without pins gets nothing: no env, no shim dir, no warning, no notes", async () => {
|
|
128
|
+
const binDir = join(tmp("tc-run-"), "bin");
|
|
129
|
+
for (const files of [{}, { "package.json": { name: "x", scripts: { test: "node --test" } } }]) {
|
|
130
|
+
const tc = await prepareToolchain({ dir: repo(files), binDir, hostNode: () => { throw new Error("must not probe node"); } });
|
|
131
|
+
assert.equal(tc.env, null); assert.equal(tc.summary, null); assert.equal(tc.warning, null);
|
|
132
|
+
assert.deepEqual(tc.shims, []); assert.deepEqual(tc.notes, []);
|
|
133
|
+
assert.equal(existsSync(binDir), false);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("toolchainEnv prepends the shim dir", () => {
|
|
138
|
+
assert.deepEqual(toolchainEnv("/s", "/a:/b"), { PATH: `/s${delimiter}/a:/b`, COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", COREPACK_ENABLE_STRICT: "0" });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("writeShims writes 0755 shims that never resolve the launcher from the shim dir; summary names the pins", async () => {
|
|
142
|
+
const host = fakeHost();
|
|
143
|
+
const binDir = join(tmp("tc-ws-"), "bin");
|
|
144
|
+
exe(binDir, "corepack", "echo self");
|
|
145
|
+
const names = await writeShims(binDir, { name: "pnpm", version: "10.4.1" }, { path: `${binDir}${delimiter}${host}` });
|
|
146
|
+
assert.deepEqual(names, ["pnpm", "pnpx"]);
|
|
147
|
+
for (const n of names) assert.equal(statSync(join(binDir, n)).mode & 0o777, 0o755);
|
|
148
|
+
assert.match(readFileSync(join(binDir, "pnpm"), "utf8"), new RegExp(`exec '${join(host, "corepack")}' 'pnpm@10\\.4\\.1'`));
|
|
149
|
+
assert.deepEqual(await writeShims(join(tmp("tc-ws-"), "bin"), { name: "yarn", version: "4.1.0" }, { path: "/nonexistent" }), []);
|
|
150
|
+
assert.equal(summary({ pm: { name: "pnpm", version: "10.4.1", source: "package.json packageManager" }, node: { spec: "20", source: ".nvmrc" } }), "pnpm@10.4.1 (package.json packageManager), node 20 (.nvmrc)");
|
|
151
|
+
assert.equal(summary({ pm: null, node: null }), null);
|
|
152
|
+
assert.equal(nodeWarning({ spec: "1", source: ".nvmrc" }) !== null, true, "defaults to process.version");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Verify-by-CI (#4167): run the lane's heavy verification on CI runners instead of the host. The engine pushes the
|
|
5
|
+
* branch, dispatches the configured workflow (or waits on the head commit's push-triggered checks), and returns the
|
|
6
|
+
* failed jobs' log tails for the builder's fix loop. All GitHub effects go through the injected io.
|
|
7
|
+
*/
|
|
8
|
+
import { ciPollDelayMs } from "../engine.mjs";
|
|
9
|
+
|
|
10
|
+
export const VERIFY_MODES = Object.freeze(["local", "ci", "hybrid"]);
|
|
11
|
+
/** Changed files that never need the CI matrix (docs-only lanes skip CI verification). */
|
|
12
|
+
export const DEFAULT_SKIP_PATHS = Object.freeze(["*.md", "*.mdx", "*.rst", "*.txt", "docs/**", "devdocs/**"]);
|
|
13
|
+
export const DEFAULT_MAX_ROUNDS = 2;
|
|
14
|
+
/** Per-command timeout of the pre-PR declared check commands (#4240, `verify.precheck_timeout_seconds`). */
|
|
15
|
+
export const DEFAULT_PRECHECK_TIMEOUT_SECONDS = 300;
|
|
16
|
+
/** Total characters of failed-job log handed to the builder. */
|
|
17
|
+
export const LOG_CAP = 12000;
|
|
18
|
+
/** No run or check for the head after this long means CI is unavailable (local fallback). */
|
|
19
|
+
export const DEFAULT_GRACE_MS = 5 * 60000;
|
|
20
|
+
const LEAN_PREFIX = ".forge-lean/";
|
|
21
|
+
const OK = ["success", "neutral", "skipped"];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* forge.yaml `verify:` section (block style; parseForgeYaml gives strings) → {mode, workflow, pathsInput, skipPaths,
|
|
25
|
+
* maxRounds, precheckTimeoutSeconds}. Invalid values fall back to the defaults; mode defaults to local (today's behaviour).
|
|
26
|
+
*/
|
|
27
|
+
export function parseVerify(sec = {}) {
|
|
28
|
+
const s = sec && typeof sec === "object" ? sec : {};
|
|
29
|
+
const str = (v) => (v === undefined || v === null ? "" : String(v).trim());
|
|
30
|
+
const mode = VERIFY_MODES.includes(str(s.mode)) ? str(s.mode) : "local";
|
|
31
|
+
const workflow = /^[A-Za-z0-9._/-]+$/.test(str(s.workflow)) ? str(s.workflow) : null;
|
|
32
|
+
const pathsInput = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(str(s.paths_input)) ? str(s.paths_input) : null;
|
|
33
|
+
const rawSkip = Array.isArray(s.skip_paths) ? s.skip_paths : str(s.skip_paths) ? str(s.skip_paths).split(",") : null;
|
|
34
|
+
const skipPaths = rawSkip ? rawSkip.map((x) => String(x).trim()).filter(Boolean) : [...DEFAULT_SKIP_PATHS];
|
|
35
|
+
const n = Number(str(s.max_rounds));
|
|
36
|
+
const maxRounds = str(s.max_rounds) !== "" && Number.isInteger(n) && n >= 0 && n <= 10 ? n : DEFAULT_MAX_ROUNDS;
|
|
37
|
+
const t = Number(str(s.precheck_timeout_seconds));
|
|
38
|
+
const precheckTimeoutSeconds = str(s.precheck_timeout_seconds) !== "" && Number.isInteger(t) && t >= 1 && t <= 3600 ? t : DEFAULT_PRECHECK_TIMEOUT_SECONDS;
|
|
39
|
+
return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
|
|
43
|
+
/** A glob without "/" matches the basename anywhere (gitignore style); one with "/" matches the whole path. */
|
|
44
|
+
export function matchesGlob(file, glob) {
|
|
45
|
+
const f = String(file), g = String(glob).replace(/^\.?\//, "");
|
|
46
|
+
return g.includes("/") ? globRe(g).test(f) : globRe(g).test(f.split("/").at(-1));
|
|
47
|
+
}
|
|
48
|
+
/** Changed files that CI must see: everything except the engine's own `.forge-lean/` hand-off files. */
|
|
49
|
+
export const verifiableFiles = (files) => (files ?? []).filter((f) => f && !String(f).startsWith(LEAN_PREFIX));
|
|
50
|
+
/** True when every verifiable changed file matches a skip glob (a docs-only lane), or there is none. */
|
|
51
|
+
export function skipsCi(files, skipPaths = DEFAULT_SKIP_PATHS) {
|
|
52
|
+
const fs = verifiableFiles(files);
|
|
53
|
+
return fs.every((f) => skipPaths.some((g) => matchesGlob(f, g)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Tail each log so the total stays under `cap` characters. */
|
|
57
|
+
export function capLogs(failures, cap = LOG_CAP) {
|
|
58
|
+
const per = Math.max(500, Math.floor(cap / Math.max(1, failures.length)));
|
|
59
|
+
return failures.map((f) => { const l = String(f.log ?? ""); return { ...f, log: l.length > per ? `…${l.slice(-per)}` : l }; });
|
|
60
|
+
}
|
|
61
|
+
const runIdOf = (url) => /\/actions\/runs\/(\d+)/.exec(String(url ?? ""))?.[1] ?? null;
|
|
62
|
+
/**
|
|
63
|
+
* Failed checks [{name, url}] → capped [{job, url, log}]: one `failedLogs` fetch per Actions run id, the log attached to
|
|
64
|
+
* the first check of that run only (#4240: shared by runCiVerify and the merge-time fix rounds).
|
|
65
|
+
*/
|
|
66
|
+
export async function collectFailedLogs(io, failedChecks) {
|
|
67
|
+
const logs = {}, out = [];
|
|
68
|
+
for (const c of failedChecks ?? []) {
|
|
69
|
+
const id = runIdOf(c.url);
|
|
70
|
+
if (id && !(id in logs)) { try { logs[id] = String((await io.failedLogs?.(id)) ?? ""); } catch { logs[id] = ""; } }
|
|
71
|
+
out.push({ job: c.name, url: c.url ?? null, log: id && !out.some((x) => runIdOf(x.url) === id) ? logs[id] : "" });
|
|
72
|
+
}
|
|
73
|
+
return capLogs(out);
|
|
74
|
+
}
|
|
75
|
+
const sameSha = (a, b) => Boolean(a && b) && (String(a).startsWith(b) || String(b).startsWith(a));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One CI verification of `head` on `branch`. Returns {result: pass|fail|timeout|unavailable|skipped, failures, runUrl,
|
|
79
|
+
* detail, runId}. `push` is called before any CI call (never for a skipped lane); `exclude` lists run ids already judged,
|
|
80
|
+
* so a re-dispatch on the same head never reuses an old run; `onPoll` runs before each wait (lease renew, progress).
|
|
81
|
+
*/
|
|
82
|
+
export async function runCiVerify({ io, branch, head, changedFiles, verify, ciMaxMinutes = 360, now = () => Date.now(), sleep, random = Math.random,
|
|
83
|
+
push = async () => {}, exclude = [], onPoll = async () => {}, graceMs = DEFAULT_GRACE_MS }) {
|
|
84
|
+
const v = verify ?? parseVerify({});
|
|
85
|
+
const files = verifiableFiles(changedFiles);
|
|
86
|
+
if (skipsCi(files, v.skipPaths)) return { result: "skipped", failures: [], runUrl: null, detail: `all ${files.length} changed file(s) match skip_paths` };
|
|
87
|
+
await push();
|
|
88
|
+
const startedAt = now();
|
|
89
|
+
const unavailable = (detail) => ({ result: "unavailable", failures: [], runUrl: null, detail });
|
|
90
|
+
if (v.workflow) {
|
|
91
|
+
if (!io.dispatchWorkflow || !io.workflowRuns) return unavailable("no workflow dispatch seam");
|
|
92
|
+
const inputs = v.pathsInput ? { [v.pathsInput]: files.join("\n") } : {};
|
|
93
|
+
try { await io.dispatchWorkflow({ workflow: v.workflow, ref: branch, inputs }); }
|
|
94
|
+
catch (e) { return unavailable(`workflow ${v.workflow} could not be dispatched: ${String(e?.stderr ?? e?.message ?? e).split("\n")[0].slice(0, 200)}`); }
|
|
95
|
+
} else if (!io.commitChecks) return unavailable("no commit-checks seam");
|
|
96
|
+
const skip = new Set(exclude.map(String));
|
|
97
|
+
for (let i = 0; ; i++) {
|
|
98
|
+
if (v.workflow) {
|
|
99
|
+
let runs = [];
|
|
100
|
+
try { runs = (await io.workflowRuns({ workflow: v.workflow, branch })) ?? []; } catch { runs = []; }
|
|
101
|
+
const run = runs.find((r) => sameSha(r?.headSha, head) && !skip.has(String(r?.databaseId)));
|
|
102
|
+
if (run && String(run.status).toLowerCase() === "completed") {
|
|
103
|
+
const c = String(run.conclusion ?? "").toLowerCase();
|
|
104
|
+
if (OK.includes(c)) return { result: "pass", failures: [], runUrl: run.url ?? null, runId: run.databaseId, detail: `${v.workflow} ${c}` };
|
|
105
|
+
let log = "";
|
|
106
|
+
try { log = String((await io.failedLogs?.(run.databaseId)) ?? ""); } catch { log = ""; }
|
|
107
|
+
return { result: "fail", failures: capLogs([{ job: run.name ?? v.workflow, url: run.url ?? null, log }]), runUrl: run.url ?? null, runId: run.databaseId, detail: `${v.workflow} ${c || "failed"}` };
|
|
108
|
+
}
|
|
109
|
+
if (!run && now() - startedAt >= graceMs) return unavailable(`no ${v.workflow} run for ${String(head).slice(0, 12)} appeared within ${Math.round(graceMs / 60000)} min`);
|
|
110
|
+
} else {
|
|
111
|
+
let checks = [];
|
|
112
|
+
try { checks = (await io.commitChecks(head)) ?? []; } catch { checks = []; }
|
|
113
|
+
if (!checks.length && now() - startedAt >= graceMs) return unavailable(`no checks reported for ${String(head).slice(0, 12)} within ${Math.round(graceMs / 60000)} min`);
|
|
114
|
+
if (checks.length && checks.every((c) => c.state !== "pending")) {
|
|
115
|
+
const failed = checks.filter((c) => c.state === "failure");
|
|
116
|
+
if (!failed.length) return { result: "pass", failures: [], runUrl: null, detail: `${checks.length} check(s) green` };
|
|
117
|
+
return { result: "fail", failures: await collectFailedLogs(io, failed), runUrl: failed[0].url ?? null, detail: `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(", ")}` };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (now() - startedAt >= ciMaxMinutes * 60000) return { result: "timeout", failures: [], runUrl: null, detail: `CI still pending after ${ciMaxMinutes} min` };
|
|
121
|
+
await onPoll();
|
|
122
|
+
await sleep(ciPollDelayMs(i, random));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, collectFailedLogs, DEFAULT_SKIP_PATHS, LOG_CAP } from "./verify.mjs";
|
|
6
|
+
import { precheck } from "./conformance.mjs";
|
|
7
|
+
|
|
8
|
+
const HEAD = "abc123abc123abc123";
|
|
9
|
+
/** Fake io + clock: `runs` is a list of successive workflowRuns answers (the last repeats); `checks` likewise. */
|
|
10
|
+
function fake(o = {}) {
|
|
11
|
+
const f = { clock: 0, dispatched: [], logsFor: [], pushes: 0, runsQ: [...(o.runs ?? [])], checksQ: [...(o.checks ?? [])] };
|
|
12
|
+
f.io = {
|
|
13
|
+
dispatchWorkflow: async (a) => { if (o.dispatchError) throw Object.assign(new Error("HTTP 422"), { stderr: o.dispatchError }); f.dispatched.push(a); },
|
|
14
|
+
workflowRuns: async () => (f.runsQ.length > 1 ? f.runsQ.shift() : f.runsQ[0] ?? []),
|
|
15
|
+
commitChecks: async () => (f.checksQ.length > 1 ? f.checksQ.shift() : f.checksQ[0] ?? []),
|
|
16
|
+
failedLogs: async (id) => { f.logsFor.push(String(id)); return o.log ?? `log of ${id}`; },
|
|
17
|
+
};
|
|
18
|
+
f.args = (extra = {}) => ({ io: f.io, branch: "feat/issue-7-lean", head: HEAD, changedFiles: ["src/a.ts", ".forge-lean/result.json"], verify: parseVerify({ mode: "ci", workflow: "verify.yml", paths_input: "changed_paths" }),
|
|
19
|
+
ciMaxMinutes: 30, now: () => f.clock, sleep: async (ms) => { f.clock += ms; }, random: () => 0.5, push: async () => { f.pushes++; }, ...extra });
|
|
20
|
+
return f;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("verify-by-CI config (#4167)", () => {
|
|
24
|
+
it("parseVerify defaults to local and rejects invalid values", () => {
|
|
25
|
+
assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300 });
|
|
26
|
+
const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
|
|
27
|
+
assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300 });
|
|
28
|
+
assert.equal(parseVerify({ mode: "remote" }).mode, "local");
|
|
29
|
+
assert.equal(parseVerify({ workflow: "x; rm -rf /" }).workflow, null);
|
|
30
|
+
assert.equal(parseVerify({ max_rounds: "-1" }).maxRounds, 2);
|
|
31
|
+
assert.equal(parseVerify({ max_rounds: "0" }).maxRounds, 0);
|
|
32
|
+
assert.equal(parseVerify({ mode: "hybrid" }).mode, "hybrid");
|
|
33
|
+
});
|
|
34
|
+
it("globs: a pattern without / matches the basename anywhere; ** spans directories", () => {
|
|
35
|
+
assert.ok(matchesGlob("README.md", "*.md")); assert.ok(matchesGlob("pkg/a/CHANGELOG.md", "*.md"));
|
|
36
|
+
assert.ok(matchesGlob("docs/guide/x.png", "docs/**")); assert.ok(!matchesGlob("src/docs/x.ts", "docs/**"));
|
|
37
|
+
assert.ok(!matchesGlob("src/a.ts", "*.md")); assert.ok(!matchesGlob("a.mdx.ts", "*.mdx"));
|
|
38
|
+
});
|
|
39
|
+
it("a docs-only change skips CI; one code file does not; .forge-lean files are ignored", () => {
|
|
40
|
+
assert.ok(skipsCi(["README.md", "docs/a.md", ".forge-lean/result.json"]));
|
|
41
|
+
assert.ok(!skipsCi(["README.md", "src/a.ts"]));
|
|
42
|
+
assert.ok(!skipsCi(["poetry.lock"]), "a lock file is never docs-only");
|
|
43
|
+
});
|
|
44
|
+
it("capLogs keeps the tails within the cap", () => {
|
|
45
|
+
const out = capLogs([{ job: "a", log: "x".repeat(50000) + "END-A" }, { job: "b", log: "short" }]);
|
|
46
|
+
assert.ok(out[0].log.endsWith("END-A")); assert.ok(out[0].log.length <= LOG_CAP / 2 + 1); assert.equal(out[1].log, "short");
|
|
47
|
+
});
|
|
48
|
+
it("precheck: a CI-verified head covers validate ids left to CI; a recorded failure still fails", () => {
|
|
49
|
+
const contract = { requirements: [], deliverables: [{ id: "D1", path: "src/a.ts", change: "x", requirements: [] }], validate: [{ id: "V1", command: "tsc" }, { id: "V2", command: "lint" }] };
|
|
50
|
+
const result = { validation: [{ id: "V1", command: "tsc", ci: true }, { id: "V2", command: "lint", exit: 0 }] };
|
|
51
|
+
const kinds = (r, ciVerified) => precheck({ contract, result: r, changedFiles: ["src/a.ts"], ciVerified }).failures.map((f) => f.kind);
|
|
52
|
+
assert.deepEqual(kinds(result, false), ["validate_missing"]);
|
|
53
|
+
assert.deepEqual(kinds(result, true), []);
|
|
54
|
+
assert.deepEqual(kinds({ validation: [] }, true), []);
|
|
55
|
+
assert.deepEqual(kinds({ validation: [{ id: "V1", exit: 0 }, { id: "V2", exit: 1 }] }, true), ["validate_failed"]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("runCiVerify (#4167)", () => {
|
|
60
|
+
it("docs-only: skipped without a push or any CI call", async () => {
|
|
61
|
+
const f = fake();
|
|
62
|
+
const r = await runCiVerify(f.args({ changedFiles: ["README.md", "docs/x.md"] }));
|
|
63
|
+
assert.equal(r.result, "skipped"); assert.equal(f.pushes, 0); assert.equal(f.dispatched.length, 0);
|
|
64
|
+
});
|
|
65
|
+
it("workflow: pushes, dispatches on the branch with the changed paths, waits for the run of this head, passes", async () => {
|
|
66
|
+
const f = fake({ runs: [[], [{ databaseId: 9, headSha: HEAD, status: "in_progress" }], [{ databaseId: 9, headSha: HEAD, status: "completed", conclusion: "success", url: "u9" }]] });
|
|
67
|
+
const r = await runCiVerify(f.args());
|
|
68
|
+
assert.equal(r.result, "pass"); assert.equal(r.runId, 9); assert.equal(f.pushes, 1);
|
|
69
|
+
assert.deepEqual(f.dispatched, [{ workflow: "verify.yml", ref: "feat/issue-7-lean", inputs: { changed_paths: "src/a.ts" } }]);
|
|
70
|
+
});
|
|
71
|
+
it("workflow: a red run returns the failed-job log; excluded (already judged) runs are never reused", async () => {
|
|
72
|
+
const f = fake({ runs: [[{ databaseId: 1, headSha: HEAD, status: "completed", conclusion: "failure" }, { databaseId: 2, headSha: HEAD, status: "completed", conclusion: "failure", name: "verify", url: "u2" }]], log: "poetry.lock is out of date" });
|
|
73
|
+
const r = await runCiVerify(f.args({ exclude: [1] }));
|
|
74
|
+
assert.equal(r.result, "fail"); assert.equal(r.runId, 2); assert.deepEqual(f.logsFor, ["2"]);
|
|
75
|
+
assert.deepEqual(r.failures, [{ job: "verify", url: "u2", log: "poetry.lock is out of date" }]);
|
|
76
|
+
});
|
|
77
|
+
it("workflow: a refused dispatch, or no run within the grace period, is unavailable (local fallback)", async () => {
|
|
78
|
+
assert.equal((await runCiVerify(fake({ dispatchError: "workflow does not have workflow_dispatch trigger" }).args())).result, "unavailable");
|
|
79
|
+
const r = await runCiVerify(fake({ runs: [[{ databaseId: 5, headSha: "other", status: "completed", conclusion: "success" }]] }).args());
|
|
80
|
+
assert.equal(r.result, "unavailable"); assert.match(r.detail, /no verify\.yml run/);
|
|
81
|
+
});
|
|
82
|
+
it("slow CI is a timeout at the budget, never a failure", async () => {
|
|
83
|
+
const f = fake({ runs: [[{ databaseId: 3, headSha: HEAD, status: "queued" }]] });
|
|
84
|
+
const r = await runCiVerify(f.args({ ciMaxMinutes: 10 }));
|
|
85
|
+
assert.equal(r.result, "timeout"); assert.ok(f.clock >= 10 * 60000);
|
|
86
|
+
});
|
|
87
|
+
it("no workflow: waits on the head's push-triggered checks and fetches each failed Actions run's log once", async () => {
|
|
88
|
+
const f = fake({ checks: [[{ name: "lint", state: "pending" }], [{ name: "lint", state: "success" }, { name: "test", state: "failure", url: "https://github.com/o/r/actions/runs/55/job/1" }, { name: "types", state: "failure", url: "https://github.com/o/r/actions/runs/55/job/2" }]] });
|
|
89
|
+
const r = await runCiVerify(f.args({ verify: parseVerify({ mode: "ci" }) }));
|
|
90
|
+
assert.equal(r.result, "fail"); assert.deepEqual(f.logsFor, ["55"]); assert.equal(f.dispatched.length, 0);
|
|
91
|
+
assert.deepEqual(r.failures.map((x) => [x.job, Boolean(x.log)]), [["test", true], ["types", false]]);
|
|
92
|
+
const g = fake({ checks: [[]] });
|
|
93
|
+
assert.equal((await runCiVerify(g.args({ verify: parseVerify({ mode: "ci" }) }))).result, "unavailable");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("merge-time fix rounds and declared check commands (#4240)", () => {
|
|
98
|
+
it("collectFailedLogs fetches one log per Actions run and caps the total", async () => {
|
|
99
|
+
const fetched = [];
|
|
100
|
+
const io = { failedLogs: async (id) => { fetched.push(id); return "x".repeat(LOG_CAP * 2); } };
|
|
101
|
+
const out = await collectFailedLogs(io, [
|
|
102
|
+
{ name: "Unit tests", url: "https://github.com/o/r/actions/runs/9/job/1" },
|
|
103
|
+
{ name: "Shell tests", url: "https://github.com/o/r/actions/runs/9/job/2" },
|
|
104
|
+
{ name: "status", url: null },
|
|
105
|
+
]);
|
|
106
|
+
assert.deepEqual(fetched, ["9"]);
|
|
107
|
+
assert.deepEqual(out.map((f) => f.job), ["Unit tests", "Shell tests", "status"]);
|
|
108
|
+
assert.ok(out[0].log.length > 0 && out[1].log === "" && out[2].log === "");
|
|
109
|
+
assert.ok(out.reduce((n, f) => n + f.log.length, 0) <= LOG_CAP + 3);
|
|
110
|
+
assert.deepEqual(await collectFailedLogs({ failedLogs: async () => { throw new Error("gone"); } }, [{ name: "a", url: "https://x/actions/runs/1" }]), [{ job: "a", url: "https://x/actions/runs/1", log: "" }]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("parseVerify precheck_timeout_seconds: default 300, integer 1-3600 accepted, others fall back", () => {
|
|
114
|
+
assert.equal(parseVerify({}).precheckTimeoutSeconds, 300);
|
|
115
|
+
assert.equal(parseVerify({ precheck_timeout_seconds: "60" }).precheckTimeoutSeconds, 60);
|
|
116
|
+
for (const bad of ["0", "-5", "1.5", "abc", "3601"]) assert.equal(parseVerify({ precheck_timeout_seconds: bad }).precheckTimeoutSeconds, 300, bad);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Sparse lane worktrees (#4221). A lane checks out every tracked file except regular blobs at or above
|
|
5
|
+
* `maxBlobBytes` (forge.yaml `env.sparse_max_kb`, default 1024; 0 = off). Lockfiles, manifests, toolchain pins and
|
|
6
|
+
* git/forge config are always checked out (the #4220 dep cache and #4163 toolchain pins read them). The patterns are
|
|
7
|
+
* non-cone (`/*` then `!/<path>`), kept in the worktree's own `info/sparse-checkout`; `core.sparseCheckout` is per-worktree
|
|
8
|
+
* config (git sets `extensions.worktreeConfig` in the shared config), so the main checkout is untouched. A repo with no
|
|
9
|
+
* such blob gets today's plain `git worktree add`. A failed sparse step falls back to a full checkout; never fails a lane.
|
|
10
|
+
*/
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_SPARSE_MAX_KB = 1024;
|
|
14
|
+
|
|
15
|
+
const KEEP_RE = /^(package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|yarn\.lock|\.yarnrc\.yml|uv\.lock|poetry\.lock|pyproject\.toml|requirements.*\.txt|setup\.py|setup\.cfg|\.nvmrc|\.node-version|\.tool-versions|tsconfig.*\.json|forge\.yaml|\.gitattributes|\.gitignore)$/;
|
|
16
|
+
|
|
17
|
+
/** Blobs to leave out, from `git ls-tree -r -l -z <ref>` output: {excludes, bytes}. Symlinks, gitlinks and kept files never. */
|
|
18
|
+
export function sparsePlan(lsTree, { maxBlobBytes = DEFAULT_SPARSE_MAX_KB * 1024 } = {}) {
|
|
19
|
+
const excludes = [];
|
|
20
|
+
let bytes = 0;
|
|
21
|
+
if (!(maxBlobBytes > 0)) return { excludes, bytes };
|
|
22
|
+
for (const rec of String(lsTree ?? "").split("\0")) {
|
|
23
|
+
const m = /^(\d{6}) blob [0-9a-f]+ +(\d+)\t([\s\S]+)$/.exec(rec.replace(/^\n+/, ""));
|
|
24
|
+
if (!m || !["100644", "100755"].includes(m[1])) continue;
|
|
25
|
+
const size = Number(m[2]), path = m[3];
|
|
26
|
+
if (size < maxBlobBytes || /[\n\r]/.test(path) || KEEP_RE.test(path.split("/").at(-1))) continue;
|
|
27
|
+
excludes.push(path);
|
|
28
|
+
bytes += size;
|
|
29
|
+
}
|
|
30
|
+
return { excludes, bytes };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** gitignore-syntax escape for a literal path: \ * ? [ anywhere, trailing spaces. */
|
|
34
|
+
const esc = (p) => p.replace(/[\\*?[]| (?= *$)/g, (c) => `\\${c}`);
|
|
35
|
+
|
|
36
|
+
/** Non-cone sparse patterns that include everything except `excludes`. */
|
|
37
|
+
export function sparsePatterns(excludes) {
|
|
38
|
+
return ["/*", ...excludes.map((p) => `!/${esc(p)}`)];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Add a lane worktree, sparse when the plan is non-empty. `git(args, {cwd})` is the injected io.git; `write(path, text)`
|
|
43
|
+
* writes the pattern file (argv-safe for any number of patterns). `addArgs` is the plain `["worktree","add",...]` argv.
|
|
44
|
+
* A failing `worktree add` is rethrown unchanged (the caller retries lock races). Returns {sparse, excluded, bytes, fallback?}.
|
|
45
|
+
*/
|
|
46
|
+
export async function addLaneWorktree(git, write, { cwd, wt, addArgs, ref, maxBlobBytes = DEFAULT_SPARSE_MAX_KB * 1024 }) {
|
|
47
|
+
let plan = { excludes: [], bytes: 0 };
|
|
48
|
+
if (maxBlobBytes > 0 && typeof write === "function") {
|
|
49
|
+
try { plan = sparsePlan(await git(["ls-tree", "-r", "-l", "-z", ref], { cwd }), { maxBlobBytes }); } catch { /* no plan: full checkout */ }
|
|
50
|
+
}
|
|
51
|
+
if (!plan.excludes.length) {
|
|
52
|
+
await git(addArgs, { cwd });
|
|
53
|
+
return { sparse: false, excluded: 0, bytes: 0 };
|
|
54
|
+
}
|
|
55
|
+
await git([...addArgs.slice(0, 2), "--no-checkout", ...addArgs.slice(2)], { cwd });
|
|
56
|
+
const g = (a) => git(a, { cwd: wt });
|
|
57
|
+
try {
|
|
58
|
+
await g(["sparse-checkout", "set", "--no-cone", "/*"]);
|
|
59
|
+
const file = String(await g(["rev-parse", "--git-path", "info/sparse-checkout"])).trim();
|
|
60
|
+
await write(file.startsWith("/") ? file : join(wt, file), `${sparsePatterns(plan.excludes).join("\n")}\n`);
|
|
61
|
+
await g(["read-tree", "-mu", "HEAD"]);
|
|
62
|
+
return { sparse: true, excluded: plan.excludes.length, bytes: plan.bytes };
|
|
63
|
+
} catch (e) {
|
|
64
|
+
const reason = String(e?.stderr || e?.message || e).trim().split("\n").slice(-2).join(" ").slice(0, 300);
|
|
65
|
+
try { await g(["sparse-checkout", "disable"]); } catch { /* full read-tree below */ }
|
|
66
|
+
await g(["read-tree", "-mu", "HEAD"]);
|
|
67
|
+
return { sparse: false, excluded: 0, bytes: 0, fallback: reason || "sparse checkout failed" };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { mkdirSync, writeFileSync, readdirSync, statSync, existsSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { sparsePlan, sparsePatterns, addLaneWorktree } from "./worktree.mjs";
|
|
10
|
+
|
|
11
|
+
const MiB = 1024 * 1024;
|
|
12
|
+
const gitSync = (args, cwd) => execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * MiB, stdio: ["ignore", "pipe", "pipe"] });
|
|
13
|
+
const git = async (args, { cwd } = {}) => gitSync(args, cwd);
|
|
14
|
+
const write = async (p, t) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, t); };
|
|
15
|
+
const blob = (n, ch = "x") => ch.repeat(n);
|
|
16
|
+
|
|
17
|
+
/** A fixture repo: {path: content}. Returns its dir. */
|
|
18
|
+
function repo(files) {
|
|
19
|
+
const dir = tmpDir("wt4221-");
|
|
20
|
+
const r = join(dir, "r");
|
|
21
|
+
mkdirSync(r);
|
|
22
|
+
gitSync(["init", "-q", "-b", "main"], r);
|
|
23
|
+
for (const [p, c] of Object.entries(files)) { mkdirSync(dirname(join(r, p)), { recursive: true }); writeFileSync(join(r, p), c); }
|
|
24
|
+
gitSync(["add", "-A"], r);
|
|
25
|
+
gitSync(["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "init"], r);
|
|
26
|
+
return { dir, r, wt: join(dir, "wt") };
|
|
27
|
+
}
|
|
28
|
+
/** Bytes of regular files on disk under `d`, skipping .git. */
|
|
29
|
+
function diskBytes(d) {
|
|
30
|
+
let n = 0;
|
|
31
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
32
|
+
if (e.name === ".git") continue;
|
|
33
|
+
const p = join(d, e.name);
|
|
34
|
+
if (e.isDirectory()) n += diskBytes(p); else if (e.isFile()) n += statSync(p).size;
|
|
35
|
+
}
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const FIXTURES = {
|
|
40
|
+
"npm JS/TS": {
|
|
41
|
+
files: { "package.json": '{"name":"a","version":"1.0.0"}\n', "package-lock.json": `{"lockfileVersion":3,"pad":"${blob(MiB + 10)}"}\n`,
|
|
42
|
+
"tsconfig.json": "{}\n", "src/index.ts": "export const x = 1;\n", "public/asset.json": blob(2 * MiB) },
|
|
43
|
+
big: "public/asset.json", kept: ["package.json", "package-lock.json", "src/index.ts", "tsconfig.json"],
|
|
44
|
+
},
|
|
45
|
+
"uv Python": {
|
|
46
|
+
files: { "pyproject.toml": '[project]\nname = "a"\nversion = "0.1.0"\n', "uv.lock": `version = 1\n# ${blob(MiB + 10)}\n`,
|
|
47
|
+
"app/main.py": "print(1)\n", "data/blob.bin": blob(2 * MiB, "\u0001") },
|
|
48
|
+
big: "data/blob.bin", kept: ["pyproject.toml", "uv.lock", "app/main.py"],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe("sparse lane worktrees (#4221)", () => {
|
|
53
|
+
for (const [name, fx] of Object.entries(FIXTURES)) {
|
|
54
|
+
it(`${name}: large blobs stay out, lockfiles and manifests stay in, git sees no change`, async () => {
|
|
55
|
+
const { dir, r, wt } = repo(fx.files);
|
|
56
|
+
try {
|
|
57
|
+
const res = await addLaneWorktree(git, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "feat/issue-1-lean", wt, "main"], ref: "main", maxBlobBytes: MiB });
|
|
58
|
+
assert.deepEqual(res, { sparse: true, excluded: 1, bytes: 2 * MiB });
|
|
59
|
+
assert.equal(existsSync(join(wt, fx.big)), false);
|
|
60
|
+
assert.match(gitSync(["ls-files", "-t", fx.big], wt), /^S /);
|
|
61
|
+
for (const k of fx.kept) assert.ok(existsSync(join(wt, k)), `${k} checked out`);
|
|
62
|
+
assert.equal(gitSync(["status", "--porcelain"], wt), "");
|
|
63
|
+
gitSync(["add", "-A"], wt);
|
|
64
|
+
assert.equal(gitSync(["diff", "--cached", "--name-only"], wt), "", "git add -A stages no deletion");
|
|
65
|
+
// R5: on-disk checkout is no more than the included blobs (+1 MiB slack): the left-out blob costs nothing.
|
|
66
|
+
const included = Object.entries(fx.files).filter(([p]) => p !== fx.big).reduce((n, [, c]) => n + Buffer.byteLength(c), 0);
|
|
67
|
+
assert.ok(diskBytes(wt) <= included + MiB, `disk ${diskBytes(wt)} <= ${included + MiB}`);
|
|
68
|
+
assert.throws(() => gitSync(["config", "--get", "core.sparseCheckout"], r), "main checkout has no core.sparseCheckout");
|
|
69
|
+
assert.equal(Object.keys(fx.files).every((p) => existsSync(join(r, p))), true);
|
|
70
|
+
mkdirSync(dirname(join(wt, fx.big)), { recursive: true });
|
|
71
|
+
writeFileSync(join(wt, fx.big), "regenerated\n");
|
|
72
|
+
assert.match(gitSync(["status", "--porcelain"], wt), new RegExp(` M ${fx.big}`));
|
|
73
|
+
gitSync(["checkout", "--", fx.big], wt);
|
|
74
|
+
gitSync(["sparse-checkout", "add", `/${fx.big}`], wt);
|
|
75
|
+
assert.equal(statSync(join(wt, fx.big)).size, 2 * MiB);
|
|
76
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it("a repo with no large blob gets today's plain worktree add", async () => {
|
|
81
|
+
const { dir, r, wt } = repo({ "package.json": "{}\n", "src/a.js": "1\n" });
|
|
82
|
+
try {
|
|
83
|
+
const calls = [];
|
|
84
|
+
const spy = async (a, o) => { calls.push(a); return git(a, o); };
|
|
85
|
+
const addArgs = ["worktree", "add", "-B", "feat/issue-1-lean", wt, "main"];
|
|
86
|
+
assert.deepEqual(await addLaneWorktree(spy, write, { cwd: r, wt, addArgs, ref: "main" }), { sparse: false, excluded: 0, bytes: 0 });
|
|
87
|
+
assert.deepEqual(calls.filter((a) => a[0] === "worktree"), [addArgs]);
|
|
88
|
+
assert.equal(calls.some((a) => a[0] === "sparse-checkout"), false);
|
|
89
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("maxBlobBytes 0 disables it; a failing sparse step falls back to a full checkout", async () => {
|
|
93
|
+
const { dir, r, wt } = repo({ "a.bin": blob(2 * MiB), "b.txt": "1\n" });
|
|
94
|
+
try {
|
|
95
|
+
const calls = [];
|
|
96
|
+
const spy = async (a, o) => { calls.push(a); return git(a, o); };
|
|
97
|
+
await addLaneWorktree(spy, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "lane", wt, "main"], ref: "main", maxBlobBytes: 0 });
|
|
98
|
+
assert.equal(calls.some((a) => a[0] === "ls-tree" || a.includes("--no-checkout")), false);
|
|
99
|
+
assert.ok(existsSync(join(wt, "a.bin")));
|
|
100
|
+
gitSync(["worktree", "remove", "--force", wt], r);
|
|
101
|
+
const failing = async (a, o) => { if (a[0] === "read-tree" && !failing.done) { failing.done = true; throw Object.assign(new Error("boom"), { stderr: "fatal: boom" }); } return git(a, o); };
|
|
102
|
+
const res = await addLaneWorktree(failing, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "lane", wt, "main"], ref: "main", maxBlobBytes: MiB });
|
|
103
|
+
assert.equal(res.sparse, false);
|
|
104
|
+
assert.match(res.fallback, /boom/);
|
|
105
|
+
assert.ok(existsSync(join(wt, "a.bin")) && existsSync(join(wt, "b.txt")));
|
|
106
|
+
assert.equal(gitSync(["status", "--porcelain"], wt), "");
|
|
107
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("a failing worktree add is rethrown unchanged (the caller retries lock races)", async () => {
|
|
111
|
+
const e = Object.assign(new Error("lock"), { stderr: "could not lock config file" });
|
|
112
|
+
const g = async (a) => { if (a[0] === "worktree") throw e; return "100644 blob " + "a".repeat(40) + " 2000000\tbig.bin\0"; };
|
|
113
|
+
await assert.rejects(addLaneWorktree(g, write, { cwd: "/x", wt: "/x/wt", addArgs: ["worktree", "add", "/x/wt", "main"], ref: "main" }), (x) => x === e);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("sparsePlan: threshold, keep-list, symlinks and gitlinks; patterns escape glob characters", () => {
|
|
117
|
+
const h = "a".repeat(40);
|
|
118
|
+
const out = [
|
|
119
|
+
`100644 blob ${h} 2000000\tweb/openapi.json`, `100755 blob ${h} 1048576\tbin/tool`, `100644 blob ${h} 1048575\tsmall.txt`,
|
|
120
|
+
`120000 blob ${h} 40\tlink`, `160000 commit ${h} -\tsub`, `100644 blob ${h} 3000000\tpnpm-lock.yaml`,
|
|
121
|
+
`100644 blob ${h} 3000000\tsvc/requirements-dev.txt`, `100644 blob ${h} 3000000\tpkg/tsconfig.base.json`, `100644 blob ${h} 5000000\ta [x]*?.bin `,
|
|
122
|
+
].join("\0") + "\0";
|
|
123
|
+
const p = sparsePlan(out, { maxBlobBytes: MiB });
|
|
124
|
+
assert.deepEqual(p.excludes, ["web/openapi.json", "bin/tool", "a [x]*?.bin "]);
|
|
125
|
+
assert.equal(p.bytes, 2000000 + 1048576 + 5000000);
|
|
126
|
+
assert.deepEqual(sparsePlan(out, { maxBlobBytes: 0 }).excludes, []);
|
|
127
|
+
assert.deepEqual(sparsePatterns(p.excludes), ["/*", "!/web/openapi.json", "!/bin/tool", "!/a \\[x]\\*\\?.bin\\ "]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("an escaped pattern really matches its odd path", async () => {
|
|
131
|
+
const odd = "d/a [x]*?.bin";
|
|
132
|
+
const { dir, r, wt } = repo({ [odd]: blob(2 * MiB), "d/a x.bin": "keep\n", "f.txt": "1\n" });
|
|
133
|
+
try {
|
|
134
|
+
const res = await addLaneWorktree(git, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "lane", wt, "main"], ref: "main", maxBlobBytes: MiB });
|
|
135
|
+
assert.equal(res.sparse, true);
|
|
136
|
+
assert.equal(existsSync(join(wt, odd)), false);
|
|
137
|
+
assert.ok(existsSync(join(wt, "d/a x.bin")));
|
|
138
|
+
assert.equal(gitSync(["status", "--porcelain"], wt), "");
|
|
139
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
140
|
+
});
|
|
141
|
+
});
|