@sous-io/sous 0.2.16 → 0.2.18

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.
Files changed (33) hide show
  1. package/docs/markdown/commands.md +59 -13
  2. package/docs/markdown/repositories-authoring.md +75 -13
  3. package/docs/markdown/repositories-consuming.md +44 -2
  4. package/docs/markdown/repositories-file-formats.md +22 -1
  5. package/docs/markdown/repositories-providers.md +20 -10
  6. package/package.json +1 -1
  7. package/recipes/core/sous-skills/sous.recipe.yaml +8 -1
  8. package/src/commands/repo/release.ts +41 -0
  9. package/src/commands/repo/submit.ts +245 -35
  10. package/src/commands/repo/unlink.ts +333 -20
  11. package/src/commands/subscription/update.ts +215 -0
  12. package/src/lib/repos/formats/common.ts +20 -0
  13. package/src/lib/repos/formats/links-map.ts +5 -3
  14. package/src/lib/repos/formats/recipe-manifest.ts +7 -0
  15. package/src/lib/repos/formats/repo-manifest.ts +8 -0
  16. package/src/lib/repos/git-clone.ts +71 -0
  17. package/src/lib/repos/links.ts +2 -1
  18. package/src/lib/repos/locked-recipes.ts +22 -0
  19. package/src/lib/repos/providers/base.ts +33 -1
  20. package/src/lib/repos/providers/github.ts +275 -3
  21. package/src/lib/repos/providers/provider.ts +119 -3
  22. package/src/lib/repos/release/changelog.ts +448 -0
  23. package/src/lib/repos/release/git-state.ts +101 -15
  24. package/src/lib/repos/release/index.ts +2 -0
  25. package/src/lib/repos/release/submissions.ts +214 -0
  26. package/src/lib/repos/release/submit-checkout.ts +271 -0
  27. package/src/lib/repos/release/submit-questions.ts +153 -0
  28. package/src/lib/repos/release/submit-service.ts +581 -174
  29. package/src/lib/repos/resolver.ts +25 -2
  30. package/src/lib/repos/seed.ts +64 -5
  31. package/src/lib/repos/store/hash.ts +68 -8
  32. package/src/lib/repos/subscription-service.ts +744 -20
  33. package/src/lib/repos/update-plan.ts +234 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Proposing a change to a recipe repository: validate first, then delegate.
2
+ * Proposing a change to a recipe repository, over the proposal's whole life.
3
3
  *
4
4
  * `submit` universally means "propose a change for maintainers to review". It
5
5
  * never publishes and never writes to a repository directly; the fork and
@@ -7,20 +7,34 @@
7
7
  * already has the contributor's credentials through that host's command line
8
8
  * tool.
9
9
  *
10
+ * One command covers a proposal from start to finish, for the branch that is
11
+ * checked out or the one `--branch` names:
12
+ *
13
+ * - no proposal yet: open one;
14
+ * - an open proposal: push whatever is new, which updates it, and replace its
15
+ * title or body when new ones are given; with nothing new, report on it;
16
+ * - a merged proposal: say so, and continue on a new branch (named, generated,
17
+ * or not at all), which gets a new proposal;
18
+ * - a proposal closed without merging: say so, and open a fresh one;
19
+ * - a pushed branch holding commits the local one lacks: git refuses the push,
20
+ * and its refusal is passed through. Nothing here ever forces a push.
21
+ *
10
22
  * This module is a SEQUENCER and nothing more. It knows the order the steps go
11
23
  * in, what each one is called, and what to say when one fails; it does not know
12
24
  * that GitHub exists, which tool proposes a change, or how a fork is spelled.
13
25
  * Every host-specific fact is asked of the provider interface and comes back as
14
- * plain data, which is what keeps a third provider a single new file.
26
+ * plain data, which is what keeps a third provider a single new file. Every
27
+ * question is asked through `SubmitQuestions`, so the command decides how a
28
+ * question is put (or which flag answered it) and this module never prompts.
15
29
  *
16
- * Two rules shape everything here:
30
+ * Three rules shape everything here:
17
31
  *
32
+ * - Everything is worked out, and every question asked, before anything is
33
+ * written. A contributor who declines, or a run that cannot ask, leaves the
34
+ * checkout exactly as it found it.
18
35
  * - Nothing is sent until the repository validates and the contributor has left
19
- * its index alone. A proposal that fails the maintainer's own checks wastes
20
- * their review. Whether the index agrees with the tags is the maintainer's
21
- * check, not the contributor's: `sous repo release --check` makes it on a full
22
- * clone, while a contributor usually works in the shallow checkout that
23
- * `sous repo link` makes, which holds almost none of the tags.
36
+ * its index alone. Whether the index agrees with the tags is the maintainer's
37
+ * check (`sous repo release --check`, on a full clone), not the contributor's.
24
38
  * - Every step announces itself BEFORE it runs, and a failure says exactly which
25
39
  * steps completed. A half-finished submission (a branch pushed, no proposal
26
40
  * opened) is a normal outcome of a network failure, and the contributor has to
@@ -34,27 +48,42 @@ import { ConfigError } from "../../errors.js";
34
48
  import { runGit, type CommandRunner } from "../providers/git.js";
35
49
  import { detectProvider } from "../providers/index.js";
36
50
  import {
51
+ supportsProposals,
37
52
  supportsSubmit,
38
53
  type CanonicalRepo,
54
+ type ProposalStatus,
55
+ type ProposalSummary,
39
56
  type ProviderOptions,
40
57
  type RepoProvider,
41
58
  type SubmitCapableProvider,
42
59
  } from "../providers/provider.js";
43
60
  import { INDEX_FILENAME } from "../formats/common.js";
