@bluewombat/slots 0.10.0 → 0.11.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.
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, readFileSync, rmSync } from "node:fs";
4
+ import { isAbsolute, resolve } from "node:path";
5
+ import {
6
+ emitVerdict,
7
+ GATE_FLAGS,
8
+ ownArgv,
9
+ parseOptions,
10
+ take,
11
+ textOption,
12
+ } from "@bluewombat/slot-kit";
13
+ import { parseJunit, scenarioOf } from "../lib/junit.mjs";
14
+ import { createOutputTail } from "../lib/output-tail.mjs";
15
+
16
+ // The tests of a Feature, run on the assembled Feature line: does the Feature do what the
17
+ // operator agreed it should? Only the scenarios of this Feature run, not the Project's whole suite.
18
+ //
19
+ // Nothing here knows a stack. The Project supplies one command, run in the workspace through the
20
+ // shell, and the Gate owns the rest: running, comparing.
21
+ //
22
+ // --run COMMAND (required): runs the tests of the Feature and writes a JUnit XML report. `{ids}`
23
+ // stands for the scenario ids of the manifest joined with "|" (a regular expression:
24
+ // `pnpm test --grep "{ids}"`), `{junit}` for the path the report must be written to.
25
+ // --manifest PATH: the scenarios of the Feature. Default feature-tests/scenarios.json: a JSON
26
+ // array of ids, or { "scenarios": [ "S1" | { "id": "S1", "title": "…" } ] }.
27
+ // --junit PATH: where --run writes its report. Default feature-tests/results/junit.xml. Removed
28
+ // before the run.
29
+ // --max-run-ms N: the most the whole Gate may take. Default 600000 (10 minutes), and never more
30
+ // than the Gate's own ceiling.
31
+ //
32
+ // A scenario is a manifest id; each test carries its id in its name. The comparison is exact: an
33
+ // id with no test, a test with no id, or a test the runner skipped is a failure.
34
+ //
35
+ // Verdicts: pass; fail-retryable for a red run (a wrong test is its author's to fix, a wrong
36
+ // application the producer's: what the caller does with the report is its own rule); fail-blocking for what no change to the application fixes: a missing manifest,
37
+ // a run that writes no report or outlives its ceiling.
38
+
39
+ const cwd = process.cwd();
40
+ const KEEP_BYTES = 12_000;
41
+ const MARGIN_MS = 5_000;
42
+ const MIN_PHASE_MS = 1_000;
43
+ const REPORT_CHARS = 8_000;
44
+
45
+ const parsed = parseOptions(
46
+ ownArgv(process.argv, GATE_FLAGS),
47
+ {
48
+ "--run": {},
49
+ "--manifest": { fallback: "feature-tests/scenarios.json" },
50
+ "--junit": { fallback: "feature-tests/results/junit.xml" },
51
+ "--max-run-ms": { fallback: "600000" },
52
+ },
53
+ "feature-tests",
54
+ );
55
+ if (!parsed.ok) {
56
+ emitVerdict("fail-blocking", parsed.reason);
57
+ process.exit(0);
58
+ }
59
+ const runTemplate = textOption(parsed.values, "run");
60
+ if (runTemplate === undefined || runTemplate.trim().length === 0) {
61
+ emitVerdict(
62
+ "fail-blocking",
63
+ 'feature-tests needs --run "<command>". The Project supplies the command that runs the tests.',
64
+ );
65
+ process.exit(0);
66
+ }
67
+ const maxRunMs = positive(textOption(parsed.values, "maxRunMs"));
68
+ if (maxRunMs === undefined) {
69
+ emitVerdict("fail-blocking", "feature-tests: --max-run-ms takes a positive integer.");
70
+ process.exit(0);
71
+ }
72
+ const manifestPath = inWorkspace(textOption(parsed.values, "manifest") ?? "");
73
+ const junitPath = inWorkspace(textOption(parsed.values, "junit") ?? "");
74
+
75
+ const manifest = readManifest();
76
+ if (typeof manifest === "string") {
77
+ emitVerdict("fail-blocking", manifest);
78
+ process.exit(0);
79
+ }
80
+ // Typed apart from the early exit above: the functions below are hoisted, and a narrowing does not reach them.
81
+ /** @type {string[]} */
82
+ const ids = manifest;
83
+
84
+ const ceilingMs = Number(take(process.argv, "--timeout-ms"));
85
+ const budgetMs =
86
+ Number.isFinite(ceilingMs) && ceilingMs > 0
87
+ ? Math.min(maxRunMs, Math.max(MIN_PHASE_MS, ceilingMs - MARGIN_MS))
88
+ : maxRunMs;
89
+
90
+ const [verdict, report] = await judge();
91
+ emitVerdict(/** @type {"pass" | "fail-retryable" | "fail-blocking"} */ (verdict), report);
92
+ process.exit(0);
93
+
94
+ /** @returns {Promise<[string, string]>} */
95
+ async function judge() {
96
+ rmSync(junitPath, { force: true });
97
+ const command = runTemplate?.replaceAll("{ids}", ids.join("|")).replaceAll("{junit}", junitPath);
98
+ const run = await runShell(/** @type {string} */ (command), budgetMs);
99
+ if (run.timedOut) {
100
+ return ["fail-blocking", timeoutReport(run.output)];
101
+ }
102
+ const results = readResults();
103
+ if (typeof results === "string") {
104
+ return run.code === 0
105
+ ? ["fail-blocking", results]
106
+ : ["fail-retryable", `${results}\n${exitLine(run)} End of its output:\n${run.output}`];
107
+ }
108
+ const failing = failingOf(results);
109
+ if (failing.length > 0) {
110
+ return ["fail-retryable", redReport(results, failing)];
111
+ }
112
+ const foreign = results.cases.filter((c) => scenarioOf(c.name, ids) === undefined);
113
+ if (foreign.length > 0) {
114
+ return [
115
+ "fail-retryable",
116
+ [
117
+ `feature-tests: ${foreign.length} test(s) carry no scenario id of the manifest (${ids.join(", ")}): only the scenarios of the Feature run.`,
118
+ ...foreign.map((c) => ` ${c.name}`),
119
+ "Every test names its scenario id, and --run must run the ids of the manifest only ({ids}).",
120
+ ].join("\n"),
121
+ ];
122
+ }
123
+ return [
124
+ "pass",
125
+ `feature-tests: ${ids.length} scenario(s) of the feature ran and passed: ${ids.join(", ")}.`,
126
+ ];
127
+ }
128
+
129
+ /**
130
+ * @typedef {{ cases: import("../lib/junit.mjs").TestCase[] }} Result
131
+ */
132
+
133
+ /** @returns {Result | string} */
134
+ function readResults() {
135
+ if (!existsSync(junitPath)) {
136
+ return `The run wrote no JUnit report at ${junitPath}. --run must write one there ({junit}).`;
137
+ }
138
+ const cases = parseJunit(readFileSync(junitPath, "utf8"));
139
+ if (cases.length === 0) {
140
+ return `The JUnit report at ${junitPath} holds no test case.`;
141
+ }
142
+ return { cases };
143
+ }
144
+
145
+ /** The scenario ids that are not a pass: failed, skipped, or never run. */
146
+ function failingOf(/** @type {Result} */ results) {
147
+ return ids.filter((id) => {
148
+ const mine = results.cases.filter((c) => scenarioOf(c.name, ids) === id);
149
+ return mine.length === 0 || mine.some((c) => c.status !== "passed");
150
+ });
151
+ }
152
+
153
+ function redReport(/** @type {Result} */ results, /** @type {string[]} */ failing) {
154
+ const lines = [
155
+ `feature-tests: ${failing.length} of ${ids.length} scenario(s) failed on the assembled feature: ${failing.join(", ")}.`,
156
+ ];
157
+ for (const id of failing) {
158
+ const mine = results.cases.filter((c) => scenarioOf(c.name, ids) === id);
159
+ if (mine.length === 0) {
160
+ lines.push(`${id} — no test ran for it.`);
161
+ continue;
162
+ }
163
+ for (const c of mine.filter((m) => m.status !== "passed")) {
164
+ lines.push(`${id} — ${c.name}`);
165
+ if (c.status === "skipped") {
166
+ lines.push(" skipped: a skipped scenario is not a passing one.");
167
+ continue;
168
+ }
169
+ lines.push(` at: ${firstFrame(c.detail) ?? "(no step given)"}`);
170
+ lines.push(` message: ${c.message.slice(0, 600)}`);
171
+ }
172
+ }
173
+ lines.push(`Report: ${junitPath}.`);
174
+ const text = lines.join("\n");
175
+ return text.length <= REPORT_CHARS ? text : `${text.slice(0, REPORT_CHARS)}\n…`;
176
+ }
177
+
178
+ /** The first stack line that is the test's own, where a step failed. */
179
+ function firstFrame(/** @type {string} */ detail) {
180
+ return detail
181
+ .split("\n")
182
+ .map((line) => line.trim())
183
+ .find((line) => /^at\s|\.(spec|test)\.[cm]?[jt]sx?:\d+/.test(line));
184
+ }
185
+
186
+ /**
187
+ * @param {string} command
188
+ * @param {number} limitMs
189
+ * @returns {Promise<{ code: number | null, signal: string | null, timedOut: boolean, output: string }>}
190
+ */
191
+ function runShell(command, limitMs) {
192
+ return new Promise((done) => {
193
+ const child = spawn("sh", ["-c", command], {
194
+ cwd,
195
+ env: { ...process.env, CI: "true", NO_COLOR: "1", FORCE_COLOR: "0" },
196
+ detached: true,
197
+ stdio: ["ignore", "pipe", "pipe"],
198
+ });
199
+ const tail = createOutputTail(KEEP_BYTES);
200
+ child.stdout.on("data", (chunk) => tail.push("stdout", chunk));
201
+ child.stderr.on("data", (chunk) => tail.push("stderr", chunk));
202
+ let timedOut = false;
203
+ const timer = globalThis.setTimeout(
204
+ () => {
205
+ timedOut = true;
206
+ killGroup(child);
207
+ },
208
+ Math.max(MIN_PHASE_MS, limitMs),
209
+ );
210
+ child.on("error", (error) => {
211
+ clearTimeout(timer);
212
+ done({ code: 127, signal: null, timedOut: false, output: `cannot start: ${error.message}` });
213
+ });
214
+ child.on("close", (code, signal) => {
215
+ clearTimeout(timer);
216
+ done({ code, signal, timedOut, output: tail.text() });
217
+ });
218
+ });
219
+ }
220
+
221
+ /** @param {import("node:child_process").ChildProcess} child */
222
+ function killGroup(child) {
223
+ try {
224
+ if (child.pid !== undefined) {
225
+ process.kill(-child.pid, "SIGKILL");
226
+ }
227
+ } catch {
228
+ child.kill("SIGKILL");
229
+ }
230
+ }
231
+
232
+ function timeoutReport(/** @type {string} */ output) {
233
+ return [
234
+ `The run did not finish within the ${Math.round(budgetMs / 1000)}s this Gate may take, and was stopped.`,
235
+ "A person decides: the tests hang.",
236
+ output.length > 0 ? `Last output:\n${output}` : "It printed nothing.",
237
+ ].join("\n");
238
+ }
239
+
240
+ function exitLine(/** @type {{ code: number | null, signal: string | null }} */ run) {
241
+ return run.signal === null ? `The run exited ${run.code}.` : `The run got ${run.signal}.`;
242
+ }
243
+
244
+ /** @returns {string[] | string} The ids, or why the manifest is not usable. */
245
+ function readManifest() {
246
+ if (!existsSync(manifestPath)) {
247
+ return `The scenarios manifest ${manifestPath} does not exist. The feature-test Ticket writes it: one id per scenario.`;
248
+ }
249
+ let raw;
250
+ try {
251
+ raw = JSON.parse(readFileSync(manifestPath, "utf8"));
252
+ } catch (error) {
253
+ return `The scenarios manifest ${manifestPath} is not JSON: ${error instanceof Error ? error.message : error}`;
254
+ }
255
+ const list = Array.isArray(raw) ? raw : raw?.scenarios;
256
+ if (!Array.isArray(list)) {
257
+ return `The scenarios manifest ${manifestPath} must be an array of ids, or { "scenarios": [...] }.`;
258
+ }
259
+ const found = list.map((entry) => (typeof entry === "string" ? entry : entry?.id));
260
+ if (found.some((id) => typeof id !== "string" || id.trim().length === 0)) {
261
+ return `Every scenario of ${manifestPath} needs a non-empty id.`;
262
+ }
263
+ const unique = [...new Set(found.map((id) => id.trim()))];
264
+ if (unique.length !== found.length) {
265
+ return `The scenarios manifest ${manifestPath} lists an id twice.`;
266
+ }
267
+ if (unique.length === 0) {
268
+ return `The scenarios manifest ${manifestPath} lists no scenario: there is nothing to prove.`;
269
+ }
270
+ return unique;
271
+ }
272
+
273
+ function positive(/** @type {string | undefined} */ text) {
274
+ const value = Number(text);
275
+ return Number.isInteger(value) && value > 0 ? value : undefined;
276
+ }
277
+
278
+ function inWorkspace(/** @type {string} */ path) {
279
+ return isAbsolute(path) ? path : resolve(cwd, path);
280
+ }
@@ -5,11 +5,17 @@ import { join, relative, resolve } from "node:path";
5
5
  import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
