@sous-io/sous 0.2.27 → 0.2.29
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/repositories-authoring.md +12 -8
- package/docs/markdown/repositories-consuming.md +8 -1
- package/docs/markdown/repositories-file-formats.md +53 -8
- package/docs/markdown/repositories-providers.md +8 -6
- package/docs/markdown/repositories-troubleshooting.md +6 -0
- package/docs/markdown/repositories-variables.md +9 -2
- package/docs/markdown/repositories.md +3 -3
- package/package.json +1 -1
- package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +11 -7
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/lock/rebuild.ts +2 -2
- package/src/commands/repo/contribute.ts +1 -10
- package/src/commands/repo/gc.ts +3 -2
- package/src/commands/repo/release.ts +20 -3
- package/src/commands/repo/search.ts +2 -1
- package/src/commands/vars/ask.ts +32 -10
- package/src/lib/refs/find.ts +202 -21
- package/src/lib/refs/index.ts +2 -0
- package/src/lib/refs/parse.ts +844 -0
- package/src/lib/refs/scopes.ts +34 -0
- package/src/lib/refs/settle.ts +219 -0
- package/src/lib/repos/catalog.ts +44 -25
- package/src/lib/repos/formats/index-file.ts +2 -1
- package/src/lib/repos/formats/recipe-manifest.ts +10 -8
- package/src/lib/repos/index.ts +0 -1
- package/src/lib/repos/lock-service.ts +3 -2
- package/src/lib/repos/locked-namespace-resolver.ts +21 -15
- package/src/lib/repos/locked-recipes.ts +2 -2
- package/src/lib/repos/namespace-resolver.ts +40 -47
- package/src/lib/repos/providers/base.ts +30 -0
- package/src/lib/repos/providers/github.ts +22 -0
- package/src/lib/repos/providers/gitlab.ts +68 -0
- package/src/lib/repos/providers/provider.ts +96 -0
- package/src/lib/repos/release/index-builder.ts +55 -17
- package/src/lib/repos/release/index.ts +1 -0
- package/src/lib/repos/release/plan.ts +8 -6
- package/src/lib/repos/release/settle.ts +237 -0
- package/src/lib/repos/release/tags.ts +3 -7
- package/src/lib/repos/resolver.ts +198 -41
- package/src/lib/repos/subscription-service.ts +262 -38
- package/src/lib/vars/mappings.ts +17 -30
- package/src/lib/repos/ref.ts +0 -513
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { namespaceOfKey } from "../refs/parse.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Namespace addressability for templates: the reserved `~` include sigil.
|
|
@@ -202,7 +203,7 @@ function describeDropped(
|
|
|
202
203
|
dropped: DroppedRecipe,
|
|
203
204
|
opts: { namespace: string; rest: string }
|
|
204
205
|
): string[] {
|
|
205
|
-
const recipe =
|
|
206
|
+
const { recipe } = splitIncludePath(opts.namespace, opts.rest);
|
|
206
207
|
if (dropped.by === "disabled-subscription") {
|
|
207
208
|
return [
|
|
208
209
|
`This project subscribes to "${dropped.subscription}", but that subscription is ` +
|
|
@@ -232,14 +233,15 @@ export type StaticNamespaceResolverOptions = {
|
|
|
232
233
|
*/
|
|
233
234
|
recipes: Record<string, string>;
|
|
234
235
|
/**
|
|
235
|
-
* What each recipe declares, mapping `<namespace>/<recipe>` to the
|
|
236
|
-
* may address. An entry
|
|
237
|
-
* namespace (`workflow`, meaning every recipe in it)
|
|
238
|
-
*
|
|
236
|
+
* What each recipe declares, mapping `<namespace>/<recipe>` to the keys it
|
|
237
|
+
* may address. An entry is a recipe key (`workflow/task-files`) or a bare
|
|
238
|
+
* namespace (`workflow`, meaning every recipe in it), already settled from
|
|
239
|
+
* however the manifest wrote it. A recipe with no entry declares nothing and
|
|
240
|
+
* may address only itself.
|
|
239
241
|
*/
|
|
240
242
|
dependencies?: Record<string, string[]>;
|
|
241
243
|
/**
|
|
242
|
-
* What the project's own templates may address,
|
|
244
|
+
* What the project's own templates may address, as the same kind of keys as
|
|
243
245
|
* `dependencies`. Omit it to make every known recipe addressable from
|
|
244
246
|
* project templates, which is the rule for a real project: its templates may
|
|
245
247
|
* address everything its lockfile pins.
|
|
@@ -293,7 +295,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
293
295
|
private knownNamespaces(): string[] {
|
|
294
296
|
const names = new Set<string>();
|
|
295
297
|
for (const ref of Object.keys(this.recipes)) {
|
|
296
|
-
names.add(ref
|
|
298
|
+
names.add(namespaceOfKey(ref));
|
|
297
299
|
}
|
|
298
300
|
return [...names].sort();
|
|
299
301
|
}
|
|
@@ -301,7 +303,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
301
303
|
/** Every known recipe ref inside one namespace, sorted. */
|
|
302
304
|
private recipesIn(namespace: string): string[] {
|
|
303
305
|
return Object.keys(this.recipes)
|
|
304
|
-
.filter((ref) => ref
|
|
306
|
+
.filter((ref) => namespaceOfKey(ref) === namespace)
|
|
305
307
|
.sort();
|
|
306
308
|
}
|
|
307
309
|
|
|
@@ -329,9 +331,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
329
331
|
resolve(request: NamespaceRequest): NamespaceResolution {
|
|
330
332
|
const { namespace, rest, fromFile } = request;
|
|
331
333
|
|
|
332
|
-
const segments =
|
|
333
|
-
const recipeName = segments[0] ?? "";
|
|
334
|
-
const ref = `${namespace}/${recipeName}`;
|
|
334
|
+
const { recipe: ref, segments } = splitIncludePath(namespace, rest);
|
|
335
335
|
|
|
336
336
|
if (!this.knownNamespaces().includes(namespace)) {
|
|
337
337
|
return {
|
|
@@ -361,7 +361,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
361
361
|
return { kind: "not-a-dependency", recipe: ref, includingRecipe };
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
const inner = segments.
|
|
364
|
+
const inner = segments.join("/");
|
|
365
365
|
const resolved = path.resolve(recipeDir, inner);
|
|
366
366
|
|
|
367
367
|
// A `~namespace` reference addresses a recipe's own files. Without this the
|
|
@@ -370,7 +370,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
370
370
|
// segment check catches the written form, and the relative check catches
|
|
371
371
|
// everything else, including an absolute inner path and any symlink-free
|
|
372
372
|
// route out that normalisation would otherwise hide.
|
|
373
|
-
if (escapesRecipe(recipeDir, segments
|
|
373
|
+
if (escapesRecipe(recipeDir, segments, inner, resolved)) {
|
|
374
374
|
return { kind: "escapes-recipe", recipe: ref, reference: `${namespace}/${rest}` };
|
|
375
375
|
}
|
|
376
376
|
|
|
@@ -378,6 +378,27 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Takes the part of a `@~namespace/...` include line after the namespace apart:
|
|
383
|
+
* its first segment names the recipe, and the rest is a file path inside it.
|
|
384
|
+
* This is an include path, not a ref; it is the one place such a path is split.
|
|
385
|
+
*
|
|
386
|
+
* splitIncludePath("workflow", "task-files/_partials/resume.md")
|
|
387
|
+
* // -> { recipe: "workflow/task-files", segments: ["_partials", "resume.md"] }
|
|
388
|
+
*
|
|
389
|
+
* @param namespace - The namespace the include line named.
|
|
390
|
+
* @param rest - Everything after `~<namespace>/`.
|
|
391
|
+
*/
|
|
392
|
+
function splitIncludePath(
|
|
393
|
+
namespace: string,
|
|
394
|
+
rest: string
|
|
395
|
+
): { recipe: string; segments: string[] } {
|
|
396
|
+
const [recipeName = "", ...segments] = rest
|
|
397
|
+
.split("/")
|
|
398
|
+
.filter((segment) => segment.length > 0);
|
|
399
|
+
return { recipe: `${namespace}/${recipeName}`, segments };
|
|
400
|
+
}
|
|
401
|
+
|
|
381
402
|
/**
|
|
382
403
|
* Whether an inner path would address something outside the recipe directory.
|
|
383
404
|
*
|
|
@@ -401,46 +422,18 @@ function escapesRecipe(
|
|
|
401
422
|
}
|
|
402
423
|
|
|
403
424
|
/**
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
* `
|
|
407
|
-
*
|
|
408
|
-
* (`misc/stuff@^1.2`).
|
|
409
|
-
*
|
|
410
|
-
* @param ref - A reference as written in a manifest or subscription entry.
|
|
411
|
-
* @returns The bare `<namespace>` or `<namespace>/<recipe>` form.
|
|
412
|
-
*/
|
|
413
|
-
export function normalizeRef(ref: string): string {
|
|
414
|
-
const trimmed = ref.trim();
|
|
415
|
-
|
|
416
|
-
// A locator URL names the repository first and the recipe last, so the two
|
|
417
|
-
// trailing segments are the ref; everything before them is where it lives.
|
|
418
|
-
const scheme = trimmed.indexOf("://");
|
|
419
|
-
const body =
|
|
420
|
-
scheme === -1
|
|
421
|
-
? trimmed.includes(":")
|
|
422
|
-
? trimmed.slice(trimmed.indexOf(":") + 1)
|
|
423
|
-
: trimmed
|
|
424
|
-
: trimmed.slice(scheme + 3).split("/").slice(-2).join("/");
|
|
425
|
-
|
|
426
|
-
const at = body.lastIndexOf("@");
|
|
427
|
-
return (at > 0 ? body.slice(0, at) : body).trim();
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Whether a list of declared refs covers a recipe, either by naming the recipe
|
|
432
|
-
* itself or by naming its whole namespace.
|
|
425
|
+
* Whether a list of declared keys covers a recipe, either by naming the recipe
|
|
426
|
+
* itself or by naming its whole namespace. The keys are already settled
|
|
427
|
+
* (`namespace` or `namespace/recipe`): whatever form a manifest wrote a
|
|
428
|
+
* dependency in, the lockfile records the key it resolved to.
|
|
433
429
|
*
|
|
434
|
-
* @param declared - Declared
|
|
430
|
+
* @param declared - Declared keys (dependencies, or the project's subscriptions).
|
|
435
431
|
* @param namespace - The namespace being addressed.
|
|
436
432
|
* @param ref - The fully qualified recipe ref being addressed.
|
|
437
433
|
* @returns True when the reference is in scope.
|
|
438
434
|
*/
|
|
439
435
|
function declaresRef(declared: string[], namespace: string, ref: string): boolean {
|
|
440
|
-
return declared.some((entry) =>
|
|
441
|
-
const normalized = normalizeRef(entry);
|
|
442
|
-
return normalized === ref || normalized === namespace;
|
|
443
|
-
});
|
|
436
|
+
return declared.some((entry) => entry === ref || entry === namespace);
|
|
444
437
|
}
|
|
445
438
|
|
|
446
439
|
/**
|
|
@@ -37,6 +37,8 @@ import type {
|
|
|
37
37
|
ProviderId,
|
|
38
38
|
ProviderOptions,
|
|
39
39
|
RepoProvider,
|
|
40
|
+
LocationReading,
|
|
41
|
+
WrittenLocation,
|
|
40
42
|
} from "./provider.js";
|
|
41
43
|
|
|
42
44
|
/**
|
|
@@ -68,6 +70,34 @@ export abstract class ProviderBase implements RepoProvider {
|
|
|
68
70
|
options?: ProviderOptions
|
|
69
71
|
): Promise<void>;
|
|
70
72
|
|
|
73
|
+
// --- Reading a location ----------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/** This provider's public host, declared by a provider that has one. */
|
|
76
|
+
declare readonly defaultHost?: string;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* No readings: a provider that does not override this reads no location
|
|
80
|
+
* inside its repositories, so a ref naming one is refused with a sentence.
|
|
81
|
+
*
|
|
82
|
+
* @param _location - The written location; unused.
|
|
83
|
+
*/
|
|
84
|
+
readLocation(_location: WrittenLocation): LocationReading[] {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `<id>://<host>/<repoPath>/<rest>`, with the host left out when it is this
|
|
90
|
+
* provider's default.
|
|
91
|
+
*
|
|
92
|
+
* @param host - The repository's host.
|
|
93
|
+
* @param repoPath - The repository's path on that host.
|
|
94
|
+
* @param rest - What is named inside it.
|
|
95
|
+
*/
|
|
96
|
+
formatLocator(host: string, repoPath: string, rest: string): string {
|
|
97
|
+
const where = host === this.defaultHost ? repoPath : `${host}/${repoPath}`;
|
|
98
|
+
return `${this.id}://${where}${rest.length === 0 ? "" : `/${rest}`}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
71
101
|
// --- Subprocess plumbing ---------------------------------------------------
|
|
72
102
|
|
|
73
103
|
/**
|
|
@@ -24,7 +24,11 @@ import { fetchText, type FetchLike } from "./http.js";
|
|
|
24
24
|
import {
|
|
25
25
|
buildCanonicalRepo,
|
|
26
26
|
invalidRepoUrl,
|
|
27
|
+
readingsAfterRepository,
|
|
27
28
|
splitRepoUrl,
|
|
29
|
+
withoutGitSuffix,
|
|
30
|
+
type LocationReading,
|
|
31
|
+
type WrittenLocation,
|
|
28
32
|
type AuthStatus,
|
|
29
33
|
type CanonicalRepo,
|
|
30
34
|
type ChangeProposal,
|
|
@@ -83,6 +87,24 @@ export class GithubProvider extends ProviderBase {
|
|
|
83
87
|
/** What GitHub calls a proposal. */
|
|
84
88
|
readonly proposalNoun = "pull request";
|
|
85
89
|
|
|
90
|
+
/** The host a `github://` locator means when it names none. */
|
|
91
|
+
readonly defaultHost = GITHUB_HOST;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A GitHub repository is always an owner and a name, so the first two
|
|
95
|
+
* segments are the repository and everything after them names something
|
|
96
|
+
* inside it: a namespace, a recipe, or a browser path after `tree/` or
|
|
97
|
+
* `blob/`.
|
|
98
|
+
*
|
|
99
|
+
* @param location - The host and the segments after it.
|
|
100
|
+
*/
|
|
101
|
+
readLocation(location: WrittenLocation): LocationReading[] {
|
|
102
|
+
const { segments } = location;
|
|
103
|
+
if (segments.length < 2) return [];
|
|
104
|
+
const repoPath = `${segments[0]}/${withoutGitSuffix(segments[1]!)}`;
|
|
105
|
+
return readingsAfterRepository(repoPath, segments.slice(2));
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
matches(url: string): boolean {
|
|
87
109
|
const parts = splitRepoUrl(url);
|
|
88
110
|
return parts !== undefined && parts.host === GITHUB_HOST;
|
|
@@ -28,7 +28,11 @@ import { fetchText, type FetchLike } from "./http.js";
|
|
|
28
28
|
import {
|
|
29
29
|
buildCanonicalRepo,
|
|
30
30
|
invalidRepoUrl,
|
|
31
|
+
readingsAfterRepository,
|
|
31
32
|
splitRepoUrl,
|
|
33
|
+
withoutGitSuffix,
|
|
34
|
+
type LocationReading,
|
|
35
|
+
type WrittenLocation,
|
|
32
36
|
type AuthStatus,
|
|
33
37
|
type CanonicalRepo,
|
|
34
38
|
type ChangeProposal,
|
|
@@ -80,6 +84,70 @@ export class GitlabProvider extends ProviderBase {
|
|
|
80
84
|
/** What GitLab calls a proposal. */
|
|
81
85
|
readonly proposalNoun = "merge request";
|
|
82
86
|
|
|
87
|
+
/** The host a `gitlab://` locator means when it names none. */
|
|
88
|
+
readonly defaultHost = GITLAB_HOST;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A GitLab project path nests (`group/subgroup/project`), so where it ends is
|
|
92
|
+
* only certain when the location says so: a `-` segment (GitLab's own
|
|
93
|
+
* separator, as in `/-/tree/main/...`) or a `.git` suffix ends it. Otherwise
|
|
94
|
+
* every split that leaves a project of at least two segments and at most a
|
|
95
|
+
* namespace and a recipe after it is a reading, and the caller keeps the one
|
|
96
|
+
* an index confirms.
|
|
97
|
+
*
|
|
98
|
+
* readLocation({ host: "gitlab.com", segments: ["a", "b", "c", "d"] });
|
|
99
|
+
* // -> project "a/b" naming "c/d", project "a/b/c" naming "d",
|
|
100
|
+
* // and project "a/b/c/d" naming nothing
|
|
101
|
+
*
|
|
102
|
+
* @param location - The host and the segments after it.
|
|
103
|
+
*/
|
|
104
|
+
readLocation(location: WrittenLocation): LocationReading[] {
|
|
105
|
+
const { segments } = location;
|
|
106
|
+
|
|
107
|
+
const separator = segments.indexOf("-");
|
|
108
|
+
if (separator >= 2) {
|
|
109
|
+
return readingsAfterRepository(
|
|
110
|
+
this.projectPath(segments.slice(0, separator)),
|
|
111
|
+
segments.slice(separator)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const suffixed = segments.findIndex((segment) => /\.git$/i.test(segment));
|
|
116
|
+
if (suffixed >= 1) {
|
|
117
|
+
return readingsAfterRepository(
|
|
118
|
+
this.projectPath(segments.slice(0, suffixed + 1)),
|
|
119
|
+
segments.slice(suffixed + 1)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const readings: LocationReading[] = [];
|
|
124
|
+
for (let end = 2; end <= segments.length; end++) {
|
|
125
|
+
const rest = segments.slice(end);
|
|
126
|
+
if (rest.length > 2) continue;
|
|
127
|
+
readings.push({ repoPath: this.projectPath(segments.slice(0, end)), named: rest });
|
|
128
|
+
}
|
|
129
|
+
return readings;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The canonical locator always marks where the project path ends with `-`,
|
|
134
|
+
* because without it a nested group reads more than one way.
|
|
135
|
+
*
|
|
136
|
+
* @param host - The project's host.
|
|
137
|
+
* @param repoPath - The project's path on that host.
|
|
138
|
+
* @param rest - What is named inside it.
|
|
139
|
+
*/
|
|
140
|
+
formatLocator(host: string, repoPath: string, rest: string): string {
|
|
141
|
+
const where = host === this.defaultHost ? repoPath : `${host}/${repoPath}`;
|
|
142
|
+
return `${this.id}://${where}${rest.length === 0 ? "" : `/-/${rest}`}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A project path from its segments, with the last one's `.git` gone. */
|
|
146
|
+
private projectPath(segments: string[]): string {
|
|
147
|
+
const last = segments.length - 1;
|
|
148
|
+
return segments.map((segment, index) => (index === last ? withoutGitSuffix(segment) : segment)).join("/");
|
|
149
|
+
}
|
|
150
|
+
|
|
83
151
|
matches(url: string): boolean {
|
|
84
152
|
const parts = splitRepoUrl(url);
|
|
85
153
|
if (parts === undefined) return false;
|
|
@@ -56,6 +56,44 @@ export type CanonicalRepo = {
|
|
|
56
56
|
sshUrl: string;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* A location as written in a ref, taken apart only as far as every host agrees:
|
|
61
|
+
* the host, and the path segments after it. Which of those segments are the
|
|
62
|
+
* repository and which name something inside it is a host's own rule, so a
|
|
63
|
+
* provider answers that (see `RepoProvider.readLocation`).
|
|
64
|
+
*
|
|
65
|
+
* https://gitlab.com/group/sub/project/-/tree/main/recipes/workflow/alpha
|
|
66
|
+
* // -> { host: "gitlab.com",
|
|
67
|
+
* // segments: ["group", "sub", "project", "-", "tree", "main", ...] }
|
|
68
|
+
*/
|
|
69
|
+
export type WrittenLocation = {
|
|
70
|
+
/** The host, lowercased, such as `github.com`. */
|
|
71
|
+
host: string;
|
|
72
|
+
/** Every path segment after the host, in order, none of them empty. */
|
|
73
|
+
segments: string[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One way a provider reads a written location. A location on a host where a
|
|
78
|
+
* repository path can be any length (a GitLab group nests) may have several.
|
|
79
|
+
*/
|
|
80
|
+
export type LocationReading = {
|
|
81
|
+
/** The repository's path on the host, such as `sous-io/sous-recipes`, with no `.git`. */
|
|
82
|
+
repoPath: string;
|
|
83
|
+
/**
|
|
84
|
+
* The segments after the repository that name something inside it: a
|
|
85
|
+
* namespace, a namespace and a recipe, or a namespace and `*`. Empty when the
|
|
86
|
+
* location names only the repository.
|
|
87
|
+
*/
|
|
88
|
+
named?: string[];
|
|
89
|
+
/**
|
|
90
|
+
* A browser URL's path inside the repository, copied from a host's file view
|
|
91
|
+
* (after `tree/` or `blob/`), with the branch still in front of it. It is
|
|
92
|
+
* settled against the `path` each recipe's index entry records.
|
|
93
|
+
*/
|
|
94
|
+
browsed?: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
59
97
|
/** Options every provider call accepts, all of them for testing seams. */
|
|
60
98
|
export type ProviderOptions = {
|
|
61
99
|
/**
|
|
@@ -215,6 +253,27 @@ export interface RepoProvider {
|
|
|
215
253
|
readonly id: ProviderId;
|
|
216
254
|
/** What this provider can do; see ProviderFeature. */
|
|
217
255
|
readonly features: ProviderFeature[];
|
|
256
|
+
/**
|
|
257
|
+
* The host a locator such as `github://owner/repo/...` means when it names
|
|
258
|
+
* none. A provider without a public host of its own leaves it out.
|
|
259
|
+
*/
|
|
260
|
+
readonly defaultHost?: string;
|
|
261
|
+
/**
|
|
262
|
+
* Every way this provider reads a location inside one of its repositories:
|
|
263
|
+
* where the repository path ends, and whether the rest names a namespace, a
|
|
264
|
+
* recipe or a browser path. An empty list means the provider reads no such
|
|
265
|
+
* location.
|
|
266
|
+
*/
|
|
267
|
+
readLocation(location: WrittenLocation): LocationReading[];
|
|
268
|
+
/**
|
|
269
|
+
* The canonical locator for something inside one of this provider's
|
|
270
|
+
* repositories, which is how sous prints a located ref.
|
|
271
|
+
*
|
|
272
|
+
* @param host - The repository's host.
|
|
273
|
+
* @param repoPath - The repository's path on that host.
|
|
274
|
+
* @param rest - What is named inside it, such as `workflow/alpha`.
|
|
275
|
+
*/
|
|
276
|
+
formatLocator(host: string, repoPath: string, rest: string): string;
|
|
218
277
|
/** True when this provider handles the given repository URL. */
|
|
219
278
|
matches(url: string): boolean;
|
|
220
279
|
/** Takes a repository URL apart, raising a ConfigError when it does not fit. */
|
|
@@ -339,6 +398,43 @@ export function supportsSubmit(provider: RepoProvider): provider is SubmitCapabl
|
|
|
339
398
|
);
|
|
340
399
|
}
|
|
341
400
|
|
|
401
|
+
/** The words a host's file view puts between a repository and a path: `tree/` and `blob/`. */
|
|
402
|
+
const BROWSE_WORDS = new Set(["tree", "blob"]);
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* A repository's last path segment without a `.git` suffix.
|
|
406
|
+
*
|
|
407
|
+
* @param segment - The segment as written.
|
|
408
|
+
*/
|
|
409
|
+
export function withoutGitSuffix(segment: string): string {
|
|
410
|
+
return segment.replace(/\.git$/i, "");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Every reading of the segments after a repository: what they name, and, when
|
|
415
|
+
* they start with `tree/` or `blob/`, the browser path they could also be. Both
|
|
416
|
+
* are returned when both are possible (a namespace could be called `tree`), and
|
|
417
|
+
* the caller keeps the one the repository's index confirms. A leading `-`
|
|
418
|
+
* segment (GitLab's separator between a project and its pages) is dropped
|
|
419
|
+
* first, wherever it is written.
|
|
420
|
+
*
|
|
421
|
+
* readingsAfterRepository("o/r", ["tree", "main", "recipes", "workflow", "alpha"]);
|
|
422
|
+
* // -> [{ repoPath: "o/r", browsed: "main/recipes/workflow/alpha" },
|
|
423
|
+
* // { repoPath: "o/r", named: ["tree", "main", "recipes", "workflow", "alpha"] }]
|
|
424
|
+
*
|
|
425
|
+
* @param repoPath - The repository's path on its host.
|
|
426
|
+
* @param rest - The segments after it.
|
|
427
|
+
*/
|
|
428
|
+
export function readingsAfterRepository(repoPath: string, rest: string[]): LocationReading[] {
|
|
429
|
+
const after = rest[0] === "-" ? rest.slice(1) : rest;
|
|
430
|
+
const readings: LocationReading[] = [];
|
|
431
|
+
if (after.length >= 2 && BROWSE_WORDS.has(after[0]!.toLowerCase())) {
|
|
432
|
+
readings.push({ repoPath, browsed: after.slice(1).join("/") });
|
|
433
|
+
}
|
|
434
|
+
readings.push({ repoPath, named: after });
|
|
435
|
+
return readings;
|
|
436
|
+
}
|
|
437
|
+
|
|
342
438
|
/**
|
|
343
439
|
* Normalizes a repository URL for matching: trims it, drops a trailing slash
|
|
344
440
|
* and a trailing `.git`, and lowercases the scheme and host only.
|
|
@@ -60,7 +60,9 @@ import {
|
|
|
60
60
|
type ValidatedRecipe,
|
|
61
61
|
type ValidationProblem,
|
|
62
62
|
} from "./validate.js";
|
|
63
|
-
import {
|
|
63
|
+
import { isNamedReading, namespaceOfKey, parseRef, refKey } from "../../refs/parse.js";
|
|
64
|
+
import { RefSource } from "../../refs/scopes.js";
|
|
65
|
+
import type { SettledDependency } from "./settle.js";
|
|
64
66
|
|
|
65
67
|
/** A version that is ready to publish but has no tag yet. */
|
|
66
68
|
export type PendingRelease = {
|
|
@@ -113,6 +115,12 @@ export type BuildIndexOptions = {
|
|
|
113
115
|
* index that describes itself.
|
|
114
116
|
*/
|
|
115
117
|
publishing?: Record<string, string>;
|
|
118
|
+
/**
|
|
119
|
+
* The dependencies that read more than one way, as `settleDependencyLocations`
|
|
120
|
+
* settled them, keyed by the dependency as written. Each is recorded under
|
|
121
|
+
* the keys it reached, with the repository it settled on.
|
|
122
|
+
*/
|
|
123
|
+
settled?: Map<string, SettledDependency>;
|
|
116
124
|
};
|
|
117
125
|
|
|
118
126
|
/** The index file's absolute path in a repository. */
|
|
@@ -181,7 +189,15 @@ export async function buildIndex(
|
|
|
181
189
|
// repository as it stood at the tag, which is what it was released against.
|
|
182
190
|
for (const tag of recipeTags) {
|
|
183
191
|
if (Object.hasOwn(versions, tag.version)) continue;
|
|
184
|
-
const rebuilt = await rebuildTaggedVersion(
|
|
192
|
+
const rebuilt = await rebuildTaggedVersion(
|
|
193
|
+
rootDir,
|
|
194
|
+
tag,
|
|
195
|
+
recipe,
|
|
196
|
+
tagsByKey,
|
|
197
|
+
existing,
|
|
198
|
+
run,
|
|
199
|
+
options.settled
|
|
200
|
+
);
|
|
185
201
|
problems.push(...rebuilt.problems);
|
|
186
202
|
versions[tag.version] = {
|
|
187
203
|
hash: rebuilt.hash,
|
|
@@ -197,7 +213,7 @@ export async function buildIndex(
|
|
|
197
213
|
const hasTag = recipeTags.some((entry) => entry.tag === tagName);
|
|
198
214
|
const workingHash = await hashDirectory(recipe.dir);
|
|
199
215
|
const where = relativeTo(rootDir, recipe.manifestPath);
|
|
200
|
-
const declared = declaredDependencies(recipe, validation);
|
|
216
|
+
const declared = declaredDependencies(recipe, validation, options.settled);
|
|
201
217
|
|
|
202
218
|
// A version the index publishes must have a tag, with one exception: the
|
|
203
219
|
// version the manifest declares right now, which is the one a release is in
|
|
@@ -464,10 +480,12 @@ type SiblingState = {
|
|
|
464
480
|
*
|
|
465
481
|
* @param recipe - The recipe whose dependencies are being read.
|
|
466
482
|
* @param validation - The validated repository, for expanding namespace refs.
|
|
483
|
+
* @param settled - The dependencies that read more than one way, as the release settled them.
|
|
467
484
|
*/
|
|
468
485
|
function declaredDependencies(
|
|
469
486
|
recipe: ValidatedRecipe,
|
|
470
|
-
validation: RepoValidation
|
|
487
|
+
validation: RepoValidation,
|
|
488
|
+
settled: Map<string, SettledDependency> = new Map()
|
|
471
489
|
): DeclaredDependencies {
|
|
472
490
|
const byKey = new Map<string, DeclaredDependency>();
|
|
473
491
|
const namespaces = new Set<string>();
|
|
@@ -488,25 +506,43 @@ function declaredDependencies(
|
|
|
488
506
|
};
|
|
489
507
|
|
|
490
508
|
for (const written of declared) {
|
|
491
|
-
|
|
509
|
+
// A dependency that reads more than one way was settled by the release,
|
|
510
|
+
// and what it settled on is what a consumer reads instead of probing.
|
|
511
|
+
const answer = settled.get(written.trim());
|
|
512
|
+
if (answer !== undefined) {
|
|
513
|
+
for (const key of answer.keys) {
|
|
514
|
+
byKey.set(key, { kind: "remote", repo: answer.identity, range: answer.range ?? "*" });
|
|
515
|
+
}
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
let readings;
|
|
492
520
|
try {
|
|
493
|
-
|
|
521
|
+
readings = parseRef(written, RefSource.Manifest);
|
|
494
522
|
} catch {
|
|
495
523
|
// A dependency that does not parse is already reported by validation.
|
|
496
524
|
continue;
|
|
497
525
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
526
|
+
// Several readings, or a browser path, are settled above or reported by
|
|
527
|
+
// the settling step; there is nothing more to record here.
|
|
528
|
+
const parsed = readings[0]!;
|
|
529
|
+
if (readings.length !== 1 || !isNamedReading(parsed)) continue;
|
|
530
|
+
|
|
531
|
+
if (parsed.location !== undefined) {
|
|
532
|
+
// A whole namespace in another repository is read from that repository's
|
|
533
|
+
// own index by the consumer; only a recipe has a key to record.
|
|
534
|
+
if (parsed.recipe !== undefined) {
|
|
535
|
+
byKey.set(refKey(parsed), {
|
|
536
|
+
kind: "remote",
|
|
537
|
+
repo: parsed.location.identity,
|
|
538
|
+
range: parsed.range ?? "*",
|
|
539
|
+
});
|
|
540
|
+
}
|
|
505
541
|
continue;
|
|
506
542
|
}
|
|
507
543
|
|
|
508
544
|
if (parsed.recipe !== undefined) {
|
|
509
|
-
addSibling(
|
|
545
|
+
addSibling(refKey(parsed), parsed.range, true);
|
|
510
546
|
continue;
|
|
511
547
|
}
|
|
512
548
|
|
|
@@ -663,7 +699,7 @@ function checkRecordedDependencies(
|
|
|
663
699
|
if (declared.byKey.has(name)) continue;
|
|
664
700
|
// A recipe the declared namespace held when the version was published, and
|
|
665
701
|
// no longer does, is history rather than a disagreement.
|
|
666
|
-
const namespace =
|
|
702
|
+
const namespace = namespaceOfKey(name);
|
|
667
703
|
if (entry.repo === undefined && declared.namespaces.has(namespace)) continue;
|
|
668
704
|
differences.push(
|
|
669
705
|
`'${name}': the index records ${describeRecorded(entry)}, and the manifest does not ` +
|
|
@@ -741,6 +777,7 @@ type RebuiltVersion = {
|
|
|
741
777
|
* @param tagsByKey - Every release tag, grouped by recipe.
|
|
742
778
|
* @param existing - The committed index, when there is one.
|
|
743
779
|
* @param run - The command runner git calls go through.
|
|
780
|
+
* @param settled - The dependencies that read more than one way, as the release settled them.
|
|
744
781
|
*/
|
|
745
782
|
async function rebuildTaggedVersion(
|
|
746
783
|
rootDir: string,
|
|
@@ -748,7 +785,8 @@ async function rebuildTaggedVersion(
|
|
|
748
785
|
recipe: ValidatedRecipe,
|
|
749
786
|
tagsByKey: Map<string, RecipeTag[]>,
|
|
750
787
|
existing: IndexFile | undefined,
|
|
751
|
-
run: RunOptions["run"]
|
|
788
|
+
run: RunOptions["run"],
|
|
789
|
+
settled?: Map<string, SettledDependency>
|
|
752
790
|
): Promise<RebuiltVersion> {
|
|
753
791
|
return withTaggedTree(
|
|
754
792
|
rootDir,
|
|
@@ -763,7 +801,7 @@ async function rebuildTaggedVersion(
|
|
|
763
801
|
const tagged = snapshot.recipes.find((entry) => entry.key === recipe.key);
|
|
764
802
|
if (tagged !== undefined) {
|
|
765
803
|
const dependencies = resolveIndexDependencies(
|
|
766
|
-
declaredDependencies(tagged, snapshot),
|
|
804
|
+
declaredDependencies(tagged, snapshot, settled),
|
|
767
805
|
siblingState(snapshot, tagsByKey, existing)
|
|
768
806
|
);
|
|
769
807
|
return { hash, ...(dependencies === undefined ? {} : { dependencies }), problems: [] };
|
|
@@ -11,6 +11,7 @@ export * from "./validate.js";
|
|
|
11
11
|
export * from "./tags.js";
|
|
12
12
|
export * from "./git-state.js";
|
|
13
13
|
export * from "./index-builder.js";
|
|
14
|
+
export * from "./settle.js";
|
|
14
15
|
export * from "./plan.js";
|
|
15
16
|
export * from "./bump.js";
|
|
16
17
|
export * from "./submit-service.js";
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import semver from "semver";
|
|
26
26
|
import { hashDirectory } from "../store/hash.js";
|
|
27
|
-
import {
|
|
27
|
+
import { isNamedReading, parseRef, splitRecipeKey } from "../../refs/parse.js";
|
|
28
|
+
import { RefSource } from "../../refs/scopes.js";
|
|
28
29
|
import type { RunOptions } from "../providers/git.js";
|
|
29
30
|
import { listRecipeTags, tagFor, withTaggedTree, type RecipeTag } from "./tags.js";
|
|
30
31
|
import { nextVersion, type BumpLevel } from "./bump.js";
|
|
@@ -343,13 +344,14 @@ export function siblingKeysOf(validation: RepoValidation, key: string): string[]
|
|
|
343
344
|
|
|
344
345
|
const keys: string[] = [];
|
|
345
346
|
for (const written of declared) {
|
|
346
|
-
let
|
|
347
|
+
let readings;
|
|
347
348
|
try {
|
|
348
|
-
|
|
349
|
+
readings = parseRef(written, RefSource.Manifest);
|
|
349
350
|
} catch {
|
|
350
351
|
continue;
|
|
351
352
|
}
|
|
352
|
-
|
|
353
|
+
const parsed = readings[0]!;
|
|
354
|
+
if (!isNamedReading(parsed) || parsed.location !== undefined) continue;
|
|
353
355
|
|
|
354
356
|
if (parsed.recipe !== undefined) {
|
|
355
357
|
keys.push(`${parsed.namespace}/${parsed.recipe}`);
|
|
@@ -502,8 +504,8 @@ function coveredByScope(recipe: ValidatedRecipe, scope: ReleaseScope): boolean {
|
|
|
502
504
|
|
|
503
505
|
/** The tag that publishes one version of one recipe key. */
|
|
504
506
|
function tagOf(key: string, version: string): string {
|
|
505
|
-
const
|
|
506
|
-
return tagFor(
|
|
507
|
+
const { namespace, name } = splitRecipeKey(key);
|
|
508
|
+
return tagFor(namespace, name, version);
|
|
507
509
|
}
|
|
508
510
|
|
|
509
511
|
/** Renders an absolute path as a repository-relative one, with forward slashes. */
|