@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.
- package/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/commands.md +28 -0
- package/docs/markdown/repositories-authoring.md +7 -1
- package/docs/markdown/repositories-contributing.md +145 -0
- package/package.json +1 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/repo/contribute.ts +604 -0
- package/src/lib/repos/contribute.ts +452 -0
- package/src/lib/repos/release/changelog.ts +20 -7
- package/src/lib/repos/release/git-state.ts +16 -0
- package/src/lib/repos/release/submit-service.ts +1 -1
- package/src/utils/formatting.ts +27 -1
|
@@ -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
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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}
|
|
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";
|
package/src/utils/formatting.ts
CHANGED
|
@@ -143,12 +143,38 @@ export function headerTo(write: (line: string) => void): void {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
-
*
|
|
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
|
*/
|