@sous-io/sous 0.2.18 → 0.2.19

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,452 @@
1
+ /**
2
+ * What `sous repo contribute` chains together, and how it decides whether a
3
+ * branch still has something to propose.
4
+ *
5
+ * Contributing to a recipe repository is a lifecycle: start (link the
6
+ * repository on a fresh branch of an up-to-date checkout), edit, propose,
7
+ * revise, and finish (propose whatever is left, then go back to the published
8
+ * versions). Each part already has a command of its own (`sous repo link`,
9
+ * `sous repo submit`, `sous repo unlink`), and the contribution command adds
10
+ * nothing those commands lack: this module only works out which of them to run,
11
+ * with which flags, in which order.
12
+ *
13
+ * A step is described as the command it runs and the arguments it passes, so
14
+ * the command can run it in-process, print it for a dry run, and name it in a
15
+ * failure, all from one description. Everything here that reads the checkout
16
+ * goes through the injectable git runner, so no test needs a network.
17
+ */
18
+
19
+ import { ConfigError } from "../errors.js";
20
+ import {
21
+ currentBranch,
22
+ defaultBranch,
23
+ runGit,
24
+ UPSTREAM_REMOTE,
25
+ type GitOptions,
26
+ } from "./git-clone.js";
27
+ import type { ProposalSummary } from "./providers/provider.js";
28
+ import { FORK_REMOTE } from "./release/submit-service.js";
29
+
30
+ /** The commands a contribution is made of, by their oclif ids. */
31
+ export type ContributionCommand = "repo:link" | "repo:submit" | "repo:unlink";
32
+
33
+ /** One command a contribution runs, and how to talk about it. */
34
+ export type ContributionStep = {
35
+ /** The command's oclif id. */
36
+ command: ContributionCommand;
37
+ /** The arguments and flags passed to it, exactly as a command line would carry them. */
38
+ argv: string[];
39
+ /** What the step does, as a heading printed when it starts. */
40
+ running: string;
41
+ /** What the step did, as a line in the list of completed steps. */
42
+ done: string;
43
+ };
44
+
45
+ /**
46
+ * Config-locating flags the contribution command was given, which every step
47
+ * that discovers a project config has to be given as well, or it could find a
48
+ * different project from the one the contribution started in.
49
+ */
50
+ export type LocatorFlags = {
51
+ config?: string;
52
+ "sous-dir"?: string;
53
+ "sous-confd"?: string;
54
+ };
55
+
56
+ /** The flags that shape the start of a contribution. */
57
+ export type StartFlags = {
58
+ /** `--branch`: an existing branch to work on. */
59
+ branch?: string;
60
+ /** `--create-branch`: a new branch to create. */
61
+ createBranch?: string;
62
+ /** `--generate-branch`: a new branch with a generated name. */
63
+ generateBranch?: boolean;
64
+ /** `--from`: the branch a new branch starts from. */
65
+ from?: string;
66
+ /** `--global`: the machine-wide link. */
67
+ global?: boolean;
68
+ /** The confirmation flag. */
69
+ yes?: boolean;
70
+ };
71
+
72
+ /** The flags that shape the end of a contribution. */
73
+ export type FinishFlags = {
74
+ /** `--title`: the proposal's title. */
75
+ title?: string;
76
+ /** `--body`: the proposal's description. */
77
+ body?: string;
78
+ /** `--draft`: open a new proposal as a draft. */
79
+ draft?: boolean;
80
+ /** `--commit`: commit uncommitted changes as part of the submission. */
81
+ commit?: boolean;
82
+ /** `--branch`: the branch to submit, instead of the one checked out. */
83
+ branch?: string;
84
+ /** `--remove`: delete the checkout when unlinking. */
85
+ remove?: boolean;
86
+ /** `--global`: the machine-wide link. */
87
+ global?: boolean;
88
+ /** The confirmation flag. */
89
+ yes?: boolean;
90
+ };
91
+
92
+ /**
93
+ * The config-locating flags, as arguments.
94
+ *
95
+ * @param locator - The flags the contribution command was given.
96
+ */
97
+ export function locatorArgs(locator: LocatorFlags): string[] {
98
+ const args: string[] = [];
99
+ if (locator.config !== undefined) args.push("--config", locator.config);
100
+ if (locator["sous-dir"] !== undefined) args.push("--sous-dir", locator["sous-dir"]);
101
+ if (locator["sous-confd"] !== undefined) args.push("--sous-confd", locator["sous-confd"]);
102
+ return args;
103
+ }
104
+
105
+ /**
106
+ * The step that starts a contribution: `sous repo link <repo> --latest` on a
107
+ * new branch. The branch is generated unless one was named, either as a branch
108
+ * to create or as an existing branch to work on.
109
+ *
110
+ * startStep("sous-recipes", {}, {}).argv
111
+ * // -> ["sous-recipes", "--latest", "--generate-branch"]
112
+ *
113
+ * @param repo - The repository's short name.
114
+ * @param flags - The start flags, passed through.
115
+ * @param locator - The config-locating flags, passed through.
116
+ */
117
+ export function startStep(
118
+ repo: string,
119
+ flags: StartFlags,
120
+ locator: LocatorFlags = {}
121
+ ): ContributionStep {
122
+ const argv = [repo, "--latest"];
123
+ let running: string;
124
+ let done: string;
125
+
126
+ if (flags.branch !== undefined) {
127
+ argv.push("--branch", flags.branch);
128
+ running = `Linking '${repo}' on the branch '${flags.branch}', made to match upstream's`;
129
+ done = `Linked '${repo}' on the branch '${flags.branch}'`;
130
+ } else if (flags.createBranch !== undefined) {
131
+ argv.push("--create-branch", flags.createBranch);
132
+ running = `Linking '${repo}' on a new branch named '${flags.createBranch}'`;
133
+ done = `Linked '${repo}' on the new branch '${flags.createBranch}'`;
134
+ } else {
135
+ argv.push("--generate-branch");
136
+ running = `Linking '${repo}' on a new branch with a generated name`;
137
+ done = `Linked '${repo}' on a new branch`;
138
+ }
139
+
140
+ if (flags.from !== undefined) argv.push("--from", flags.from);
141
+ if (flags.global === true) argv.push("--global");
142
+ if (flags.yes === true) argv.push("--yes");
143
+ argv.push(...locatorArgs(locator));
144
+
145
+ return { command: "repo:link", argv, running, done };
146
+ }
147
+
148
+ /**
149
+ * The step that proposes what the branch holds: `sous repo submit <repo>`,
150
+ * with the proposal flags passed through. The confirmation flag is passed only
151
+ * when it was given, so a question the contributor has not answered ahead of
152
+ * time is still asked.
153
+ *
154
+ * submitStep("sous-recipes", { title: "Fix a typo" }).argv
155
+ * // -> ["sous-recipes", "--title", "Fix a typo"]
156
+ *
157
+ * @param repo - The repository's short name.
158
+ * @param flags - The finish flags, passed through.
159
+ */
160
+ export function submitStep(repo: string, flags: FinishFlags): ContributionStep {
161
+ const argv = [repo];
162
+ if (flags.title !== undefined) argv.push("--title", flags.title);
163
+ if (flags.body !== undefined) argv.push("--body", flags.body);
164
+ if (flags.branch !== undefined) argv.push("--branch", flags.branch);
165
+ if (flags.draft === true) argv.push("--draft");
166
+ if (flags.commit === true) argv.push("--commit");
167
+ if (flags.yes === true) argv.push("--yes");
168
+
169
+ return {
170
+ command: "repo:submit",
171
+ argv,
172
+ running: "Proposing what the branch holds",
173
+ done: "Proposed what the branch holds",
174
+ };
175
+ }
176
+
177
+ /**
178
+ * The step that ends a contribution: `sous repo unlink <repo> --update`, which
179
+ * goes back to the published versions and moves the pins to the newest ones
180
+ * their ranges allow, so a release carrying the change is picked up.
181
+ *
182
+ * unlinkStep("sous-recipes", { remove: true }).argv
183
+ * // -> ["sous-recipes", "--update", "--remove"]
184
+ *
185
+ * @param repo - The repository's short name.
186
+ * @param flags - The finish flags, passed through.
187
+ * @param locator - The config-locating flags, passed through.
188
+ */
189
+ export function unlinkStep(
190
+ repo: string,
191
+ flags: FinishFlags,
192
+ locator: LocatorFlags = {}
193
+ ): ContributionStep {
194
+ const argv = [repo, "--update"];
195
+ if (flags.remove === true) argv.push("--remove");
196
+ if (flags.global === true) argv.push("--global");
197
+ if (flags.yes === true) argv.push("--yes");
198
+ argv.push(...locatorArgs(locator));
199
+
200
+ return {
201
+ command: "repo:unlink",
202
+ argv,
203
+ running:
204
+ flags.remove === true
205
+ ? `Unlinking '${repo}', updating its pins and deleting the checkout`
206
+ : `Unlinking '${repo}' and updating its pins`,
207
+ done:
208
+ flags.remove === true
209
+ ? `Unlinked '${repo}', updated its pins and deleted the checkout`
210
+ : `Unlinked '${repo}' and updated its pins`,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * A step written out as the command line that would run it, for a dry run and
216
+ * for anyone who wants to run the parts by hand.
217
+ *
218
+ * commandLine(startStep("sous-recipes", {}))
219
+ * // -> "sous repo link sous-recipes --latest --generate-branch"
220
+ *
221
+ * @param step - The step.
222
+ */
223
+ export function commandLine(step: ContributionStep): string {
224
+ const words = ["sous", ...step.command.split(":"), ...step.argv];
225
+ return words.map(quoteArgument).join(" ");
226
+ }
227
+
228
+ /** An argument as a shell would need it written: quoted when it holds anything unusual. */
229
+ function quoteArgument(value: string): string {
230
+ if (/^[A-Za-z0-9_@%+=:,./~-]+$/.test(value)) return value;
231
+ return `'${value.replace(/'/g, `'\\''`)}'`;
232
+ }
233
+
234
+ // --- What the branch still has to propose ----------------------------------------------------------
235
+
236
+ /** What a checkout's branch holds that a proposal may not carry yet. */
237
+ export type PendingWork = {
238
+ /** The branch that was examined, or undefined when HEAD is detached. */
239
+ branch?: string;
240
+ /** The upstream default branch, when it could be worked out. */
241
+ baseBranch?: string;
242
+ /** Every uncommitted change, as `git status --porcelain` prints it. */
243
+ uncommitted: string[];
244
+ /** Commits on the branch that the upstream default branch lacks, one line each. */
245
+ ahead: string[];
246
+ /** Of those, the commits no pushed copy of the branch holds, one line each. */
247
+ unpushed: string[];
248
+ /** The pushed copies of the branch this checkout knows about, such as `origin/my-change`. */
249
+ pushedCopies: string[];
250
+ };
251
+
252
+ /**
253
+ * Reads what a checkout's branch holds that has not been proposed: its
254
+ * uncommitted changes, its commits beyond the upstream default branch, and
255
+ * which of those no pushed copy of the branch holds. Nothing is fetched; the
256
+ * pushed copies are the remote-tracking branches a push leaves behind.
257
+ *
258
+ * pendingWork("/path/to/checkout")
259
+ * // -> { branch: "sous/edit-20260927-1200", baseBranch: "main", uncommitted: [],
260
+ * // ahead: ["1a2b3c4 Fix a typo"], unpushed: ["1a2b3c4 Fix a typo"], pushedCopies: [] }
261
+ *
262
+ * @param directory - The checkout.
263
+ * @param branch - The branch to examine; the checked-out one when omitted.
264
+ * @param options - The git runner to use.
265
+ */
266
+ export function pendingWork(
267
+ directory: string,
268
+ branch?: string,
269
+ options: GitOptions = {}
270
+ ): PendingWork {
271
+ const runner = options.runner ?? runGit;
272
+ const git = (args: string[]) => runner(args, { cwd: directory });
273
+ const lines = (text: string): string[] =>
274
+ text
275
+ .split("\n")
276
+ .map((line) => line.trim())
277
+ .filter((line) => line.length > 0);
278
+ const refExists = (ref: string) => git(["rev-parse", "--verify", "--quiet", ref]).status === 0;
279
+
280
+ const status = git(["status", "--porcelain"]);
281
+ if (status.status !== 0) {
282
+ throw new ConfigError(
283
+ `Sous could not read the checkout at ${directory}.\n` +
284
+ (status.stderr.length > 0 ? ` git said: ${status.stderr}\n` : "") +
285
+ ` Check that it is still a git checkout, then run the command again.`
286
+ );
287
+ }
288
+
289
+ const examined = branch ?? currentBranch(directory, options);
290
+ const tip = examined === undefined ? "HEAD" : `refs/heads/${examined}`;
291
+ if (!refExists(tip)) {
292
+ throw new ConfigError(
293
+ `The checkout at ${directory} has no branch named '${examined}'.\n` +
294
+ ` Name a branch it has with '--branch', or leave the flag off to use the one ` +
295
+ `that is checked out.`
296
+ );
297
+ }
298
+
299
+ const baseBranch = defaultBranch(directory, options);
300
+ const baseRef =
301
+ baseBranch !== undefined && refExists(`refs/remotes/${UPSTREAM_REMOTE}/${baseBranch}`)
302
+ ? `refs/remotes/${UPSTREAM_REMOTE}/${baseBranch}`
303
+ : undefined;
304
+
305
+ const pushedCopies =
306
+ examined === undefined
307
+ ? []
308
+ : [UPSTREAM_REMOTE, FORK_REMOTE]
309
+ .map((remote) => `${remote}/${examined}`)
310
+ .filter((copy) => refExists(`refs/remotes/${copy}`));
311
+
312
+ const commitsNotIn = (exclude: string[]): string[] => {
313
+ const args = ["log", "--oneline", "--no-decorate", tip];
314
+ // With no upstream default branch to measure against, a commit counts as
315
+ // the branch's own when no remote holds it.
316
+ if (exclude.length === 0) args.push("--not", "--remotes");
317
+ else args.push("--not", ...exclude);
318
+ const result = git(args);
319
+ return result.status === 0 ? lines(result.stdout) : [];
320
+ };
321
+
322
+ const base = baseRef === undefined ? [] : [baseRef];
323
+ const ahead = commitsNotIn(base);
324
+ const unpushed =
325
+ pushedCopies.length === 0
326
+ ? ahead
327
+ : commitsNotIn([...base, ...pushedCopies.map((copy) => `refs/remotes/${copy}`)]);
328
+
329
+ return {
330
+ ...(examined === undefined ? {} : { branch: examined }),
331
+ ...(baseBranch === undefined ? {} : { baseBranch }),
332
+ uncommitted: lines(status.stdout),
333
+ ahead,
334
+ unpushed,
335
+ pushedCopies,
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Whether a branch has something to propose.
341
+ *
342
+ * - `nothing`: it holds nothing a proposal lacks, so there is nothing to submit.
343
+ * - `pending`: it holds work no proposal carries yet.
344
+ * - `lookup`: everything on it was pushed, so only the proposal itself can say
345
+ * whether that work is proposed; the caller has to look it up.
346
+ */
347
+ export type PendingVerdict =
348
+ | { kind: "nothing"; reason: string }
349
+ | { kind: "pending"; reason: string }
350
+ | { kind: "lookup"; reason: string };
351
+
352
+ /**
353
+ * Judges what `pendingWork` found, without asking the repository host anything.
354
+ *
355
+ * assessPendingWork({ branch: "fix", baseBranch: "main", uncommitted: [], ahead: [],
356
+ * unpushed: [], pushedCopies: [] })
357
+ * // -> { kind: "nothing", reason: "The branch 'fix' holds no commits that origin/main lacks, ..." }
358
+ *
359
+ * @param work - What the checkout's branch holds.
360
+ */
361
+ export function assessPendingWork(work: PendingWork): PendingVerdict {
362
+ const branch = work.branch === undefined ? "the checked-out commit" : `the branch '${work.branch}'`;
363
+ const Branch = capitalize(branch);
364
+ const base =
365
+ work.baseBranch === undefined ? "any remote" : `${UPSTREAM_REMOTE}/${work.baseBranch}`;
366
+
367
+ if (work.uncommitted.length > 0) {
368
+ return {
369
+ kind: "pending",
370
+ reason: `The checkout holds ${count(work.uncommitted.length, "uncommitted change")}.`,
371
+ };
372
+ }
373
+ if (work.ahead.length === 0) {
374
+ return {
375
+ kind: "nothing",
376
+ reason: `${Branch} holds no commits that ${base} lacks, so there is nothing to submit.`,
377
+ };
378
+ }
379
+ if (work.unpushed.length > 0) {
380
+ return {
381
+ kind: "pending",
382
+ reason:
383
+ `${Branch} holds ${count(work.unpushed.length, "commit")} that ` +
384
+ (work.pushedCopies.length === 0
385
+ ? "has never been pushed"
386
+ : `${work.pushedCopies.join(" and ")} lacks`) +
387
+ `, so no proposal carries ${work.unpushed.length === 1 ? "it" : "them"} yet.`,
388
+ };
389
+ }
390
+ return {
391
+ kind: "lookup",
392
+ reason: `Every commit on ${branch} was pushed to ${work.pushedCopies.join(" and ")}.`,
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Judges a pushed branch by its proposal: an open proposal already carries
398
+ * everything, and so does a merged one; with no proposal, or one closed
399
+ * without being merged, the pushed commits are not proposed.
400
+ *
401
+ * assessProposal("fix", undefined, "pull request")
402
+ * // -> { kind: "pending", reason: "Every commit on the branch 'fix' was pushed, but no pull request is open for it." }
403
+ *
404
+ * @param branch - The branch.
405
+ * @param proposal - The branch's proposal, when it has one.
406
+ * @param noun - What the provider calls a proposal.
407
+ */
408
+ export function assessProposal(
409
+ branch: string,
410
+ proposal: ProposalSummary | undefined,
411
+ noun: string
412
+ ): PendingVerdict {
413
+ if (proposal === undefined) {
414
+ return {
415
+ kind: "pending",
416
+ reason: `Every commit on the branch '${branch}' was pushed, but no ${noun} is open for it.`,
417
+ };
418
+ }
419
+ switch (proposal.state) {
420
+ case "open":
421
+ return {
422
+ kind: "nothing",
423
+ reason:
424
+ `Every commit on the branch '${branch}' is already in its open ${noun}` +
425
+ `${proposal.url === undefined ? "" : `, ${proposal.url}`}.`,
426
+ };
427
+ case "merged":
428
+ return {
429
+ kind: "nothing",
430
+ reason:
431
+ `The ${noun} for the branch '${branch}' was merged` +
432
+ `${proposal.url === undefined ? "" : `: ${proposal.url}`}.`,
433
+ };
434
+ default:
435
+ return {
436
+ kind: "pending",
437
+ reason:
438
+ `The ${noun} for the branch '${branch}' was closed without being merged, so no ` +
439
+ `open ${noun} carries its commits.`,
440
+ };
441
+ }
442
+ }
443
+
444
+ /** "1 commit", "3 commits". */
445
+ function count(value: number, noun: string): string {
446
+ return `${value} ${noun}${value === 1 ? "" : "s"}`;
447
+ }
448
+
449
+ /** The text with its first letter in capitals. */
450
+ function capitalize(text: string): string {
451
+ return text.length === 0 ? text : `${text[0]!.toUpperCase()}${text.slice(1)}`;
452
+ }
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * A maintainer reviewing a proposal needs to know what merging it does to the
5
5
  * people who subscribe to the repository: which recipes appear, disappear or
6
- * change version, which will be released as a patch because their files
7
- * changed without a version raise, which namespaces come and go, and which
6
+ * change version, which changed their files without raising their version
7
+ * (a release run with `--ci` refuses those), which namespaces come and go, and which
8
8
  * variables change. None of that is in a commit message, and all of it can be
9
9
  * read from the manifests, so sous reads them: the ones the change carries,
10
10
  * compared with the ones on the default branch.
@@ -19,7 +19,6 @@
19
19
  */
20
20
 
21
21
  import path from "node:path";
22
- import semver from "semver";
23
22
  import {
24
23
  MANIFEST_EXTENSIONS,
25
24
  RECIPE_MANIFEST_BASENAME,
@@ -53,7 +52,7 @@ export type RecipeEntry = { key: string; version: string };
53
52
  export type VersionChange = { key: string; from: string; to: string };
54
53
 
55
54
  /** One recipe whose files changed while its version stayed where it was. */
56
- export type UnraisedChange = { key: string; version: string; next: string };
55
+ export type UnraisedChange = { key: string; version: string };
57
56
 
58
57
  /** One variable that was added, removed or changed. */
59
58
  export type VariableChange = {
@@ -185,7 +184,7 @@ export function buildChangelog(input: {
185
184
  if (from !== to) {
186
185
  changelog.versionChanges.push({ key, from, to });
187
186
  } else if (pathsInside(entry.path, changedPaths).length > 0) {
188
- changelog.unraised.push({ key, version: to, next: semver.inc(to, "patch") ?? to });
187
+ changelog.unraised.push({ key, version: to });
189
188
  }
190
189
 
191
190
  changelog.variables.push(
@@ -215,6 +214,18 @@ export function changelogIsEmpty(changelog: Changelog): boolean {
215
214
  );
216
215
  }
217
216
 
217
+ /**
218
+ * The warning a change to a recipe's files without a version raise carries.
219
+ * It is a warning and not a refusal: whether the merge's release raises the
220
+ * version, refuses the change, or runs some other way is the repository's own
221
+ * business, so the changelog states the fact and leaves the decision there.
222
+ */
223
+ export const UNRAISED_VERSION_WARNING =
224
+ "A recipe listed above changed without raising its version. A release run with `--ci`, " +
225
+ "as the workflow `sous repo init` scaffolds runs it after a merge, refuses a changed " +
226
+ "recipe whose version was not raised, so its version has to be raised in its manifest " +
227
+ "before that release can publish it.";
228
+
218
229
  /** The warning a breaking variable change carries. */
219
230
  export const BREAKING_VARIABLE_WARNING =
220
231
  "Removing a variable or tightening its validation is usually a major change: a " +
@@ -272,10 +283,12 @@ export function renderChangelog(changelog: Changelog): string {
272
283
  "Changed without a version raise",
273
284
  changelog.unraised.map(
274
285
  (entry) =>
275
- `\`${entry.key}\`: its files changed and its version is still ${entry.version}, so ` +
276
- `merging releases it as ${entry.next}`
286
+ `\`${entry.key}\`: its files changed and its version is still ${entry.version}`
277
287
  )
278
288
  );
289
+ if (changelog.unraised.length > 0) {
290
+ lines.push("", `**Warning:** ${UNRAISED_VERSION_WARNING}`);
291
+ }
279
292
  section("Namespaces added", changelog.namespacesAdded.map((name) => `\`${name}\``));
280
293
  section("Namespaces removed", changelog.namespacesRemoved.map((name) => `\`${name}\``));
281
294
  section("Variables", changelog.variables.map(describeVariableChange));
@@ -330,6 +330,22 @@ export async function pushBranch(
330
330
  cwd: rootDir,
331
331
  run: options.run,
332
332
  });