44
- import type { IndexFile } from "../formats/index-file.js";
45
- import { readIndexFile } from "./index-builder.js";
46
61
  import {
47
- currentBranch,
62
+ buildChangelog,
63
+ composeProposalBody,
64
+ readManifestsAt,
65
+ renderChangelog,
66
+ snapshotOf,
67
+ type Changelog,
68
+ } from "./changelog.js";
69
+ import {
70
+ branchExists,
71
+ commitEverything,
48
72
  createBranch,
73
+ currentBranch,
49
74
  defaultBranch,
50
75
  forkPoint,
51
- lastCommitSubject,
76
+ hasCommitIdentity,
52
77
  pathChangedSince,
78
+ pathsChangedSince,
53
79
  pushBranch,
54
80
  remoteUrl,
55
81
  submitBranchName,
82
+ switchBranch,
56
83
  uncommittedChanges,
84
+ type ChangedPath,
57
85
  } from "./git-state.js";
86
+ import { recipesRefusingSubmissions, type RefusingRecipe } from "./submissions.js";
58
87
  import {
59
88
  errorsIn,
60
89
  hasErrors,
@@ -72,19 +101,52 @@ const FORK_REMOTE = "fork";
72
101
  /** What a proposal is called when the provider does not name it. */
73
102
  const DEFAULT_PROPOSAL_NOUN = "proposal";
74
103
 
104
+ /** How the contributor wants to go on after a proposal was merged. */
105
+ export type NextBranchChoice =
106
+ | { kind: "name"; name: string }
107
+ | { kind: "generate" }
108
+ | { kind: "cancel" };
109
+
110
+ /**
111
+ * Every question a submission may need answered. The command supplies these:
112
+ * it knows whether a terminal is attached and which flags were passed, and a
113
+ * question that cannot be asked raises the error naming the flag that answers
114
+ * it. The sequencer only ever asks, and only before anything is written.
115
+ */
116
+ export type SubmitQuestions = {
117
+ /** The proposal's title, when it is required and was not given. */
118
+ title(): Promise<string>;
119
+ /** The proposal's description, when it is required and was not given. */
120
+ body(): Promise<string>;
121
+ /** Whether to commit the listed paths, for `--commit`. */
122
+ confirmCommit(paths: ReadonlyArray<ChangedPath>): Promise<boolean>;
123
+ /** Whether to propose a change to recipes that say they take no proposals. */
124
+ proceedDespiteSubmissions(refusing: ReadonlyArray<RefusingRecipe>): Promise<boolean>;
125
+ /** Where to go on after the branch's proposal was merged. */
126
+ nextBranch(merged: ProposalSummary, generated: string): Promise<NextBranchChoice>;
127
+ };
128
+
75
129
  /** What `submitRepo` needs to know. */
76
130
  export type SubmitOptions = {
77
131
  /** The repository's root directory. */
78
132
  rootDir: string;
79
- /** The title for the proposal. Defaults to the last commit's subject. */
133
+ /** The title for the proposal. Required for a new one; replaces an open one's when given. */
80
134
  title?: string;
81
- /** The body for the proposal. Defaults to a summary sous writes. */
135
+ /** The description for the proposal. Required for a new one; replaces an open one's when given. */
82
136
  body?: string;
83
- /** Whether to open the proposal as a draft. */
137
+ /** Whether to open a new proposal as a draft. */
84
138
  draft?: boolean;
85
- /** When true, everything is checked and reported and nothing is sent. */
139
+ /** The branch to work with, instead of the one that is checked out. */
140
+ branch?: string;
141
+ /** When true, only report on the branch's proposal; nothing is checked, written or sent. */
142
+ statusOnly?: boolean;
143
+ /** When true, uncommitted changes are committed (after a confirmation) rather than refused. */
144
+ commit?: boolean;
145
+ /** When true, everything is checked and reported and nothing is written or sent. */
86
146
  dryRun?: boolean;
87
- /** When the submission is happening; decides the branch name. Defaults to now. */
147
+ /** How questions are asked. Defaults to refusing every one of them. */
148
+ questions?: SubmitQuestions;
149
+ /** When the submission is happening; decides a generated branch name. Defaults to now. */
88
150
  now?: Date;
89
151
  /** How subprocesses are run. Defaults to spawning a real process. */
90
152
  run?: CommandRunner;
@@ -94,47 +156,84 @@ export type SubmitOptions = {
94
156
  onStep?: (message: string) => void;
95
157
  /** Called with anything worth saying that is not a step. */
96
158
  onNotice?: (message: string) => void;
159
+ /** Called with a warning the contributor should weigh. Defaults to `onNotice`. */
160
+ onWarning?: (message: string) => void;
97
161
  };
98
162
 
163
+ /**
164
+ * How a submission ended.
165
+ *
166
+ * - `created`: a new proposal was opened (or, on a dry run, would be).
167
+ * - `updated`: an open proposal received new commits, a new title or a new body.
168
+ * - `unchanged`: an open proposal had nothing new to receive; its status is reported.
169
+ * - `status`: `--status` reported on the branch's proposal, or on its absence.
170
+ * - `cancelled`: the contributor declined a question, and nothing was written.
171
+ */
172
+ export type SubmitOutcome = "created" | "updated" | "unchanged" | "status" | "cancelled";
173
+
99
174
  /** What a submission did. */
100
175
  export type SubmitResult = {
176
+ /** How it ended. */
177
+ outcome: SubmitOutcome;
101
178
  /** The provider the proposal went to. */
102
179
  provider: string;
180
+ /** What the provider calls a proposal. */
181
+ proposalNoun: string;
103
182
  /** The repository, as the provider understands it. */
104
183
  repo: CanonicalRepo;
105
184
  /** The branch the change is on. */
106
185
  branch: string;
107
186
  /** The branch the proposal targets. */
108
187
  baseBranch: string;
109
- /** True when the change was pushed to a fork rather than to the repository itself. */
188
+ /** True when the change goes through a fork rather than to the repository itself. */
110
189
  usedFork: boolean;
111
- /** The remote the branch was pushed to. */
190
+ /** The remote the branch was (or would be) pushed to. */
112
191
  pushedTo: string;
113
- /** The proposal's title. */
114
- title: string;
192
+ /** The proposal's title, when this run set one. */
193
+ title?: string;
115
194
  /** The proposal's URL, when the provider reported one. */
116
195
  url?: string;
196
+ /** The branch's proposal as it stood before this run, when it had one. */
197
+ previous?: ProposalSummary;
198
+ /** Where the proposal stands, when this run reported on it. */
199
+ status?: ProposalStatus;
200
+ /** The paths committed for the contributor, with `--commit`. */
201
+ committed?: string[];
202
+ /** The changelog, when this run generated one. */
203
+ changelog?: Changelog;
204
+ /** Recipes the change touches that do not take proposals. */
205
+ refusing: RefusingRecipe[];
117
206
  /** Every step that completed, in order. */
118
207
  completed: string[];
119
- /** True when nothing was actually sent. */
208
+ /** True when nothing was actually written or sent. */
120
209
  dryRun: boolean;
121
210
  };
122
211
 
212
+ /** The questions a caller that supplied none gets: each one refuses. */
213
+ const REFUSING_QUESTIONS: SubmitQuestions = {
214
+ title: async () => {
215
+ throw new ConfigError("A new proposal needs a title; pass '--title'.");
216
+ },
217
+ body: async () => {
218
+ throw new ConfigError("A new proposal needs a description; pass '--body'.");
219
+ },
220
+ confirmCommit: async () => false,
221
+ proceedDespiteSubmissions: async () => false,
222
+ nextBranch: async () => ({ kind: "cancel" }),
223
+ };
224
+
123
225
  /**
124
- * Validates a repository and proposes its committed changes upstream.
226
+ * Validates a repository and carries its branch's proposal one step further:
227
+ * opens it, updates it, reports on it, or starts the next one.
125
228
  *
126
- * @param options - The repository, the proposal's text, and the testing seams.
229
+ * @param options - The repository, the proposal's text, the flags and the testing seams.
127
230
  */
128
231
  export async function submitRepo(options: SubmitOptions): Promise<SubmitResult> {
129
- const {
130
- rootDir,
131
- draft = false,
132
- dryRun = false,
133
- now = new Date(),
134
- run,
135
- } = options;
232
+ const { rootDir, draft = false, dryRun = false, now = new Date(), run } = options;
233
+ const questions = options.questions ?? REFUSING_QUESTIONS;
136
234
  const step = options.onStep ?? (() => {});
137
235
  const notice = options.onNotice ?? (() => {});
236
+ const warn = options.onWarning ?? notice;
138
237
  const completed: string[] = [];
139
238
 
140
239
  /** Runs one step, announcing it first and recording it once it succeeds. */
@@ -147,20 +246,31 @@ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult>
147
246
  return result;
148
247
  };
149
248
 
249
+ /** Runs one read-only check, announcing it first. */
250
+ const check = async <T>(message: string, done: string, action: () => Promise<T>) => {
251
+ step(message);
252
+ const result = await action();
253
+ completed.push(done);
254
+ return result;
255
+ };
256
+
150
257
  // --- Preflight: is this a repository sous can propose a change to? --------
151
258
  //
152
- // The cheap, actionable checks come first. A repository with an uncommitted
153
- // file is the commonest reason a submission stops, and saying so is far more
154
- // useful than a content hash disagreeing because of that same uncommitted
155
- // file. The manifests are read this early only for the contribution
156
- // pointer; what the recipes say is only judged once the ground is firm.
157
-
158
- step("Reading the repository manifest and every recipe in it");
159
- const validation = validateRepo(rootDir);
160
- completed.push("Read the repository manifest and every recipe in it");
161
-
162
- step("Looking up where this repository was cloned from");
163
- const upstreamUrl = await remoteUrl(rootDir, UPSTREAM_REMOTE, { run });
259
+ // The cheap, actionable checks come first. The manifests are read this early
260
+ // only for the contribution pointer; what the recipes say is only judged once
261
+ // the ground is firm.
262
+
263
+ const validation = await check(
264
+ "Reading the repository manifest and every recipe in it",
265
+ "Read the repository manifest and every recipe in it",
266
+ async () => validateRepo(rootDir)
267
+ );
268
+
269
+ const upstreamUrl = await check(
270
+ "Looking up where this repository was cloned from",
271
+ "Looked up where this repository was cloned from",
272
+ () => remoteUrl(rootDir, UPSTREAM_REMOTE, { run })
273
+ );
164
274
  if (upstreamUrl === undefined) {
165
275
  throw new ConfigError(
166
276
  `This repository has no '${UPSTREAM_REMOTE}' remote, so sous cannot tell where to ` +
@@ -168,35 +278,158 @@ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult>
168
278
  ` Add one with 'git remote add ${UPSTREAM_REMOTE} <url>', then run the command again.`
169
279
  );
170
280
  }
171
- completed.push("Looked up where this repository was cloned from");
172
281
 
173
282
  const provider = requireSubmitProvider(upstreamUrl, validation, options.providers);
174
283
  const repo = provider.canonicalize(upstreamUrl);
284
+ const proposalNoun = provider.proposalNoun ?? DEFAULT_PROPOSAL_NOUN;
285
+ const tracksProposals = supportsProposals(provider);
175
286
 
176
287
  // Everything the provider runs, it runs inside the contributor's checkout.
177
288
  const providerOptions: ProviderOptions = { cwd: rootDir, run };
178
289
 
179
290
  const signInLabel = provider.cli?.label ?? "the repository host's command line tool";
180
- step(`Checking that ${signInLabel} is installed and signed in`);
181
- const auth = await provider.authStatus(providerOptions);
291
+ const auth = await check(
292
+ `Checking that ${signInLabel} is installed and signed in`,
293
+ `Checked that ${signInLabel} is installed and signed in`,
294
+ () => provider.authStatus(providerOptions)
295
+ );
182
296
  if (!auth.ok) {
183
297
  throw new ConfigError(`${auth.detail}\n` + contributePointer(validation));
184
298
  }
185
- completed.push(`Checked that ${signInLabel} is installed and signed in`);
186
299
 
187
- step("Checking that everything is committed");
188
- const changed = await uncommittedChanges(rootDir, { run });
189
- if (changed.length > 0) {
190
- const listed = changed.map((entry) => ` ${entry.path}`).join("\n");
300
+ const baseBranch = (await defaultBranch(rootDir, { run })) ?? "main";
301
+ const checkedOut = await currentBranch(rootDir, { run });
302
+
303
+ // --- Where the change goes: the repository itself, or a fork -------------
304
+
305
+ const canPush = await check(
306
+ "Checking whether you can push to the repository itself",
307
+ "Checked whether you can push to the repository itself",
308
+ () => provider.canPush(repo, providerOptions)
309
+ );
310
+ const usedFork = canPush === false;
311
+ const pushRemote = usedFork ? FORK_REMOTE : UPSTREAM_REMOTE;
312
+ if (canPush === undefined) {
313
+ notice(
314
+ `Sous could not tell whether you can push to ${repo.owner}/${repo.name}, so the change ` +
315
+ `goes to '${UPSTREAM_REMOTE}' as it stands.`
316
+ );
317
+ }
318
+ const knownForkOwner = usedFork ? await forkOwnerFromRemote(rootDir, provider, run) : undefined;
319
+
320
+ const base = {
321
+ provider: provider.id,
322
+ proposalNoun,
323
+ repo,
324
+ baseBranch,
325
+ usedFork,
326
+ pushedTo: pushRemote,
327
+ completed,
328
+ dryRun,
329
+ };
330
+
331
+ /** Looks up the proposal a branch was pushed for, when the provider can. */
332
+ const lookUp = async (branch: string): Promise<ProposalSummary | undefined> => {
333
+ if (!supportsProposals(provider)) return undefined;
334
+ return check(
335
+ `Looking for a ${proposalNoun} for the branch '${branch}'`,
336
+ `Looked for a ${proposalNoun} for the branch '${branch}'`,
337
+ () =>
338
+ provider.findProposal(
339
+ repo,
340
+ {
341
+ branch,
342
+ fromFork: usedFork,
343
+ ...(knownForkOwner === undefined ? {} : { forkOwner: knownForkOwner }),
344
+ },
345
+ providerOptions
346
+ )
347
+ );
348
+ };
349
+
350
+ /** Asks the provider where a proposal stands. */
351
+ const statusOf = async (proposal: ProposalSummary): Promise<ProposalStatus> => {
352
+ if (!supportsProposals(provider)) return { proposal };
353
+ return check(
354
+ `Reading where the ${proposalNoun} stands`,
355
+ `Read where the ${proposalNoun} stands`,
356
+ () => provider.proposalStatus(repo, proposal.id, providerOptions)
357
+ );
358
+ };
359
+
360
+ // --- Status only ------------------------------------------------------------
361
+
362
+ if (options.statusOnly === true) {
363
+ if (!tracksProposals) {
364
+ throw new ConfigError(
365
+ `The '${provider.id}' provider cannot look a ${proposalNoun} up after it was opened, ` +
366
+ `so sous cannot report on one.\n` +
367
+ ` Open the repository on its host to see where the ${proposalNoun} stands.`
368
+ );
369
+ }
370
+ const branch = options.branch ?? checkedOut;
371
+ if (branch === undefined) {
372
+ throw new ConfigError(
373
+ "No branch is checked out, so there is no proposal to report on.\n" +
374
+ " Name the branch with '--branch <name>'."
375
+ );
376
+ }
377
+ const found = await lookUp(branch);
378
+ const status = found === undefined ? undefined : await statusOf(found);
379
+ return {
380
+ ...base,
381
+ outcome: "status",
382
+ branch,
383
+ ...(found?.url === undefined ? {} : { url: found.url }),
384
+ ...(found === undefined ? {} : { previous: found }),
385
+ ...(status === undefined ? {} : { status }),
386
+ refusing: [],
387
+ dryRun: true,
388
+ };
389
+ }
390
+
391
+ // --- The working tree -------------------------------------------------------
392
+
393
+ const uncommitted = await check(
394
+ "Checking that everything is committed",
395
+ "Checked that everything is committed",
396
+ () => uncommittedChanges(rootDir, { run })
397
+ );
398
+ const toCommit = options.commit === true ? uncommitted : [];
399
+
400
+ if (uncommitted.length > 0 && options.commit !== true) {
401
+ const listed = uncommitted.map((entry) => ` ${entry.path}`).join("\n");
191
402
  throw new ConfigError(
192
403
  "Cannot propose a change while the working tree has uncommitted changes.\n\n" +
193
404
  `${listed}\n\n` +
194
405
  " A proposal is made of commits, so everything it should carry has to be " +
195
406
  "committed first.\n" +
196
- " Sous does not commit for you: commit these, then run the command again."
407
+ " Commit these yourself, or pass '--commit' to have sous commit them, then run " +
408
+ "the command again."
409
+ );
410
+ }
411
+ if (options.commit === true && uncommitted.length === 0) {
412
+ notice("Everything is already committed, so '--commit' has nothing to commit.");
413
+ }
414
+ if (toCommit.some((entry) => entry.path === INDEX_FILENAME)) {
415
+ throw indexEditedError(`git checkout HEAD -- ${INDEX_FILENAME}`);
416
+ }
417
+ if (toCommit.length > 0) {
418
+ // Checked before anything is written, so a missing identity is reported
419
+ // rather than surfacing as git's own error halfway through.
420
+ const identified = await check(
421
+ "Checking that git knows who is committing",
422
+ "Checked that git knows who is committing",
423
+ () => hasCommitIdentity(rootDir, { run })
197
424
  );
425
+ if (!identified) {
426
+ throw new ConfigError(
427
+ "Sous cannot commit for you, because git cannot work out who is committing.\n" +
428
+ " Set it with 'git config user.name \"Your Name\"' and " +
429
+ "'git config user.email you@example.com', then run the command again."
430
+ );
431
+ }
198
432
  }
199
- completed.push("Checked that everything is committed");
200
433
 
201
434
  // --- Validate what is about to be proposed --------------------------------
202
435
 
@@ -204,108 +437,245 @@ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult>
204
437
  assertRepoValidates(validation);
205
438
  completed.push("Checked that every recipe describes itself correctly");
206
439
 
207
- const baseBranch = (await defaultBranch(rootDir, { run })) ?? "main";
208
-
209
- step(`Checking that ${INDEX_FILENAME} was left alone`);
210
- const since = await forkPoint(rootDir, UPSTREAM_REMOTE, baseBranch, { run });
211
- if (since === undefined) {
212
- notice(
213
- `This checkout holds no copy of '${UPSTREAM_REMOTE}/${baseBranch}', so sous could not ` +
214
- `check whether ${INDEX_FILENAME} was changed.`
215
- );
216
- } else if (await pathChangedSince(rootDir, since, INDEX_FILENAME, { run })) {
217
- throw new ConfigError(
218
- `This change edits ${INDEX_FILENAME}. The index is written by the repository's own ` +
219
- `release, after a change is merged, so a proposal leaves it as it found it.\n` +
220
- ` Restore it with 'git checkout ${since.slice(0, 12)} -- ${INDEX_FILENAME}', ` +
221
- `commit that, then run the command again.`
222
- );
223
- }
224
- completed.push(`Checked that ${INDEX_FILENAME} was left alone`);
440
+ const since = await check(
441
+ `Checking that ${INDEX_FILENAME} was left alone`,
442
+ `Checked that ${INDEX_FILENAME} was left alone`,
443
+ async () => {
444
+ const point = await forkPoint(rootDir, UPSTREAM_REMOTE, baseBranch, { run });
445
+ if (point === undefined) {
446
+ notice(
447
+ `This checkout holds no copy of '${UPSTREAM_REMOTE}/${baseBranch}', so sous could not ` +
448
+ `check whether ${INDEX_FILENAME} was changed, which recipes the change touches, or ` +
449
+ `what merging it changes.`
450
+ );
451
+ } else if (await pathChangedSince(rootDir, point, INDEX_FILENAME, { run })) {
452
+ throw indexEditedError(`git checkout ${point.slice(0, 12)} -- ${INDEX_FILENAME}`);
453
+ }
454
+ return point;
455
+ }
456
+ );
225
457
 
226
- // --- The branch the change lives on ---------------------------------------
458
+ // Every path the change touches: its commits, and whatever --commit adds.
459
+ const changedPaths = [
460
+ ...(since === undefined ? [] : await pathsChangedSince(rootDir, since, { run })),
461
+ ...toCommit.map((entry) => entry.path),
462
+ ];
227
463
 
228
- const checkedOut = await currentBranch(rootDir, { run });
229
- let branch = checkedOut;
464
+ // --- Recipes that take no proposals ---------------------------------------
230
465
 
231
- if (checkedOut === undefined || checkedOut === baseBranch) {
232
- branch = submitBranchName(now);
233
- if (dryRun) {
234
- notice(`A branch named '${branch}' would be created from the current commit.`);
235
- } else {
236
- await doStep(`Creating the branch '${branch}' from the current commit`, () =>
237
- createBranch(rootDir, branch!, { run })
238
- );
466
+ const refusing = recipesRefusingSubmissions(validation, changedPaths);
467
+ if (refusing.length > 0) {
468
+ warn(describeRefusing(refusing));
469
+ if (!(await questions.proceedDespiteSubmissions(refusing))) {
470
+ return cancelled("You chose not to propose a change to those recipes.");
239
471
  }
240
472
  }
241
473
 
242
- const title =
243
- options.title ?? (await lastCommitSubject(rootDir, { run })) ?? defaultTitle(validation);
244
- const body = options.body ?? defaultBody(validation, readIndexFile(rootDir));
474
+ // --- Which branch, and which proposal -------------------------------------
245
475
 
246
- // --- Fork, push, propose --------------------------------------------------
476
+ /** Ends the run with nothing written, saying why. */
477
+ function cancelled(reason: string): SubmitResult {
478
+ notice(`${reason} Nothing was written.`);
479
+ return {
480
+ ...base,
481
+ outcome: "cancelled",
482
+ branch: options.branch ?? checkedOut ?? baseBranch,
483
+ refusing,
484
+ dryRun: true,
485
+ };
486
+ }
247
487
 
248
- let usedFork = false;
249
- let pushRemote = UPSTREAM_REMOTE;
250
- let forkOwner: string | undefined;
488
+ // The plan for the branch: stay, switch to an existing one, or create one.
489
+ let branch: string;
490
+ let branchAction: "stay" | "switch" | "create";
491
+ if (options.branch !== undefined && options.branch !== checkedOut) {
492
+ branch = options.branch;
493
+ branchAction = (await branchExists(rootDir, branch, { run })) ? "switch" : "create";
494
+ } else if (checkedOut === undefined || checkedOut === baseBranch) {
495
+ branch = options.branch ?? submitBranchName(now);
496
+ branchAction = "create";
497
+ } else {
498
+ branch = checkedOut;
499
+ branchAction = "stay";
500
+ }
251
501
 
252
- step("Checking whether you can push to the repository itself");
253
- const canPush = await provider.canPush(repo, providerOptions);
254
- completed.push("Checked whether you can push to the repository itself");
502
+ let previous: ProposalSummary | undefined;
503
+ if (branchAction !== "create") {
504
+ previous = await lookUp(branch);
505
+ } else if (tracksProposals && options.branch !== undefined) {
506
+ // A named branch that does not exist here may still have been pushed from
507
+ // another machine, with a proposal behind it.
508
+ previous = await lookUp(branch);
509
+ }
510
+ if (!tracksProposals) {
511
+ notice(
512
+ `The '${provider.id}' provider cannot look for a ${proposalNoun} that is already open, ` +
513
+ `so sous opens a new one. If this branch already has one, pushing updates it and ` +
514
+ `opening another may be refused.`
515
+ );
516
+ }
255
517
 
256
- if (canPush === undefined) {
518
+ let action: "create" | "update" = previous?.state === "open" ? "update" : "create";
519
+
520
+ if (previous?.state === "merged") {
257
521
  notice(
258
- `Sous could not tell whether you can push to ${repo.owner}/${repo.name}, so the change ` +
259
- `goes to '${UPSTREAM_REMOTE}' as it stands.`
522
+ `The ${proposalNoun} for '${branch}' was merged` +
523
+ (previous.url === undefined ? "." : `: ${previous.url}.`) +
524
+ " A merged branch takes no further changes, so the next change goes on a new branch."
260
525
  );
261
- } else if (!canPush) {
262
- usedFork = true;
263
- pushRemote = FORK_REMOTE;
264
- if (dryRun) {
265
- notice(
266
- `You cannot push to ${repo.owner}/${repo.name}, so the change would go through ` +
267
- `a fork on your own account.`
268
- );
269
- } else {
270
- forkOwner = await prepareFork(
271
- rootDir,
272
- repo,
273
- provider,
274
- providerOptions,
275
- run,
276
- doStep
277
- );
526
+ const generated = submitBranchName(now);
527
+ const choice = await questions.nextBranch(previous, generated);
528
+ if (choice.kind === "cancel") {
529
+ return cancelled("You chose not to continue on a new branch.");
278
530
  }
531
+ branch = choice.kind === "name" ? choice.name.trim() : generated;
532
+ branchAction = "create";
533
+ action = "create";
534
+ } else if (previous?.state === "closed") {
535
+ notice(
536
+ `The ${proposalNoun} for '${branch}' was closed without being merged` +
537
+ (previous.url === undefined ? "." : `: ${previous.url}.`) +
538
+ ` A fresh ${proposalNoun} is opened for the branch.`
539
+ );
540
+ }
541
+
542
+ // --- The text ---------------------------------------------------------------
543
+ //
544
+ // A new proposal needs a title and a description written by the person
545
+ // proposing it, and so does a commit sous makes for them; sous never derives
546
+ // either from commit messages. An update takes them only when given.
547
+
548
+ const needsText = action === "create" || toCommit.length > 0;
549
+ let title = blankToUndefined(options.title);
550
+ let description = blankToUndefined(options.body);
551
+ if (needsText && title === undefined) title = (await questions.title()).trim();
552
+ if (needsText && description === undefined) description = (await questions.body()).trim();
553
+ if (needsText && (title === undefined || title.length === 0)) {
554
+ throw new ConfigError(`A new ${proposalNoun} needs a title; pass '--title'.`);
555
+ }
556
+ if (needsText && (description === undefined || description.length === 0)) {
557
+ throw new ConfigError(`A new ${proposalNoun} needs a description; pass '--body'.`);
279
558
  }
280
559
 
560
+ if (toCommit.length > 0 && !(await questions.confirmCommit(toCommit))) {
561
+ return cancelled("You chose not to commit those changes.");
562
+ }
563
+
564
+ // --- The changelog ----------------------------------------------------------
565
+
566
+ const changelog = buildChangelog({
567
+ baseBranch,
568
+ base: since === undefined ? undefined : await readManifestsAt(rootDir, since, { run }),
569
+ head: snapshotOf(validation),
570
+ changedPaths,
571
+ });
572
+ const proposalBody =
573
+ description === undefined ? undefined : composeProposalBody(description, changelog);
574
+
575
+ // --- A dry run stops here ---------------------------------------------------
576
+
281
577
  if (dryRun) {
578
+ if (branchAction === "create") {
579
+ notice(`A branch named '${branch}' would be created from the current commit.`);
580
+ } else if (branchAction === "switch") {
581
+ notice(`The branch '${branch}' would be checked out.`);
582
+ }
583
+ if (toCommit.length > 0) {
584
+ notice(`${describeCount(toCommit.length, "path")} would be committed.`);
585
+ }
586
+ if (usedFork) {
587
+ notice(
588
+ `You cannot push to ${repo.owner}/${repo.name}, so the change would go through a ` +
589
+ `fork on your own account.`
590
+ );
591
+ }
282
592
  notice("Nothing was sent; this was a dry run.");
283
593
  return {
284
- provider: provider.id,
285
- repo,
286
- branch: branch!,
287
- baseBranch,
288
- usedFork,
289
- pushedTo: pushRemote,
290
- title,
291
- completed,
594
+ ...base,
595
+ outcome: action === "update" ? "updated" : "created",
596
+ branch,
597
+ ...(title === undefined ? {} : { title }),
598
+ ...(previous === undefined ? {} : { previous }),
599
+ ...(previous?.url === undefined || action !== "update" ? {} : { url: previous.url }),
600
+ changelog,
601
+ refusing,
292
602
  dryRun: true,
293
603
  };
294
604
  }
295
605
 
296
- await doStep(`Pushing '${branch}' to '${pushRemote}'`, () =>
297
- pushBranch(rootDir, pushRemote, branch!, { run })
606
+ // --- Write: the branch, the commit ------------------------------------------
607
+
608
+ if (branchAction === "create") {
609
+ await doStep(`Creating the branch '${branch}' from the current commit`, () =>
610
+ createBranch(rootDir, branch, { run })
611
+ );
612
+ } else if (branchAction === "switch") {
613
+ await doStep(`Checking out the branch '${branch}'`, () =>
614
+ switchBranch(rootDir, branch, { run })
615
+ );
616
+ }
617
+
618
+ if (toCommit.length > 0) {
619
+ await doStep(`Committing ${describeCount(toCommit.length, "path")}`, () =>
620
+ commitEverything(rootDir, commitMessage(title!, description!, changelog), { run })
621
+ );
622
+ }
623
+
624
+ // --- Fork, push, propose ----------------------------------------------------
625
+
626
+ let forkOwner = knownForkOwner;
627
+ if (usedFork) {
628
+ forkOwner = await prepareFork(rootDir, repo, provider, providerOptions, run, doStep);
629
+ }
630
+
631
+ const pushed = await doStep(`Pushing '${branch}' to '${pushRemote}'`, () =>
632
+ pushBranch(rootDir, pushRemote, branch, { run }).catch((error: unknown) => {
633
+ throw new ConfigError(
634
+ `${error instanceof Error ? error.message : String(error)}\n` +
635
+ ` Sous never forces a push. When the branch on '${pushRemote}' holds commits yours ` +
636
+ `lacks (a maintainer may have pushed to it), bring them in with ` +
637
+ `'git pull ${pushRemote} ${branch}', then run the command again.`
638
+ );
639
+ })
298
640
  );
299
641
 
300
- const proposalNoun = provider.proposalNoun ?? DEFAULT_PROPOSAL_NOUN;
642
+ if (action === "update" && previous !== undefined && supportsProposals(provider)) {
643
+ const replace = {
644
+ ...(title === undefined ? {} : { title }),
645
+ ...(proposalBody === undefined ? {} : { body: proposalBody }),
646
+ };
647
+ let url = previous.url;
648
+ if (Object.keys(replace).length > 0) {
649
+ const updated = await doStep(`Updating the ${proposalNoun}'s title and body`, () =>
650
+ provider.updateProposal(repo, previous!.id, replace, providerOptions)
651
+ );
652
+ url = updated.url ?? url;
653
+ }
654
+ const changed = pushed === "updated" || Object.keys(replace).length > 0;
655
+ const status = await statusOf(previous);
656
+ return {
657
+ ...base,
658
+ outcome: changed ? "updated" : "unchanged",
659
+ branch,
660
+ ...(title === undefined ? {} : { title }),
661
+ ...(url === undefined ? {} : { url }),
662
+ previous,
663
+ status,
664
+ ...(toCommit.length > 0 ? { committed: toCommit.map((entry) => entry.path) } : {}),
665
+ changelog,
666
+ refusing,
667
+ dryRun: false,
668
+ };
669
+ }
670
+
301
671
  const proposed = await doStep(`Opening a ${proposalNoun} for review`, () =>
302
672
  provider.proposeChange(
303
673
  repo,
304
674
  {
305
- branch: branch!,
675
+ branch,
306
676
  base: baseBranch,
307
- title,
308
- body,
677
+ title: title!,
678
+ body: proposalBody!,
309
679
  draft,
310
680
  ...(forkOwner === undefined ? {} : { head: { owner: forkOwner } }),
311
681
  },
@@ -315,15 +685,15 @@ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult>
315
685
  if (proposed.url === undefined) notice(proposed.detail);
316
686
 
317
687
  return {
318
- provider: provider.id,
319
- repo,
320
- branch: branch!,
321
- baseBranch,
322
- usedFork,
323
- pushedTo: pushRemote,
324
- title,
688
+ ...base,
689
+ outcome: "created",
690
+ branch,
691
+ title: title!,
325
692
  ...(proposed.url === undefined ? {} : { url: proposed.url }),
326
- completed,
693
+ ...(previous === undefined ? {} : { previous }),
694
+ ...(toCommit.length > 0 ? { committed: toCommit.map((entry) => entry.path) } : {}),
695
+ changelog,
696
+ refusing,
327
697
  dryRun: false,
328
698
  };
329
699
  }
@@ -340,11 +710,45 @@ function assertRepoValidates(validation: RepoValidation): void {
340
710
  );
341
711
  }
342
712
 
713
+ /** The refusal for a change that edits the index, naming how to put it back. */
714
+ function indexEditedError(restore: string): ConfigError {
715
+ return new ConfigError(
716
+ `This change edits ${INDEX_FILENAME}. The index is written by the repository's own ` +
717
+ `release, after a change is merged, so a proposal leaves it as it found it.\n` +
718
+ ` Restore it with '${restore}', commit that, then run the command again.`
719
+ );
720
+ }
721
+
343
722
  /** Renders a list of problems as an indented block. */
344
723
  function renderProblems(problems: ReadonlyArray<ValidationProblem>): string {
345
724
  return problems.map((problem) => ` ${problem.where}: ${problem.message}`).join("\n");
346
725
  }
347
726
 
727
+ /**
728
+ * Says which recipes a change touches although they take no proposals, and
729
+ * where each asks for changes to go instead.
730
+ *
731
+ * @param refusing - The recipes, as `recipesRefusingSubmissions` found them.
732
+ */
733
+ export function describeRefusing(refusing: ReadonlyArray<RefusingRecipe>): string {
734
+ const lines = [
735
+ refusing.length === 1
736
+ ? "This change touches a recipe that does not take proposed changes:"
737
+ : "This change touches recipes that do not take proposed changes:",
738
+ ];
739
+ for (const recipe of refusing) {
740
+ const where =
741
+ recipe.declaredBy === "recipe" ? "its own manifest" : "the repository manifest";
742
+ lines.push(` ${recipe.key} (${recipe.path}), as ${where} says.`);
743
+ if (recipe.instead !== undefined) lines.push(` Instead: ${recipe.instead}`);
744
+ }
745
+ lines.push(
746
+ "Merging such a change usually breaks whatever publishes those recipes, and the " +
747
+ "repository's own check refuses it."
748
+ );
749
+ return lines.join("\n");
750
+ }
751
+
348
752
  /**
349
753
  * The provider that will carry the proposal, or a ConfigError pointing the
350
754
  * contributor at whatever route the repository documents instead. The feature
@@ -386,8 +790,51 @@ function contributePointer(validation: RepoValidation): string {
386
790
  return ` This repository asks that changes be sent this way:\n ${contribute}`;
387
791
  }
388
792
 
793
+ /** A string with its surrounding whitespace removed, or undefined when nothing is left. */
794
+ function blankToUndefined(value: string | undefined): string | undefined {
795
+ if (value === undefined) return undefined;
796
+ const trimmed = value.trim();
797
+ return trimmed.length === 0 ? undefined : trimmed;
798
+ }
799
+
800
+ /** "1 path", "3 paths". */
801
+ function describeCount(count: number, noun: string): string {
802
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
803
+ }
804
+
805
+ /**
806
+ * The message `--commit` commits with: the title as the subject, then the
807
+ * description, then the changelog.
808
+ *
809
+ * @param title - The proposal's title.
810
+ * @param description - The contributor's description.
811
+ * @param changelog - The generated changelog.
812
+ */
813
+ export function commitMessage(title: string, description: string, changelog: Changelog): string {
814
+ return `${title}\n\n${description}\n\n${renderChangelog(changelog)}\n`;
815
+ }
816
+
389
817
  // --- Delegation helpers -------------------------------------------------------------------------
390
818
 
819
+ /**
820
+ * The owner of the fork a `fork` remote already points at, which is the head
821
+ * owner a proposal from it carries. Undefined when there is no such remote, or
822
+ * when its URL is not one the provider can read.
823
+ */
824
+ async function forkOwnerFromRemote(
825
+ rootDir: string,
826
+ provider: RepoProvider,
827
+ run: CommandRunner | undefined
828
+ ): Promise<string | undefined> {
829
+ const url = await remoteUrl(rootDir, FORK_REMOTE, { run });
830
+ if (url === undefined || !provider.matches(url)) return undefined;
831
+ try {
832
+ return provider.canonicalize(url).owner;
833
+ } catch {
834
+ return undefined;
835
+ }
836
+ }
837
+
391
838
  /**
392
839
  * Asks the provider to fork the repository onto the contributor's own account,
393
840
  * then makes sure a git remote points at whatever came back. The fork itself is
@@ -425,46 +872,6 @@ async function prepareFork(
425
872
  return fork.owner;
426
873
  }
427
874
 
428
- /** The title used when there is no commit subject to borrow. */
429
- function defaultTitle(validation: RepoValidation): string {
430
- return `Update the ${validation.manifest.name} recipes`;
431
- }
432
-
433
- /**
434
- * The versions merging this change would publish: every recipe whose manifest
435
- * declares a version the committed index does not list yet. Worked out from
436
- * the manifests and the index alone, so it needs none of the tags.
437
- */
438
- function versionsToPublish(
439
- validation: RepoValidation,
440
- index: IndexFile | undefined
441
- ): Array<{ key: string; version: string }> {
442
- return validation.recipes
443
- .filter((recipe) => index?.recipes[recipe.key]?.versions[recipe.manifest.version] === undefined)
444
- .map((recipe) => ({ key: recipe.key, version: recipe.manifest.version }));
445
- }
446
-
447
- /** The body sous writes when the contributor did not supply one. */
448
- function defaultBody(validation: RepoValidation, index: IndexFile | undefined): string {
449
- const lines = [
450
- `Proposed with 'sous repo submit' from the ${validation.manifest.name} repository.`,
451
- "",
452
- "Recipes in this repository:",
453
- ];
454
- for (const recipe of validation.recipes) {
455
- lines.push(`- ${recipe.key} at version ${recipe.manifest.version}`);
456
- }
457
- const toPublish = versionsToPublish(validation, index);
458
- if (toPublish.length > 0) {
459
- lines.push("");
460
- lines.push("Versions this proposal would publish once it is merged and tagged:");
461
- for (const entry of toPublish) {
462
- lines.push(`- ${entry.key} ${entry.version}`);
463
- }
464
- }
465
- return lines.join("\n");
466
- }
467
-
468
875
  /**
469
876
  * Turns a mid-flight failure into an error that says what already happened.
470
877
  * A pushed branch with no proposal behind it is a state the contributor has to