6
6
 
7
7
  // Parent working files must not change during the Attempt.
8
- // --parent DIR (repeatable). Other git worktrees of this Child are included.
8
+ // --parent DIR (repeatable): always watched.
9
+ // The main worktree of this Child's repository is watched too. Its other linked
10
+ // worktrees are sibling Children (parallel tickets, escalated leftovers): they are
11
+ // legitimately dirty, so they are ignored unless --siblings is given.
12
+ // A watched git tree only counts files modified after this Child was created, so
13
+ // work that was already there (a human's uncommitted edit) is not a leak.
9
14
 
10
15
  const cwd = resolve(process.cwd());
11
16
  const tokens = ownArgv(process.argv, GATE_FLAGS);
12
17
  const named = [];
18
+ let withSiblings = false;
13
19
 
14
20
  for (let i = 0; i < tokens.length; i++) {
15
21
  const token = tokens[i];
@@ -23,12 +29,19 @@ for (let i = 0; i < tokens.length; i++) {
23
29
  i += 1;
24
30
  continue;
25
31
  }
26
- emitVerdict("fail-blocking", `parent-clean does not take "${token}". Use --parent <dir>.`);
32
+ if (token === "--siblings") {
33
+ withSiblings = true;
34
+ continue;
35
+ }
36
+ emitVerdict(
37
+ "fail-blocking",
38
+ `parent-clean does not take "${token}". Use --parent <dir> or --siblings.`,
39
+ );
27
40
  process.exit(0);
28
41
  }
