@tallyrelease/cli 0.1.1 → 0.2.0-beta.7

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.
@@ -0,0 +1,214 @@
1
+ import { computePlan, TallyError } from "@tallyrelease/core";
2
+ import { appendFileSync, writeFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { alignColumns } from "../output/columns.js";
5
+ import { count } from "../output/count.js";
6
+ import { branchName } from "../session.js";
7
+ import { compareShadow } from "../shadow/compare.js";
8
+ import { version } from "../version.js";
9
+ const CAP_ERRORS = new Set(["E_CAP", "E_CAP_BASE"]);
10
+ const short = (sha) => sha?.slice(0, 7) ?? "-";
11
+ const MANIFEST = ".release-please-manifest.json";
12
+ const RP_CONFIG = "release-please-config.json";
13
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
14
+ /** Parsed JSON object; `{}` when the file is absent. */
15
+ const readJson = (content, path, strings = false) => {
16
+ if (content === undefined) {
17
+ return {};
18
+ }
19
+ let value;
20
+ try {
21
+ value = JSON.parse(content);
22
+ }
23
+ catch (error) {
24
+ throw new TallyError("E_SHADOW_INPUT", `cannot parse "${path}": ${error instanceof Error ? error.message : String(error)}`, { path });
25
+ }
26
+ if (!isObject(value) ||
27
+ (strings && Object.values(value).some((v) => typeof v !== "string"))) {
28
+ throw new TallyError("E_SHADOW_INPUT", `"${path}" must be a JSON object${strings ? " of version strings" : ""}`, { path });
29
+ }
30
+ return value;
31
+ };
32
+ const readManifest = (content, path) => readJson(content, path, true);
33
+ // Workflow command data escaping.
34
+ const escapeData = (value) => value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
35
+ /** Full messages of the commits since each changed unit's base tag. */
36
+ const commitMessages = async (git, plan, paths) => {
37
+ const messages = new Map();
38
+ const tags = new Set(plan.units.filter((u) => paths.has(u.path)).map((u) => u.baseTag));
39
+ for (const tag of tags) {
40
+ // eslint-disable-next-line no-await-in-loop -- one log per base tag
41
+ const from = tag === null ? null : ((await git.resolve(tag)) ?? null);
42
+ // eslint-disable-next-line no-await-in-loop -- one log per base tag
43
+ for (const c of await git.log(from, plan.head)) {
44
+ messages.set(c.sha, c.message);
45
+ }
46
+ }
47
+ return messages;
48
+ };
49
+ const unlabelled = (rows) => rows.filter((r) => r.status !== "match" && r.labels.length === 0);
50
+ const text = (report) => {
51
+ const header = `shadow ${short(report.head)} (planned ${short(report.planned)}, branch ${report.branch})`;
52
+ if (report.skipped) {
53
+ return `${header}\nskipped (${report.skipped.reason}): ${report.skipped.message}\n`;
54
+ }
55
+ const table = alignColumns([
56
+ ["PATH", "UNIT", "STATUS", "BEFORE", "AFTER", "NEXT", "LABELS"],
57
+ ...report.rows.map((r) => [
58
+ r.path,
59
+ r.unit ?? "-",
60
+ r.status,
61
+ r.before ?? "-",
62
+ r.after ?? "-",
63
+ r.next ?? "-",
64
+ r.labels.join(",") || "-",
65
+ ]),
66
+ ]);
67
+ const matched = report.rows.filter((r) => r.status === "match").length;
68
+ return `${[
69
+ header,
70
+ "",
71
+ ...table,
72
+ "",
73
+ `${count(report.rows.length, "row")}: ${String(matched)} match, ${String(unlabelled(report.rows).length)} unlabelled`,
74
+ ].join("\n")}\n`;
75
+ };
76
+ const markdown = (report) => {
77
+ const pin = report.pin === null ? "" : `, pin \`${report.pin}\``;
78
+ const lines = [
79
+ "### tallyrelease shadow",
80
+ "",
81
+ `Head \`${short(report.head)}\`, planned \`${short(report.planned)}\` on \`${report.branch}\`${pin}.`,
82
+ "",
83
+ ];
84
+ if (report.skipped) {
85
+ lines.push(`Skipped (${report.skipped.reason}): ${report.skipped.message}`);
86
+ }
87
+ else {
88
+ lines.push("| Path | Unit | Status | Before | release-please | tallyrelease | Labels |", "| :-- | :-- | :-- | :-- | :-- | :-- | :-- |", ...report.rows.map((r) => `| \`${r.path}\` | ${r.unit ?? "-"} | ${r.status} | ${r.before ?? "-"} | ${r.after ?? "-"} | ${r.next ?? "-"} | ${r.labels.join(", ") || "-"} |`));
89
+ }
90
+ return `${lines.join("\n")}\n`;
91
+ };
92
+ /** Plans `report.planned` and fills the rows; a cap error becomes a skip. */
93
+ const compareAt = async (session, report, { planned, rpConfig }) => {
94
+ const { git } = session;
95
+ const { manifest, head } = report;
96
+ try {
97
+ const { plan, units, before, bumpTypes } = await session.workspaceAt(planned, async (workspace, files) => ({
98
+ plan: await computePlan(workspace, git, {
99
+ tool: report.tool,
100
+ branch: report.branch,
101
+ head: planned,
102
+ base: null,
103
+ baseSource: "none",
104
+ all: true,
105
+ fs: files,
106
+ }),
107
+ units: workspace.units.map((u) => ({
108
+ id: u.id,
109
+ path: u.root,
110
+ released: u.version !== "none",
111
+ dependsOn: workspace.graph.dependencies.get(u.id) ?? [],
112
+ })),
113
+ before: readManifest(files.read(manifest), manifest),
114
+ bumpTypes: new Set(workspace.release.bumps.keys()),
115
+ }));
116
+ const after = readManifest(session.readAt(head, manifest), manifest);
117
+ const changed = new Set(Object.keys(after).filter((p) => before[p] !== after[p]));
118
+ report.rows = compareShadow({
119
+ before,
120
+ after,
121
+ plan,
122
+ units,
123
+ rpConfig: readJson(session.readAt(head, rpConfig), rpConfig),
124
+ messages: await commitMessages(git, plan, changed),
125
+ bumpTypes,
126
+ });
127
+ report.warnings.push(...plan.warnings);
128
+ }
129
+ catch (error) {
130
+ if (!(error instanceof TallyError) || !CAP_ERRORS.has(error.code)) {
131
+ throw error;
132
+ }
133
+ report.skipped = { reason: "cap", message: error.message };
134
+ }
135
+ };
136
+ /** Writes `--out`, the step summary, stdout and annotations. */
137
+ const writeReport = (session, flags, report) => {
138
+ const { env } = session;
139
+ if (flags.out !== undefined) {
140
+ writeFileSync(resolve(session.cwd, flags.out), `${JSON.stringify(report, null, 2)}\n`);
141
+ }
142
+ const stepSummary = env["GITHUB_STEP_SUMMARY"];
143
+ if (flags.summary && stepSummary) {
144
+ appendFileSync(stepSummary, markdown(report));
145
+ }
146
+ session.stdout.write(flags.summary && !stepSummary ? markdown(report) : text(report));
147
+ if (env["GITHUB_ACTIONS"] === "true") {
148
+ for (const r of unlabelled(report.rows)) {
149
+ session.stdout.write(`::warning title=tallyrelease shadow::${escapeData(`${r.path}: ${r.status} (release-please ${r.after ?? "-"}, tallyrelease ${r.next ?? "-"})`)}\n`);
150
+ }
151
+ }
152
+ };
153
+ /** `W_SHADOW_INPUT_MISSING` for an explicit `--manifest` or `--rp-config` absent at `head`. */
154
+ const missingInputs = (session, flags, head) => [flags.manifest, flags.rpConfig]
155
+ .filter((path) => path !== undefined)
156
+ .filter((path) => session.readAt(head, path) === undefined)
157
+ .map((path) => ({
158
+ code: "W_SHADOW_INPUT_MISSING",
159
+ message: `"${path}" does not exist at ${short(head)}`,
160
+ }));
161
+ /** Compares the plan before a release-please merge with its manifest change. */
162
+ export const shadow = async (session, flags) => {
163
+ const { git } = session;
164
+ const manifest = flags.manifest ?? MANIFEST;
165
+ const head = await git.resolve(flags.head ?? "HEAD");
166
+ if (head === undefined) {
167
+ if (flags.head === undefined) {
168
+ // Surfaces E_NOT_A_REPO and E_NOT_REPO_ROOT first.
169
+ await session.workspace();
170
+ }
171
+ throw new TallyError("E_HEAD_UNRESOLVED", `"${flags.head ?? "HEAD"}" does not resolve to a commit`, { ref: flags.head });
172
+ }
173
+ const planned = (await git.resolve(`${head}^1`)) ?? null;
174
+ const sha = session.env["GITHUB_SHA"] ?? null;
175
+ const report = {
176
+ schema: 1,
177
+ tool: `tallyrelease@${version}`,
178
+ pin: flags.pin ?? null,
179
+ sha,
180
+ replay: flags.head !== undefined || (sha !== null && sha !== head),
181
+ branch: flags.branch ?? (await branchName(session)),
182
+ head,
183
+ planned,
184
+ manifest,
185
+ skipped: null,
186
+ rows: [],
187
+ warnings: missingInputs(session, flags, head),
188
+ };
189
+ if (planned !== null &&
190
+ (await git.diffFiles(planned, head)).includes(manifest)) {
191
+ await compareAt(session, report, {
192
+ planned,
193
+ rpConfig: flags.rpConfig ?? RP_CONFIG,
194
+ });
195
+ }
196
+ else {
197
+ report.skipped = {
198
+ reason: "manifest",
199
+ message: `${manifest} did not change in ${short(head)}`,
200
+ };
201
+ }
202
+ if (report.skipped) {
203
+ report.warnings.push({
204
+ code: "W_SHADOW_SKIPPED",
205
+ message: `shadow skipped (${report.skipped.reason}): ${report.skipped.message}`,
206
+ });
207
+ }
208
+ session.warn(report.warnings);
209
+ writeReport(session, flags, report);
210
+ const open = unlabelled(report.rows).length;
211
+ if (flags.strict && open > 0) {
212
+ throw new TallyError("E_SHADOW_MISMATCH", `${count(open, "unlabelled row")} (--strict)`);
213
+ }
214
+ };
@@ -11,6 +11,7 @@ export declare const configSchema: z.ZodObject<{
11
11
  patch: "patch";
12
12
  rebuild: "rebuild";
13
13
  }>>;
14
+ initialVersion: z.ZodExactOptional<z.ZodString>;
14
15
  }, z.core.$strict>>;
