@sous-io/sous 0.2.10 → 0.2.12
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.
|
@@ -295,8 +295,13 @@ printing each step:
|
|
|
295
295
|
|
|
296
296
|
1. **Preflight.** An `origin` remote exists, sous recognizes its provider, that provider's command line tool
|
|
297
297
|
(`gh` or `glab`) is installed and signed in, and everything is committed.
|
|
298
|
-
2. **Validation.** The repository validates and
|
|
299
|
-
maintainer's own checks and wastes their review.
|
|
298
|
+
2. **Validation.** The repository validates, and your change leaves `sous.index.json` as it found it, so a
|
|
299
|
+
proposal never fails the maintainer's own checks and wastes their review. The index is written by the
|
|
300
|
+
repository's own release after a merge; whether it agrees with the release tags is checked there, by
|
|
301
|
+
`sous repo release --check` on a full clone, not by `submit`. That is what lets `submit` run from the shallow
|
|
302
|
+
checkout `sous repo link` makes, which holds almost none of the tags. The comparison is made against the
|
|
303
|
+
copy of the default branch your checkout holds (`origin/main`, for example); when it holds none, `submit`
|
|
304
|
+
says the check was skipped.
|
|
300
305
|
3. **Delegation.** Sous asks the provider whether you can push to the repository itself, forks it onto your
|
|
301
306
|
account when you cannot, pushes the branch, and asks the provider to open the proposal. A change sitting on
|
|
302
307
|
the default branch is moved to `sous/submit-<YYYYMMDD>-<HHMM>`.
|
package/package.json
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Command, Flags } from "@oclif/core";
|
|
2
|
-
import { SOUS_VERSION } from "../../lib/settings.js";
|
|
3
2
|
import { findRepoRoot, submitRepo } from "../../lib/repos/release/index.js";
|
|
4
3
|
import { reportCommandError } from "../../utils/command-errors.js";
|
|
5
4
|
import { nonInteractiveFlag } from "../../utils/flags.js";
|
|
@@ -90,7 +89,6 @@ export default class RepoSubmit extends Command {
|
|
|
90
89
|
body: flags.body,
|
|
91
90
|
draft: flags.draft,
|
|
92
91
|
dryRun,
|
|
93
|
-
sousVersion: SOUS_VERSION,
|
|
94
92
|
onStep: (message) => log(` ${message}`),
|
|
95
93
|
onNotice: (message) => (dryRun ? dryRunNotice(message) : log(` ${message}`)),
|
|
96
94
|
});
|
|
@@ -105,6 +105,59 @@ export async function defaultBranch(
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The commit where this checkout's own work starts: the point HEAD shares with
|
|
110
|
+
* the remote's copy of a branch. Returns undefined when this checkout holds no
|
|
111
|
+
* copy of that branch, or when git cannot find a commit the two share.
|
|
112
|
+
*
|
|
113
|
+
* A shallow clone answers this as long as HEAD was built on the branch it was
|
|
114
|
+
* cloned from, which is why a submission can rely on it where it cannot rely on
|
|
115
|
+
* tags.
|
|
116
|
+
*
|
|
117
|
+
* @param rootDir - The repository's root directory.
|
|
118
|
+
* @param remote - The remote whose branch is compared, normally `origin`.
|
|
119
|
+
* @param branch - The branch on that remote, normally the default one.
|
|
120
|
+
* @param options - The command runner to use.
|
|
121
|
+
*/
|
|
122
|
+
export async function forkPoint(
|
|
123
|
+
rootDir: string,
|
|
124
|
+
remote: string,
|
|
125
|
+
branch: string,
|
|
126
|
+
options: RunOptions = {}
|
|
127
|
+
): Promise<string | undefined> {
|
|
128
|
+
try {
|
|
129
|
+
const commit = await runGit(["merge-base", `refs/remotes/${remote}/${branch}`, "HEAD"], {
|
|
130
|
+
cwd: rootDir,
|
|
131
|
+
run: options.run,
|
|
132
|
+
});
|
|
133
|
+
return commit.length > 0 ? commit : undefined;
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* True when the commits since `since` changed a path, comparing that commit
|
|
141
|
+
* with HEAD.
|
|
142
|
+
*
|
|
143
|
+
* @param rootDir - The repository's root directory.
|
|
144
|
+
* @param since - The commit to compare HEAD with.
|
|
145
|
+
* @param relativePath - The path to check, relative to the repository root.
|
|
146
|
+
* @param options - The command runner to use.
|
|
147
|
+
*/
|
|
148
|
+
export async function pathChangedSince(
|
|
149
|
+
rootDir: string,
|
|
150
|
+
since: string,
|
|
151
|
+
relativePath: string,
|
|
152
|
+
options: RunOptions = {}
|
|
153
|
+
): Promise<boolean> {
|
|
154
|
+
const changed = await runGit(["diff", "--name-only", since, "HEAD", "--", relativePath], {
|
|
155
|
+
cwd: rootDir,
|
|
156
|
+
run: options.run,
|
|
157
|
+
});
|
|
158
|
+
return changed.length > 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
108
161
|
/**
|
|
109
162
|
* The URL of a remote, or undefined when the repository has no such remote.
|
|
110
163
|
*
|
|
@@ -15,8 +15,12 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Two rules shape everything here:
|
|
17
17
|
*
|
|
18
|
-
* - Nothing is sent until the repository validates and
|
|
19
|
-
* proposal that fails the maintainer's own checks wastes
|
|
18
|
+
* - 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.
|
|
20
24
|
* - Every step announces itself BEFORE it runs, and a failure says exactly which
|
|
21
25
|
* steps completed. A half-finished submission (a branch pushed, no proposal
|
|
22
26
|
* opened) is a normal outcome of a network failure, and the contributor has to
|
|
@@ -36,17 +40,16 @@ import {
|
|
|
36
40
|
type RepoProvider,
|
|
37
41
|
type SubmitCapableProvider,
|
|
38
42
|
} from "../providers/provider.js";
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
readIndexFile,
|
|
43
|
-
type IndexBuildResult,
|
|
44
|
-
} from "./index-builder.js";
|
|
43
|
+
import { INDEX_FILENAME } from "../formats/common.js";
|
|
44
|
+
import type { IndexFile } from "../formats/index-file.js";
|
|
45
|
+
import { readIndexFile } from "./index-builder.js";
|
|
45
46
|
import {
|
|
46
47
|
currentBranch,
|
|
47
48
|
createBranch,
|
|
48
49
|
defaultBranch,
|
|
50
|
+
forkPoint,
|
|
49
51
|
lastCommitSubject,
|
|
52
|
+
pathChangedSince,
|
|
50
53
|
pushBranch,
|
|
51
54
|
remoteUrl,
|
|
52
55
|
submitBranchName,
|
|
@@ -81,8 +84,6 @@ export type SubmitOptions = {
|
|
|
81
84
|
draft?: boolean;
|
|
82
85
|
/** When true, everything is checked and reported and nothing is sent. */
|
|
83
86
|
dryRun?: boolean;
|
|
84
|
-
/** The version of sous, recorded when the index is regenerated for the check. */
|
|
85
|
-
sousVersion: string;
|
|
86
87
|
/** When the submission is happening; decides the branch name. Defaults to now. */
|
|
87
88
|
now?: Date;
|
|
88
89
|
/** How subprocesses are run. Defaults to spawning a real process. */
|
|
@@ -127,7 +128,6 @@ export type SubmitResult = {
|
|
|
127
128
|
export async function submitRepo(options: SubmitOptions): Promise<SubmitResult> {
|
|
128
129
|
const {
|
|
129
130
|
rootDir,
|
|
130
|
-
sousVersion,
|
|
131
131
|
draft = false,
|
|
132
132
|
dryRun = false,
|
|
133
133
|
now = new Date(),
|
|
@@ -204,19 +204,27 @@ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult>
|
|
|
204
204
|
assertRepoValidates(validation);
|
|
205
205
|
completed.push("Checked that every recipe describes itself correctly");
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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`);
|
|
216
225
|
|
|
217
226
|
// --- The branch the change lives on ---------------------------------------
|
|
218
227
|
|
|
219
|
-
const baseBranch = (await defaultBranch(rootDir, { run })) ?? "main";
|
|
220
228
|
const checkedOut = await currentBranch(rootDir, { run });
|
|
221
229
|
let branch = checkedOut;
|
|
222
230
|
|
|
@@ -233,7 +241,7 @@ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult>
|
|
|
233
241
|
|
|
234
242
|
const title =
|
|
235
243
|
options.title ?? (await lastCommitSubject(rootDir, { run })) ?? defaultTitle(validation);
|
|
236
|
-
const body = options.body ?? defaultBody(
|
|
244
|
+
const body = options.body ?? defaultBody(validation, readIndexFile(rootDir));
|
|
237
245
|
|
|
238
246
|
// --- Fork, push, propose --------------------------------------------------
|
|
239
247
|
|
|
@@ -332,34 +340,6 @@ function assertRepoValidates(validation: RepoValidation): void {
|
|
|
332
340
|
);
|
|
333
341
|
}
|
|
334
342
|
|
|
335
|
-
/** Refuses to submit while the index disagrees with what the repository publishes. */
|
|
336
|
-
function assertIndexReady(
|
|
337
|
-
built: IndexBuildResult,
|
|
338
|
-
existing: ReturnType<typeof readIndexFile>
|
|
339
|
-
): void {
|
|
340
|
-
if (hasErrors(built.problems)) {
|
|
341
|
-
throw new ConfigError(
|
|
342
|
-
"This repository's index and its tags do not agree, so there is nothing worth " +
|
|
343
|
-
"proposing yet:\n\n" +
|
|
344
|
-
renderProblems(errorsIn(built.problems)) +
|
|
345
|
-
"\n\n Fix these, then run the command again."
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
if (built.stale) {
|
|
350
|
-
const drift = describeIndexDrift(existing, built.index)
|
|
351
|
-
.map((line) => ` ${line}`)
|
|
352
|
-
.join("\n");
|
|
353
|
-
throw new ConfigError(
|
|
354
|
-
"The committed index is out of date, and a maintainer's own checks would reject " +
|
|
355
|
-
"the proposal:\n\n" +
|
|
356
|
-
`${drift}\n\n` +
|
|
357
|
-
" Run 'sous repo release', commit the regenerated index, then run this command " +
|
|
358
|
-
"again."
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
343
|
/** Renders a list of problems as an indented block. */
|
|
364
344
|
function renderProblems(problems: ReadonlyArray<ValidationProblem>): string {
|
|
365
345
|
return problems.map((problem) => ` ${problem.where}: ${problem.message}`).join("\n");
|
|
@@ -450,8 +430,22 @@ function defaultTitle(validation: RepoValidation): string {
|
|
|
450
430
|
return `Update the ${validation.manifest.name} recipes`;
|
|
451
431
|
}
|
|
452
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
|
+
|
|
453
447
|
/** The body sous writes when the contributor did not supply one. */
|
|
454
|
-
function defaultBody(
|
|
448
|
+
function defaultBody(validation: RepoValidation, index: IndexFile | undefined): string {
|
|
455
449
|
const lines = [
|
|
456
450
|
`Proposed with 'sous repo submit' from the ${validation.manifest.name} repository.`,
|
|
457
451
|
"",
|
|
@@ -460,10 +454,11 @@ function defaultBody(built: IndexBuildResult, validation: RepoValidation): strin
|
|
|
460
454
|
for (const recipe of validation.recipes) {
|
|
461
455
|
lines.push(`- ${recipe.key} at version ${recipe.manifest.version}`);
|
|
462
456
|
}
|
|
463
|
-
|
|
457
|
+
const toPublish = versionsToPublish(validation, index);
|
|
458
|
+
if (toPublish.length > 0) {
|
|
464
459
|
lines.push("");
|
|
465
460
|
lines.push("Versions this proposal would publish once it is merged and tagged:");
|
|
466
|
-
for (const entry of
|
|
461
|
+
for (const entry of toPublish) {
|
|
467
462
|
lines.push(`- ${entry.key} ${entry.version}`);
|
|
468
463
|
}
|
|
469
464
|
}
|