29
42
 
30
43
  const parents = [...named];
31
- for (const worktree of otherWorktrees(cwd)) {
44
+ for (const worktree of otherWorktrees(cwd, withSiblings)) {
32
45
  if (!parents.includes(worktree)) {
33
46
  parents.push(worktree);
34
47
  }
@@ -67,15 +80,22 @@ if (leaked.length > 0) {
67
80
 
68
81
  emitVerdict("pass");
69
82
 
70
- /** Other worktrees of this repository, minus the ones git has marked prunable. */
71
- /** @param {string} workspace @returns {string[]} */
72
- function otherWorktrees(workspace) {
83
+ /**
84
+ * The main worktree of this repository (git lists it first), plus the other linked
85
+ * worktrees when `siblings` is set. Worktrees git has marked prunable are left out.
86
+ */
87
+ /** @param {string} workspace @param {boolean} siblings @returns {string[]} */
88
+ function otherWorktrees(workspace, siblings) {
73
89
  const result = git(workspace, ["worktree", "list", "--porcelain"]);
74
90
  if (result.status !== 0) {
75
91
  return [];
76
92
  }
77
93
  const trees = [];
78
- for (const record of result.stdout.split("\n\n")) {
94
+ const records = result.stdout.split("\n\n");
95
+ for (const [index, record] of records.entries()) {
96
+ if (index > 0 && !siblings) {
97
+ break;
98
+ }
79
99
  const lines = record.split("\n");
80
100
  const entry = lines.find((line) => line.startsWith("worktree "));
81
101
  if (entry === undefined || lines.some((line) => line.startsWith("prunable"))) {
@@ -100,10 +120,12 @@ function leaksIn(parent, workspace) {
100
120
  if (status.status !== 0) {
101
121
  return [`${parent}: git status failed`];
102
122
  }
123
+ const referenceMs = referenceTime(workspace);
103
124
  const lines = status.stdout
104
125
  .split("\n")
105
126
  .map((line) => line.trimEnd())
106
- .filter(Boolean);
127
+ .filter(Boolean)
128
+ .filter((line) => !untouchedSince(parent, line, referenceMs));
107
129
  return lines.map((line) => `${relative(workspace, parent) || parent}: ${line}`);
108
130
  }
109
131
  const referenceMs = referenceTime(workspace);
@@ -117,6 +139,22 @@ function leaksIn(parent, workspace) {
117
139
  return dirty;
118
140
  }
119
141
 
142
+ /**
143
+ * A status line whose file still exists and was last written before the Child was
144
+ * created is older than the Attempt. A deletion has no date and always counts.
145
+ * @param {string} parent
146
+ * @param {string} line
147
+ * @param {number} referenceMs
148
+ */
149
+ function untouchedSince(parent, line, referenceMs) {
150
+ const path = line.slice(3).split(" -> ").at(-1) ?? "";
151
+ try {
152
+ return statSync(join(parent, path.replace(/^"|"$/g, ""))).mtimeMs <= referenceMs;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
120
158
  /** @param {string} workspace */
121
159
  function referenceTime(workspace) {
122
160
  const gitFile = join(workspace, ".git");
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { emitVerdict, GATE_FLAGS, ownArgv, pathMatchesGlob } from "@bluewombat/slot-kit";
4
+
5
+ // Fail-retryable when the workspace changed a path that matches none of the globs: the one place
6
+ // the workspace may be written is what the globs name (the tests and their manifest). The other
7
+ // side of `sensitive-path`, which names what may not be touched; a Project whose tests sit among its
8
+ // code (`**/*.test.ts` in every package) cannot say "everything but" with globs that only deny.
9
+ // Remaining argv after Implementer's flags are the globs.
10
+ //
11
+ // What counts is what a fold can carry: tracked paths that differ from HEAD (added, modified,
12
+ // deleted, renamed) and new paths .gitignore does not exclude.
13
+
14
+ const cwd = process.cwd();
15
+ const globs = ownArgv(process.argv, GATE_FLAGS).filter((token) => token.length > 0);
16
+
17
+ if (globs.length === 0) {
18
+ emitVerdict("fail-blocking", "tests-only needs at least one glob.");
19
+ process.exit(0);
20
+ }
21
+
22
+ const status = spawnSync(
23
+ "git",
24
+ ["-C", cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all"],
25
+ {
26
+ encoding: "utf8",
27
+ },
28
+ );
29
+ if (status.status !== 0) {
30
+ emitVerdict("fail-blocking", `tests-only needs a git workspace: ${status.stderr.trim()}`);
31
+ process.exit(0);
32
+ }
33
+
34
+ /** @type {Set<string>} */
35
+ const changed = new Set();
36
+ const fields = status.stdout.split("\0");
37
+ for (let i = 0; i < fields.length; i++) {
38
+ const entry = fields[i] ?? "";
39
+ if (entry.length < 4) {
40
+ continue;
41
+ }
42
+ changed.add(entry.slice(3));
43
+ // A rename or a copy carries the old path in the next field.
44
+ if (entry[0] === "R" || entry[0] === "C") {
45
+ const old = fields[++i];
46
+ if (old !== undefined && old.length > 0) {
47
+ changed.add(old);
48
+ }
49
+ }
50
+ }
51
+
52
+ const outside = [...changed]
53
+ .map((path) => path.replaceAll("\\", "/"))
54
+ .filter((path) => !globs.some((glob) => pathMatchesGlob(path, glob)))
55
+ .sort();
56
+
57
+ if (outside.length > 0) {
58
+ emitVerdict(
59
+ "fail-retryable",
60
+ `Paths changed that are not tests or their manifest (${globs.join(", ")}):\n${outside.join("\n")}`,
61
+ );
62
+ process.exit(0);
63
+ }
64
+
65
+ emitVerdict("pass");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bluewombat/slots",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "type": "module",
5
5
  "description": "Shipped mason slots: Builders, Gates, publishers, and refreshers a Project names in its config.",
6
6
  "keywords": [
@@ -52,7 +52,7 @@
52
52
  "test": "tsx --test $(find test -name '*.test.ts')"
53
53
  },
54
54
  "dependencies": {
55
- "@bluewombat/slot-kit": "^0.10.0",
55
+ "@bluewombat/slot-kit": "^0.11.1",
56
56
  "@pinkpuma/deck": "1.1.0"
57
57
  }
58
58
  }
@@ -14,18 +14,29 @@ import {
14
14
  // Put the ticket's work where the Authority can read it, by pushing the name
15
15
  // Isolator gave it to the work line's own remote.
16
16
  //
17
- // A plain push, and no force of any kind. Integrator only ever adds history, so
18
- // republishing after a refusal is a fast-forward of what was already there. A
19
- // rejection therefore means the name moved under us — someone else's commit, or
20
- // a rewrite — and that is exactly the case worth refusing rather than
21
- // overwriting.
17
+ // What is pushed is the Ticket's result, not what its workspace went through:
18
+ // the workspace is snapshotted again at every integration, and a blob that was
19
+ // added and removed along the way (an install, a build) would travel with its
20
+ // history and make a pack no host accepts. So one commit is made from the
21
+ // branch's tree, on top of the work line's tip, and that commit is pushed.
22
+ // A resubmission continues what this slot published before, which the work line
23
+ // remembers, so the push stays a fast-forward.
24
+ //
25
+ // A plain push, and no force of any kind. A rejection means the name moved
26
+ // under us — someone else's commit, or a rewrite — and that is exactly the
27
+ // case worth refusing rather than overwriting: a branch this slot did not
28
+ // publish is never built upon.
22
29
  //
23
30
  // The remote is `origin`, and there is no option for it: Host reads the work
24
31
  // line back from `origin` after the Authority folds, with no way to learn what
25
32
  // this slot was told. An option that publishes to one remote while the refresh
26
33
  // reads another is a trap, so it waits for that half to become a slot too.
27
34
  //
28
- // Runs in the work line. mason appends --id, --ref and --target.
35
+ // A Feature's line is not a Ticket's result: it is a history the Authority built
36
+ // by folding Tickets into it, already on the remote, and it is pushed as it is,
37
+ // a fast-forward of what the remote holds. mason says so with `--kind line`.
38
+ //
39
+ // Runs in the work line. mason appends --id, --ref, --target and --kind.
29
40
 
30
41
  const cwd = process.cwd();
31
42
  const tokens = ownArgv(process.argv, PUBLISHER_FLAGS);
@@ -48,18 +59,108 @@ if (!existsSync(join(cwd, ".git"))) {
48
59
 
49
60
  // C locale: git's words become the refusal a team reads on the tracker, and a
50
61
  // machine's own language would put French or German in an English thread.
51
- const pushed = spawnSync("git", ["-C", cwd, "push", remote, `${ref}:${ref}`], {
52
- encoding: "utf8",
53
- env: { ...process.env, LC_ALL: "C" },
54
- });
55
- if (pushed.error !== undefined) {
56
- writeDiagnostic(`git publisher cannot run git: ${pushed.error.message}\n`);
57
- process.exit(1);
62
+ const env = { ...process.env, LC_ALL: "C" };
63
+ /** @param {...string} args */
64
+ const git = (...args) => spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", env });
65
+ /** @param {{ stderr?: string | null, stdout?: string | null }} run */
66
+ const text = (run) => `${run.stderr ?? ""}${run.stdout ?? ""}`.trim().slice(-1000);
67
+
68
+ /**
69
+ * Runs git; a git that cannot run at all ends the slot, anything else is the caller's to read.
70
+ * @param {...string} args
71
+ */
72
+ function run(...args) {
73
+ const done = git(...args);
74
+ if (done.error !== undefined) {
75
+ writeDiagnostic(`git publisher cannot run git: ${done.error.message}\n`);
76
+ process.exit(1);
77
+ }
78
+ return done;
58
79
  }
59
- if (pushed.status !== 0) {
60
- const detail = `${pushed.stderr ?? ""}${pushed.stdout ?? ""}`.trim().slice(-1000);
61
- emitPublishRefusal(`Could not publish ${ref}: ${detail}`);
80
+
81
+ /**
82
+ * @param {string} what
83
+ * @param {{ stderr?: string | null, stdout?: string | null }} [done]
84
+ * @returns {never}
85
+ */
86
+ function refuse(what, done) {
87
+ emitPublishRefusal(
88
+ `Could not publish ${ref}: ${what}${done === undefined ? "" : `: ${text(done)}`}`,
89
+ );
90
+ process.exit(0);
91
+ }
92
+
93
+ const kind = take(process.argv, "--kind") ?? "ticket";
94
+ if (kind === "line") {
95
+ const pushedLine = run("push", remote, `refs/heads/${ref}:refs/heads/${ref}`);
96
+ if (pushedLine.status !== 0) {
97
+ refuse("the push was refused", pushedLine);
98
+ }
99
+ emitPublication(ref);
100
+ process.exit(0);
101
+ }
102
+
103
+ const target = take(process.argv, "--target");
104
+ const id = take(process.argv, "--id");
105
+ if (target === undefined || target.trim().length === 0) {
106
+ refuse("it was given no --target to build on");
107
+ }
108
+
109
+ // The line's tip, as the remote holds it: what the Submission is a change to.
110
+ const fetched = run("fetch", "--quiet", remote, target);
111
+ if (fetched.status !== 0) {
112
+ refuse(`cannot read ${target} from ${remote}`, fetched);
113
+ }
114
+ const tip = run("rev-parse", "FETCH_HEAD");
115
+ const tipOid = tip.stdout.trim();
116
+ if (tip.status !== 0 || tipOid.length === 0) {
117
+ refuse(`cannot read ${target}`, tip);
118
+ }
119
+ const tree = run("rev-parse", `${ref}^{tree}`);
120
+ if (tree.status !== 0) {
121
+ refuse(`${ref} is not a branch of this work line`, tree);
122
+ }
123
+ const treeOid = tree.stdout.trim();
124
+
125
+ // What this slot published before: the work line keeps it under `refs/published/`.
126
+ // A name that holds anything else moved under us, and is left for the push to
127
+ // reject.
128
+ const recordName = `refs/published/${ref}`;
129
+ let before;
130
+ const recorded = run("rev-parse", "--verify", "--quiet", recordName).stdout.trim();
131
+ const remoteRef = run("ls-remote", remote, `refs/heads/${ref}`);
132
+ const remoteOid = remoteRef.stdout.trim().split(/\s+/)[0] ?? "";
133
+ if (recorded.length > 0 && remoteRef.status === 0 && remoteOid === recorded) {
134
+ before = recorded;
135
+ }
136
+
137
+ // Nothing new: the same tree on the same line, already there.
138
+ if (
139
+ before !== undefined &&
140
+ run("rev-parse", `${before}^{tree}`).stdout.trim() === treeOid &&
141
+ run("merge-base", "--is-ancestor", tipOid, before).status === 0
142
+ ) {
143
+ emitPublication(ref);
62
144
  process.exit(0);
63
145
  }
64
146
 
147
+ const parents = [...new Set([before, tipOid].filter((oid) => oid !== undefined))];
148
+ const made = run(
149
+ "commit-tree",
150
+ treeOid,
151
+ ...parents.flatMap((oid) => ["-p", oid]),
152
+ "-m",
153
+ `Submission of ${id ?? ref}`,
154
+ );
155
+ const commit = made.stdout.trim();
156
+ if (made.status !== 0 || commit.length === 0) {
157
+ refuse("cannot make the commit", made);
158
+ }
159
+
160
+ const pushed = run("push", remote, `${commit}:refs/heads/${ref}`);
161
+ if (pushed.status !== 0) {
162
+ refuse("the push was refused", pushed);
163
+ }
164
+ run("update-ref", recordName, commit);
165
+
65
166
  emitPublication(ref);