15
16
  units: z.ZodExactOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
16
17
  id: z.ZodExactOptional<z.ZodString>;
@@ -29,6 +30,7 @@ export declare const configSchema: z.ZodObject<{
29
30
  rebuild: "rebuild";
30
31
  }>>;
31
32
  tags: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
33
+ initialVersion: z.ZodExactOptional<z.ZodString>;
32
34
  }, z.core.$strict>>>;
33
35
  deps: z.ZodExactOptional<z.ZodArray<z.ZodObject<{
34
36
  from: z.ZodString;
@@ -38,6 +40,46 @@ export declare const configSchema: z.ZodObject<{
38
40
  }, z.core.$strict>>>;
39
41
  ignore: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
40
42
  globalInputs: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
43
+ branches: z.ZodExactOptional<z.ZodArray<z.ZodObject<{
44
+ name: z.ZodString;
45
+ line: z.ZodEnum<{
46
+ default: "default";
47
+ maintenance: "maintenance";
48
+ }>;
49
+ channel: z.ZodExactOptional<z.ZodString>;
50
+ unit: z.ZodExactOptional<z.ZodString>;
51
+ group: z.ZodExactOptional<z.ZodString>;
52
+ }, z.core.$strict>>>;
53
+ bumps: z.ZodExactOptional<z.ZodObject<{
54
+ types: z.ZodExactOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
55
+ patch: "patch";
56
+ major: "major";
57
+ minor: "minor";
58
+ }>>>;
59
+ preMajor: z.ZodExactOptional<z.ZodObject<{
60
+ breaking: z.ZodExactOptional<z.ZodEnum<{
61
+ patch: "patch";
62
+ major: "major";
63
+ minor: "minor";
64
+ }>>;
65
+ feat: z.ZodExactOptional<z.ZodEnum<{
66
+ patch: "patch";
67
+ major: "major";
68
+ minor: "minor";
69
+ }>>;
70
+ }, z.core.$strict>>;
71
+ absorbed: z.ZodExactOptional<z.ZodEnum<{
72
+ patch: "patch";
73
+ minor: "minor";
74
+ }>>;
75
+ }, z.core.$strict>>;
76
+ groups: z.ZodExactOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
77
+ units: z.ZodArray<z.ZodString>;
78
+ tags: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
79
+ }, z.core.$strict>, z.ZodObject<{
80
+ units: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
81
+ tags: z.ZodArray<z.ZodString>;
82
+ }, z.core.$strict>]>>>;
41
83
  }, z.core.$strict>;