333
+
334
+ // Git records what was pushed in the remote-tracking branch only when the
335
+ // remote's fetch configuration covers the branch, and a clone `sous repo
336
+ // link` makes covers only the default branch. Recording it here keeps the
337
+ // checkout's own answer to "was this pushed?" true, which is what `sous repo
338
+ // unlink --remove` and `sous repo contribute --finish` read. It is a record,
339
+ // not a step: a failure to write it changes nothing about the push.
340
+ try {
341
+ await runGit(["update-ref", `refs/remotes/${remote}/${branch}`, `refs/heads/${branch}`], {
342
+ cwd: rootDir,
343
+ run: options.run,
344
+ });
345
+ } catch {
346
+ // The push succeeded; only the local record of it is missing.
347
+ }
348
+
333
349
  return pushReportIsUpToDate(report) ? "up-to-date" : "updated";
334
350
  }
335
351
 
@@ -96,7 +96,7 @@ import {
96
96
  const UPSTREAM_REMOTE = "origin";
97
97
 
98
98
  /** The remote name sous gives a fork it created. */
99
- const FORK_REMOTE = "fork";
99
+ export const FORK_REMOTE = "fork";
100
100
 
101
101
  /** What a proposal is called when the provider does not name it. */
102
102
  const DEFAULT_PROPOSAL_NOUN = "proposal";
@@ -143,12 +143,38 @@ export function headerTo(write: (line: string) => void): void {
143
143
  }
144
144
 
145
145
  /**
146
- * Writes the CLI header to stdout.
146
+ * How many commands are running inside another one right now. While any are,
147
+ * `header` writes nothing, because the command that started them has already
148
+ * drawn the banner once.
149
+ */
150
+ let nestedCommandDepth = 0;
151
+
152
+ /**
153
+ * Writes the CLI header to stdout, unless the command asking for it runs
154
+ * inside another command (see `withoutHeader`).
147
155
  */
148
156
  export function header(): void {
157
+ if (nestedCommandDepth > 0) return;
149
158
  headerTo(log);
150
159
  }
151
160
 
161
+ /**
162
+ * Runs another command in this process without drawing the CLI header a
163
+ * second time. A command that chains others (`sous repo contribute`) wraps each
164
+ * one in this, so the output opens with one banner however many run.
165
+ *
166
+ * @param run - Runs the nested command.
167
+ * @returns Whatever `run` resolves to.
168
+ */
169
+ export async function withoutHeader<T>(run: () => Promise<T>): Promise<T> {
170
+ nestedCommandDepth += 1;
171
+ try {
172
+ return await run();
173
+ } finally {
174
+ nestedCommandDepth -= 1;
175
+ }
176
+ }
177
+
152
178
  /**
153
179
  * Writes the CLI footer to the console.
154
180
  */