@sous-io/sous 0.2.11 → 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 the committed index is current, so a proposal never fails the
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@sous-io/sous",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "Compiles AI coding agent configuration (CLAUDE.md, skills, memories) from LiquidJS templates",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -11,7 +11,7 @@ formatVersion: 1
11
11
 
12
12
  namespace: core
13
13
  name: sous-skills
14
- version: 0.2.11
14
+ version: 0.2.12
15
15
 
16
16
  description: >-
17
17
  The skills that teach an agent what sous is and how it works: which files sous
@@ -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 its index is current. A
19
- * proposal that fails the maintainer's own checks wastes their review.
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
- buildIndex,
41
- describeIndexDrift,
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
- step("Confirming the committed index is current");
208
- const built = await buildIndex({
209
- validation,
210
- existing: readIndexFile(rootDir),
211
- sousVersion,
212
- run,
213
- });
214
- assertIndexReady(built, readIndexFile(rootDir));
215
- completed.push("Confirmed the committed index is current");
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(built, validation);
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(built: IndexBuildResult, validation: RepoValidation): string {
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
- if (built.pending.length > 0) {
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 built.pending) {
461
+ for (const entry of toPublish) {
467
462
  lines.push(`- ${entry.key} ${entry.version}`);
468
463
  }
469
464
  }