42
84
  /** JSON schema (draft-07) of `tallyrelease.yaml` as published for editors. */
43
85
  export declare const configJsonSchema: () => {
@@ -54,6 +96,7 @@ export declare const configJsonSchema: () => {
54
96
  patch: "patch";
55
97
  rebuild: "rebuild";
56
98
  }>>;
99
+ initialVersion: z.ZodExactOptional<z.ZodString>;
57
100
  }, z.core.$strict>>;
58
101
  units: z.ZodExactOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
59
102
  id: z.ZodExactOptional<z.ZodString>;
@@ -72,6 +115,7 @@ export declare const configJsonSchema: () => {
72
115
  rebuild: "rebuild";
73
116
  }>>;
74
117
  tags: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
118
+ initialVersion: z.ZodExactOptional<z.ZodString>;
75
119
  }, z.core.$strict>>>;
76
120
  deps: z.ZodExactOptional<z.ZodArray<z.ZodObject<{
77
121
  from: z.ZodString;
@@ -81,6 +125,46 @@ export declare const configJsonSchema: () => {
81
125
  }, z.core.$strict>>>;
82
126
  ignore: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
83
127
  globalInputs: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
128
+ branches: z.ZodExactOptional<z.ZodArray<z.ZodObject<{
129
+ name: z.ZodString;
130
+ line: z.ZodEnum<{
131
+ default: "default";
132
+ maintenance: "maintenance";
133
+ }>;
134
+ channel: z.ZodExactOptional<z.ZodString>;
135
+ unit: z.ZodExactOptional<z.ZodString>;
136
+ group: z.ZodExactOptional<z.ZodString>;
137
+ }, z.core.$strict>>>;
138
+ bumps: z.ZodExactOptional<z.ZodObject<{
139
+ types: z.ZodExactOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
140
+ patch: "patch";
141
+ major: "major";
142
+ minor: "minor";
143
+ }>>>;
144
+ preMajor: z.ZodExactOptional<z.ZodObject<{
145
+ breaking: z.ZodExactOptional<z.ZodEnum<{
146
+ patch: "patch";
147
+ major: "major";
148
+ minor: "minor";
149
+ }>>;
150
+ feat: z.ZodExactOptional<z.ZodEnum<{
151
+ patch: "patch";
152
+ major: "major";
153
+ minor: "minor";
154
+ }>>;
155
+ }, z.core.$strict>>;
156
+ absorbed: z.ZodExactOptional<z.ZodEnum<{
157
+ patch: "patch";
158
+ minor: "minor";
159
+ }>>;
160
+ }, z.core.$strict>>;
161
+ groups: z.ZodExactOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
162
+ units: z.ZodArray<z.ZodString>;
163
+ tags: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
164
+ }, z.core.$strict>, z.ZodObject<{
165
+ units: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
166
+ tags: z.ZodArray<z.ZodString>;
167
+ }, z.core.$strict>]>>>;
84
168
  }, z.core.$strict>>;
85
169
  $schema?: "https://json-schema.org/draft/2020-12/schema" | "http://json-schema.org/draft-07/schema#" | "http://json-schema.org/draft-04/schema#";
86
170
  $anchor?: string;
@@ -6,6 +6,15 @@ const propagate = z.enum(["auto", "patch", "rebuild"]).meta({
6
6
  const tagFormat = z.string().meta({
7
7
  description: "Tag template. Defaults to {id}-v{version}, or v{version} for the root unit.",
8
8
  });
9
+ const initialVersion = z.string().meta({
10
+ description: "Version of the first release, parsed by the unit's scheme. Defaults to the scheme's initial version (semver: 1.0.0).",
11
+ });
12
+ const bumpLevel = z.enum(["major", "minor", "patch"]);
13
+ const unitId = z.string().regex(/^(?!\.\.?$)[A-Za-z0-9._-]+$/);
14
+ const groupUnits = z.array(unitId).meta({ description: "Member unit ids." });
15
+ const groupTags = z
16
+ .array(z.string())
17
+ .meta({ description: "Selects released units with any of these tags." });
9
18
  const unitOverlay = z.strictObject({
10
19
  id: z
11
20
  .string()
@@ -47,6 +56,7 @@ const unitOverlay = z.strictObject({
47
56
  description: "Labels for filtering with affected --tag (letters, digits, dot, underscore, dash), for example docker or npm.",
48
57
  })
49
58
  .exactOptional(),
59
+ initialVersion: initialVersion.exactOptional(),
50
60
  });
51
61
  /** Schema of `tallyrelease.yaml`. */
52
62
  export const configSchema = z.strictObject({
@@ -70,6 +80,7 @@ export const configSchema = z.strictObject({
70
80
  .exactOptional(),
71
81
  tagFormat: tagFormat.exactOptional(),
72
82
  propagate: propagate.exactOptional(),
83
+ initialVersion: initialVersion.exactOptional(),
73
84
  })
74
85
  .meta({ description: "Defaults applied to every unit." })
75
86
  .exactOptional(),
@@ -102,6 +113,82 @@ export const configSchema = z.strictObject({
102
113
  globalInputs: globs.meta({
103
114
  description: "Globs of files whose change affects all units, unioned with plugin defaults.",
104
115
  }),
116
+ branches: z
117
+ .array(z.strictObject({
118
+ name: z.string().meta({
119
+ description: "Branch name pattern; supports * and the captures {major} and {minor}.",
120
+ }),
121
+ line: z.enum(["default", "maintenance"]).meta({
122
+ description: "Release line of matching branches.",
123
+ }),
124
+ channel: z
125
+ .string()
126
+ .meta({
127
+ description: "Prerelease channel (letters, digits, dash); may use {major} and {minor} captured by name.",
128
+ })
129
+ .exactOptional(),
130
+ unit: z
131
+ .string()
132
+ .meta({
133
+ description: "Maintenance rules: ungrouped released unit whose version must be on the line captured by name. Excludes group.",
134
+ })
135
+ .exactOptional(),
136
+ group: z
137
+ .string()
138
+ .meta({
139
+ description: "Maintenance rules: fixed group whose version must be on the line captured by name. Excludes unit.",
140
+ })
141
+ .exactOptional(),
142
+ }))
143
+ .meta({
144
+ description: "Branch rules; the first rule matching the branch wins.",
145
+ })
146
+ .exactOptional(),
147
+ bumps: z
148
+ .strictObject({
149
+ types: z
150
+ .record(z.string(), bumpLevel)
151
+ .meta({
152
+ description: "Commit type to bump level, merged over feat: minor, fix: patch, perf: patch, revert: patch. Breaking changes bump major.",
153
+ })
154
+ .exactOptional(),
155
+ preMajor: z
156
+ .strictObject({
157
+ breaking: bumpLevel.exactOptional(),
158
+ feat: bumpLevel.exactOptional(),
159
+ })
160
+ .meta({
161
+ description: "Bump levels for breaking changes and feat while the major version is 0. Default minor for both.",
162
+ })
163
+ .exactOptional(),
164
+ absorbed: z
165
+ .enum(["patch", "minor"])
166
+ .meta({
167
+ description: "Level a released unit gets from bump-producing changes absorbed from unreleased dependencies: patch (default) or minor (feat and breaking give minor, fix gives patch).",
168
+ })
169
+ .exactOptional(),
170
+ })
171
+ .meta({ description: "How commits map to bump levels." })
172
+ .exactOptional(),
173
+ groups: z
174
+ .record(unitId, z.union([
175
+ z.strictObject({
176
+ units: groupUnits,
177
+ tags: groupTags.exactOptional(),
178
+ }),
179
+ z.strictObject({
180
+ units: groupUnits.exactOptional(),
181
+ tags: groupTags,
182
+ }),
183
+ ]))
184
+ .refine((groups) => Object.keys(groups).length > 0, {
185
+ message: "groups needs at least one group",
186
+ })
187
+ .meta({
188
+ description: "Fixed groups by name (unit id characters): members release together with one version. Each group needs units, tags or both.",
189
+ minProperties: 1,
190
+ })
191
+ .exactOptional(),
105
192
  });
106
193
  /** JSON schema (draft-07) of `tallyrelease.yaml` as published for editors. */
107
194
  export const configJsonSchema = () => ({
package/dist/fs-ctx.d.ts CHANGED
@@ -1,3 +1,10 @@
1
1
  import type { ReadonlyFs } from "@tallyrelease/core";
2
2
  /** `ReadonlyFs` over the directory `root`. */
3
3
  export declare const nodeFs: (root: string) => ReadonlyFs;
4
+ /**
5
+ * `ReadonlyFs` over the tree of commit `sha` in the repository at `root`,
6
+ * matching `nodeFs`; paths are listed once, files read lazily.
7
+ */
8
+ export declare const gitTreeFs: (root: string, sha: string) => ReadonlyFs;
9
+ /** Content of `path` at commit `ref` in the repository at `root`, undefined when absent. */
10
+ export declare const gitBlob: (root: string, ref: string, path: string) => string | undefined;
package/dist/fs-ctx.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
4
+ import picomatch from "picomatch/posix.js";
3
5
  import { globSync } from "tinyglobby";
4
6
  const IGNORED = ["**/.git/**", "**/node_modules/**", "vendor/**"];
5
7
  /** `ReadonlyFs` over the directory `root`. */
@@ -23,3 +25,65 @@ export const nodeFs = (root) => ({
23
25
  .map((p) => p.replace(/\/$/, ""))
24
26
  .sort(),
25
27
  });
28
+ /**
29
+ * `ReadonlyFs` over the tree of commit `sha` in the repository at `root`,
30
+ * matching `nodeFs`; paths are listed once, files read lazily.
31
+ */
32
+ export const gitTreeFs = (root, sha) => {
33
+ const git = (args) => execFileSync("git", ["-c", "core.quotePath=false", ...args], {
34
+ cwd: root,
35
+ maxBuffer: 256 * 1024 * 1024,
36
+ stdio: ["ignore", "pipe", "ignore"],
37
+ });
38
+ const ignored = picomatch(IGNORED, { dot: true });
39
+ const paths = git(["ls-tree", "-r", "-z", "--name-only", sha])
40
+ .toString("utf8")
41
+ .split("\0")
42
+ .filter((p) => p !== "" && !ignored(p))
43
+ .sort();
44
+ const files = new Set(paths);
45
+ const dirs = [
46
+ ...new Set(paths.flatMap((p) => {
47
+ const parts = p.split("/");
48
+ return parts.slice(1).map((_, i) => parts.slice(0, i + 1).join("/"));
49
+ })),
50
+ ]
51
+ .filter((d) => !ignored(d) && !ignored(`${d}/`))
52
+ .sort();
53
+ const dirSet = new Set(dirs);
54
+ const cache = new Map();
55
+ return {
56
+ read: (path) => {
57
+ if (!files.has(path)) {
58
+ return undefined;
59
+ }
60
+ if (!cache.has(path)) {
61
+ try {
62
+ cache.set(path, git(["cat-file", "blob", `${sha}:${path}`]).toString("utf8"));
63
+ }
64
+ catch {
65
+ cache.set(path, undefined);
66
+ }
67
+ }
68
+ return cache.get(path);
69
+ },
70
+ exists: (path) => path === "." || files.has(path) || dirSet.has(path),
71
+ glob: (patterns, opts) => {
72
+ const match = picomatch([...patterns], { dot: true });
73
+ return (opts?.dirs ? dirs : paths).filter((p) => match(p));
74
+ },
75
+ };
76
+ };
77
+ /** Content of `path` at commit `ref` in the repository at `root`, undefined when absent. */
78
+ export const gitBlob = (root, ref, path) => {
79
+ try {
80
+ return execFileSync("git", ["cat-file", "blob", `${ref}:${path}`], {
81
+ cwd: root,
82
+ maxBuffer: 256 * 1024 * 1024,
83
+ stdio: ["ignore", "pipe", "ignore"],
84
+ }).toString("utf8");
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ };
@@ -52,8 +52,12 @@ export const gitReader = (cwd, debug) => {
52
52
  return result.code === 0;
53
53
  },
54
54
  isFirstParentAncestor: async (a, b) => {
55
+ const chain = await firstParentSet(b);
56
+ if (chain.has(a)) {
57
+ return true;
58
+ }
55
59
  const sha = await resolve(a);
56
- return sha !== undefined && (await firstParentSet(b)).has(sha);
60
+ return sha !== undefined && chain.has(sha);
57
61
  },
58
62
  log: async (from, to) => {
59
63
  const out = await ok([
@@ -62,6 +66,7 @@ export const gitReader = (cwd, debug) => {
62
66
  "--no-show-signature",
63
67
  "--no-color",
64
68
  "--no-merges",
69
+ "--topo-order",
65
70
  "--no-renames",
66
71
  "--name-only",
67
72
  "--format=%x1e%H%x00%P%x00%cI%x00%B%x00",
package/dist/main.js CHANGED
@@ -3,7 +3,10 @@ import { Command, CommanderError, Option } from "commander";
3
3
  import { inspect } from "node:util";
4
4
  import { affected } from "./commands/affected.js";
5
5
  import { check } from "./commands/check.js";
6
+ import { explain } from "./commands/explain.js";
6
7
  import { graph } from "./commands/graph.js";
8
+ import { plan } from "./commands/plan.js";
9
+ import { shadow } from "./commands/shadow.js";
7
10
  import { painter } from "./output/paint.js";
8
11
  import { createSession } from "./session.js";
9
12
  import { version } from "./version.js";
@@ -62,6 +65,49 @@ export const main = async (argv, deps) => {
62
65
  }
63
66
  await affected(session(), flags);
64
67
  });
68
+ program
69
+ .command("plan")
70
+ .description("compute the release plan (read-only)")
71
+ .option("--branch <name>", "plan for this branch (default: current)")
72
+ .option("--base <ref>", "diff against this ref")
73
+ .option("--head <ref>", "plan this commit with the current config; pass --branch for its branch rule")
74
+ .option("--all", "also list released units outside the build set")
75
+ .option("--json", "print the plan (same as --format json)")
76
+ .addOption(new Option("--format <format>", "output format").choices([
77
+ "text",
78
+ "json",
79
+ ]))
80
+ .option("--out <file>", "write the plan to a file")
81
+ .option("--github-output", "append outputs to $GITHUB_OUTPUT")
82
+ .action(async (flags, cmd) => {
83
+ if (flags.json && flags.format !== undefined) {
84
+ cmd.error("--json and --format cannot be combined", { exitCode: 2 });
85
+ }
86
+ await plan(session(), flags);
87
+ });
88
+ program
89
+ .command("explain")
90
+ .description("explain why a unit is affected and how it is versioned")
91
+ .argument("<unit>", "unit id")
92
+ .option("--branch <name>", "plan for this branch (default: current)")
93
+ .option("--base <ref>", "diff against this ref")
94
+ .action(async (unit, flags) => {
95
+ await explain(session(), unit, flags);
96
+ });
97
+ program
98
+ .command("shadow")
99
+ .description("compare the plan with a release-please manifest change")
100
+ .option("--manifest <path>", "release-please manifest (default: .release-please-manifest.json)")
101
+ .option("--rp-config <path>", "release-please config (default: release-please-config.json)")
102
+ .option("--head <ref>", "compare this merge instead of HEAD (replay)")
103
+ .option("--branch <name>", "plan for this branch (default: current)")
104
+ .option("--pin <spec>", "cli version recorded in the report")
105
+ .option("--out <file>", "write the report to a file")
106
+ .option("--summary", "write markdown to $GITHUB_STEP_SUMMARY, else print it")
107
+ .option("--strict", "exit 1 on unlabelled mismatches")
108
+ .action(async (flags) => {
109
+ await shadow(session(), flags);
110
+ });
65
111
  try {
66
112
  await program.parseAsync(argv, { from: "user" });
67
113
  return 0;