rman 2.5.0 → 2.6.0

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.
@@ -98,6 +98,48 @@ declare const config: {
98
98
  describe: string;
99
99
  type: "boolean";
100
100
  };
101
+ /**
102
+ * **What one changelog file is about.** `'package'` gives every package its own; `'group'` gives
103
+ * one to each set of packages that versions and releases together (`.rmanrc group`), written at
104
+ * the repository root - `CHANGELOG.md` for the default group, `CHANGELOG-<name>.md` for a named
105
+ * one. A `group: false` package is a group of itself either way, so its file stays its own.
106
+ *
107
+ * **`'package'` is the default because changing a repository's layout silently is not something a
108
+ * minor release may do**, not because it is the better answer. For a repository releasing along
109
+ * one line it is measurably the worse one: every package is bumped together, so most of them have
110
+ * no commit of their own and get a file that only ever says "Updated dependencies" - measured on
111
+ * `panates/sqb`, fifteen of seventeen. The whole release is then readable nowhere.
112
+ *
113
+ * Read off the repository root and nowhere else - see `changelogGroupBy`.
114
+ */
115
+ /**
116
+ * **The panel is drawn on stderr, not stdout**, which is the whole reason this needed a change
117
+ * rather than a call: `changelog` is `printsDocument`, so `rman changelog > NOTES.md` would
118
+ * otherwise capture the panel's cursor-movement codes into the notes. Same reasoning the status
119
+ * region already carries.
120
+ */
121
+ /**
122
+ * **Implies `--write`, because there is nothing else it could mean.** "Rebuild the changelog"
123
+ * with no file to rebuild would either print (which `rman changelog --from <first-tag>` already
124
+ * does) or silently do nothing; neither is what was asked for.
125
+ */
126
+ rebuild: {
127
+ target: "cli";
128
+ describe: string;
129
+ type: "boolean";
130
+ };
131
+ progress: {
132
+ target: "both";
133
+ describe: string;
134
+ type: "boolean";
135
+ };
136
+ groupBy: {
137
+ target: "both";
138
+ cliName: string;
139
+ describe: string;
140
+ type: "string";
141
+ choices: readonly ["package", "group"];
142
+ };
101
143
  startingAt: {
102
144
  target: "both";
103
145
  cliName: string;
@@ -278,6 +320,48 @@ declare const changelogCommand: (app: import("../index.js").RmanApplication) =>
278
320
  describe: string;
279
321
  type: "boolean";
280
322
  };
323
+ /**
324
+ * **What one changelog file is about.** `'package'` gives every package its own; `'group'` gives
325
+ * one to each set of packages that versions and releases together (`.rmanrc group`), written at
326
+ * the repository root - `CHANGELOG.md` for the default group, `CHANGELOG-<name>.md` for a named
327
+ * one. A `group: false` package is a group of itself either way, so its file stays its own.
328
+ *
329
+ * **`'package'` is the default because changing a repository's layout silently is not something a
330
+ * minor release may do**, not because it is the better answer. For a repository releasing along
331
+ * one line it is measurably the worse one: every package is bumped together, so most of them have
332
+ * no commit of their own and get a file that only ever says "Updated dependencies" - measured on
333
+ * `panates/sqb`, fifteen of seventeen. The whole release is then readable nowhere.
334
+ *
335
+ * Read off the repository root and nowhere else - see `changelogGroupBy`.
336
+ */
337
+ /**
338
+ * **The panel is drawn on stderr, not stdout**, which is the whole reason this needed a change
339
+ * rather than a call: `changelog` is `printsDocument`, so `rman changelog > NOTES.md` would
340
+ * otherwise capture the panel's cursor-movement codes into the notes. Same reasoning the status
341
+ * region already carries.
342
+ */
343
+ /**
344
+ * **Implies `--write`, because there is nothing else it could mean.** "Rebuild the changelog"
345
+ * with no file to rebuild would either print (which `rman changelog --from <first-tag>` already
346
+ * does) or silently do nothing; neither is what was asked for.
347
+ */
348
+ rebuild: {
349
+ target: "cli";
350
+ describe: string;
351
+ type: "boolean";
352
+ };
353
+ progress: {
354
+ target: "both";
355
+ describe: string;
356
+ type: "boolean";
357
+ };
358
+ groupBy: {
359
+ target: "both";
360
+ cliName: string;
361
+ describe: string;
362
+ type: "string";
363
+ choices: readonly ["package", "group"];
364
+ };
281
365
  startingAt: {
282
366
  target: "both";
283
367
  cliName: string;
@@ -1,8 +1,10 @@
1
+ import path from 'node:path';
1
2
  import colors from 'ansi-colors';
2
3
  import { registerCommand } from '../interfaces/rman-config.interface.js';
3
4
  import { ChangeHashService } from '../services/change-hash.service.js';
4
5
  import { Logger, resolveRootLogLevel } from '../utils/logger.js';
5
6
  import { fromRootOption, packageFilterOptions, readPackageFilterOptions } from '../utils/package-filter.js';
7
+ import { ProgressPanel } from '../utils/progress-panel.js';
6
8
  const COMMAND = 'changelog';
7
9
  const config = {
8
10
  ...packageFilterOptions,
@@ -122,6 +124,50 @@ const config = {
122
124
  '--no-unreleased for a changelog of released history only',
123
125
  type: 'boolean',
124
126
  },
127
+ /**
128
+ * **What one changelog file is about.** `'package'` gives every package its own; `'group'` gives
129
+ * one to each set of packages that versions and releases together (`.rmanrc group`), written at
130
+ * the repository root - `CHANGELOG.md` for the default group, `CHANGELOG-<name>.md` for a named
131
+ * one. A `group: false` package is a group of itself either way, so its file stays its own.
132
+ *
133
+ * **`'package'` is the default because changing a repository's layout silently is not something a
134
+ * minor release may do**, not because it is the better answer. For a repository releasing along
135
+ * one line it is measurably the worse one: every package is bumped together, so most of them have
136
+ * no commit of their own and get a file that only ever says "Updated dependencies" - measured on
137
+ * `panates/sqb`, fifteen of seventeen. The whole release is then readable nowhere.
138
+ *
139
+ * Read off the repository root and nowhere else - see `changelogGroupBy`.
140
+ */
141
+ /**
142
+ * **The panel is drawn on stderr, not stdout**, which is the whole reason this needed a change
143
+ * rather than a call: `changelog` is `printsDocument`, so `rman changelog > NOTES.md` would
144
+ * otherwise capture the panel's cursor-movement codes into the notes. Same reasoning the status
145
+ * region already carries.
146
+ */
147
+ /**
148
+ * **Implies `--write`, because there is nothing else it could mean.** "Rebuild the changelog"
149
+ * with no file to rebuild would either print (which `rman changelog --from <first-tag>` already
150
+ * does) or silently do nothing; neither is what was asked for.
151
+ */
152
+ rebuild: {
153
+ target: 'cli',
154
+ describe: 'Regenerate each changelog file from the whole history instead of appending to it - ' +
155
+ 'implies --write, and replaces what is in the file rather than prepending to it',
156
+ type: 'boolean',
157
+ },
158
+ progress: {
159
+ target: 'both',
160
+ describe: 'Show a live progress panel (default: true; auto-disabled when stderr is not a TTY)',
161
+ type: 'boolean',
162
+ },
163
+ groupBy: {
164
+ target: 'both',
165
+ cliName: 'group-by',
166
+ describe: 'What one changelog file covers: "package" (default, one per package) or "group" (one per ' +
167
+ 'set of packages released together, at the repository root)',
168
+ type: 'string',
169
+ choices: ['package', 'group'],
170
+ },
125
171
  startingAt: {
126
172
  target: 'both',
127
173
  cliName: 'starting-at',
@@ -148,13 +194,20 @@ const changelogCommand = registerCommand(app => {
148
194
  ],
149
195
  handler: async (args) => {
150
196
  const from = args.from;
151
- const write = args.write;
197
+ const rebuild = !!args.rebuild;
198
+ const write = args.write || rebuild;
152
199
  const logger = new Logger(args.logLevel ?? resolveRootLogLevel(repository));
153
- if (!from || from === ChangeHashService.AUTO) {
154
- // Auto-detection is mostly local git work, but the registry fallback it can reach for
155
- // (only when a package has no tag at all, and only if the package's own ecosystem provides
156
- // one) is a network round trip per package - without this, the command looks hung for that
157
- // stretch instead of just busy.
200
+ /**
201
+ * **The panel replaces the static line, and only where it can actually draw.** That line
202
+ * ("Detecting each package's last release...") answered "did it start" and then said nothing
203
+ * for the rest of the run - which is the longer half: measured on a `panates/sqb` backfill,
204
+ * boundary detection is 1.2s and reading the commits is 6.6s, one target at a time.
205
+ *
206
+ * Off it falls back to the line, because the two cases it is off in are exactly the two the
207
+ * line is for: a pipe, and `--no-progress`.
208
+ */
209
+ const panel = new ProgressPanel('CHANGELOG', (args.progress ?? resolveProgressConfig(repository)) !== false && !!process.stderr.isTTY, process.stderr);
210
+ if (!panel.enabled && (!from || from === ChangeHashService.AUTO)) {
158
211
  logger.info(colors.gray("Detecting each package's last release..."));
159
212
  }
160
213
  const options = {
@@ -167,20 +220,110 @@ const changelogCommand = registerCommand(app => {
167
220
  startingAt: args.startingAt,
168
221
  unreleased: args.unreleased,
169
222
  commitHash: args.commitHash,
223
+ groupBy: args.groupBy,
224
+ rebuild,
225
+ progress: panelReporter(panel),
170
226
  };
171
227
  const changelog = app.getService('changelog');
172
- const entries = write ? await changelog.generateToFile(options) : await changelog.getEntries(options);
228
+ panel.start();
229
+ let entries;
230
+ try {
231
+ entries = write ? await changelog.generateToFile(options) : await changelog.getEntries(options);
232
+ }
233
+ finally {
234
+ /** In a `finally`, or a throw leaves the redraw interval running and the cursor parked
235
+ * inside a half-drawn block - the process then prints its error over the panel. */
236
+ panel.stop();
237
+ }
173
238
  if (!entries.length) {
174
239
  logger.info(colors.gray('No unreleased changes.'));
175
240
  return;
176
241
  }
177
- for (const entry of entries) {
178
- if (write)
179
- logger.info(colors.green('updated'), colors.cyan(entry.label), entry.filePath);
180
- else
242
+ if (!write) {
243
+ for (const entry of entries)
181
244
  console.log(entry.content);
245
+ return;
246
+ }
247
+ /**
248
+ * **One line per file, not one per entry.** An entry is a *release*, so a backfill produces
249
+ * several for the same file and printed per entry the run repeats `updated ... CHANGELOG.md`
250
+ * once per release it found - twelve identical lines on a real repository, saying nothing
251
+ * the first did not.
252
+ *
253
+ * The path is repository-relative and the label is gone with the repetition: `packages/core/
254
+ * CHANGELOG.md` already says which package it belongs to, and under
255
+ * `changelog.groupBy: 'group'` the label names a group while the path names the file that
256
+ * was actually written - which is what the reader is being told about.
257
+ */
258
+ for (const file of new Set(entries.map(e => e.file))) {
259
+ logger.info(colors.green('updated'), path.relative(repository.dirname, file));
182
260
  }
183
261
  },
184
262
  };
185
263
  });
264
+ /** `.rmanrc changelog.progress`, off the **root** - one panel for the run, so one answer for the
265
+ * repository, the rule `run`'s `concurrency` already follows. */
266
+ function resolveProgressConfig(repository) {
267
+ const v = repository.rootPackage.config?.changelog?.progress;
268
+ return typeof v === 'boolean' ? v : undefined;
269
+ }
270
+ /**
271
+ * The panel, driven by what `ChangelogService` reports.
272
+ *
273
+ * A label sits at `pending` between its two phases on purpose: `detect` finishing does not finish
274
+ * the label, and the header's bar counts anything not pending as done - so marking it otherwise
275
+ * would show the run complete while the slow half had not started.
276
+ */
277
+ const PHASE_LABELS = {
278
+ detect: 'detecting last release',
279
+ commits: 'reading commits',
280
+ render: 'writing changelog',
281
+ };
282
+ function panelReporter(panel) {
283
+ const items = new Map();
284
+ return {
285
+ start(labels) {
286
+ for (const label of labels)
287
+ items.set(label, panel.addItem(label));
288
+ },
289
+ step(label, phase) {
290
+ const item = items.get(label);
291
+ if (!item)
292
+ return;
293
+ item.currentStep = PHASE_LABELS[phase];
294
+ item.startedAt ??= Date.now();
295
+ /**
296
+ * **`detect` leaves the row pending on purpose.** Every label enters it at once and it is
297
+ * over in milliseconds; showing all of them "running" would fill the panel with rows that
298
+ * are about to go quiet again, and the header counts anything not pending as done.
299
+ */
300
+ item.status = phase === 'detect' ? 'pending' : 'running';
301
+ /** The counter belongs to `commits` alone - there it counts real work, and a `(1/3)` over
302
+ * the other two would be counting phases, which the label already names. */
303
+ item.stepIndex = undefined;
304
+ item.stepsTotal = undefined;
305
+ },
306
+ commits(label, done, total) {
307
+ const item = items.get(label);
308
+ if (!item)
309
+ return;
310
+ /** **This is what the panel is for.** The row sat at one line for the whole of the slow
311
+ * phase - measured on `panates/sqb`, minutes for a full rebuild's 1825 commits - because
312
+ * the unit shown was files to write, and there is exactly one of those under
313
+ * `changelog.groupBy: 'group'`. The commits are the work. */
314
+ item.stepsTotal = total;
315
+ /** 0-based: the panel renders `stepIndex + 1`, the way `RunService` sets it. */
316
+ item.stepIndex = Math.max(0, done - 1);
317
+ },
318
+ done(label, wrote) {
319
+ const item = items.get(label);
320
+ if (!item)
321
+ return;
322
+ /** Nothing to document is not a failure and not a success - it is the third thing the panel
323
+ * already has a word for. */
324
+ item.status = wrote ? 'success' : 'skipped';
325
+ item.finishedAt = Date.now();
326
+ },
327
+ };
328
+ }
186
329
  export default changelogCommand;
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.5.0';
1
+ export const version = '2.6.0';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "2.5.0",
4
+ "version": "2.6.0",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -1,4 +1,5 @@
1
1
  import type { Package } from '../core/classes/package.js';
2
+ import type { Repository } from '../core/classes/repository.js';
2
3
  import { Service } from '../core/classes/service.js';
3
4
  import { type PackageFilterOptions } from '../utils/package-filter.js';
4
5
  /**
@@ -55,6 +56,18 @@ export declare class ChangelogService extends Service {
55
56
  */
56
57
  getEntries(options?: ChangelogService.Options): Promise<ChangelogService.Entry[]>;
57
58
  }
59
+ /**
60
+ * What one changelog file is about - `.rmanrc changelog.groupBy`, `'package'` when nothing says.
61
+ *
62
+ * **Read off the repository root and nowhere else.** It decides a *layout*, so there is one answer
63
+ * for the whole repository, exactly as `run`'s `concurrency` is the root's because there is one
64
+ * scheduler. Cascaded per package it would mean two packages of one group disagreeing about which
65
+ * file they share, which has no answer.
66
+ *
67
+ * `VersionService` asks too, so that `version --changelog` makes one call per file rather than one
68
+ * per package - the same entry prepended once per member otherwise.
69
+ */
70
+ export declare function changelogGroupBy(repository: Repository, options?: ChangelogService.Options): 'package' | 'group';
58
71
  export declare namespace ChangelogService {
59
72
  interface Options extends PackageFilterOptions {
60
73
  /** Generate the changelog since this commit/hash - applied the same way to every package.
@@ -96,6 +109,10 @@ export declare namespace ChangelogService {
96
109
  * different question and deliberately ignores all of it.
97
110
  */
98
111
  write?: boolean;
112
+ /** Regenerate each file from the whole history rather than appending to what is there: the
113
+ * file's own marker and `catchUpFile` are both ignored, and every file this run writes is
114
+ * emptied first. Only meaningful with `write`; an explicit `from` still wins over it. */
115
+ rebuild?: boolean;
99
116
  /** Where this package's changelog begins - a version or release tag, a `YYYY-MM-DD` date, or a
100
117
  * commit. Releases below it are left out. `.rmanrc changelog.startingAt` when omitted; see
101
118
  * `resolveStartingPoint` for how the three forms are told apart. */
@@ -107,13 +124,58 @@ export declare namespace ChangelogService {
107
124
  /** Whether each line ends with its commit's short sha - `.rmanrc changelog.commitHash` when
108
125
  * omitted, and `true` when neither says. See `withCommitHash`. */
109
126
  commitHash?: boolean;
127
+ /** What one changelog file is about - a package, or the group of packages that release
128
+ * together. `.rmanrc changelog.groupBy` (read off the root) when omitted, and `'package'` when
129
+ * neither says. See `changelogGroupBy` and `partitionTargets`. */
130
+ groupBy?: 'package' | 'group';
131
+ /** Where this run reports what it is doing, so a caller can draw a panel. Left out, nothing is
132
+ * reported and nothing is printed - see `Progress`. */
133
+ progress?: Progress;
134
+ }
135
+ /**
136
+ * **What a run reports, for a caller that wants to render it.** `ChangelogService` prints
137
+ * nothing itself - `version --changelog` drives it in the middle of its own output, so a panel
138
+ * drawn from inside would land on top of that. The CLI implements this against `ProgressPanel`;
139
+ * every other caller leaves it out and the calls cost nothing.
140
+ *
141
+ * A label names a **file** rather than a package, since that is the unit under
142
+ * `changelog.groupBy: 'group'` - it is the same string `Entry.label` carries.
143
+ */
144
+ interface Progress {
145
+ /** Every label this run will work through, before any of it starts. */
146
+ start(labels: string[]): void;
147
+ /**
148
+ * A label entered a phase, in this order:
149
+ *
150
+ * - `'detect'` - resolving its boundary. Every label enters this at once, and it is quick.
151
+ * - `'commits'` - reading and parsing the range. **The slow one**, two `git show` per commit,
152
+ * one commit at a time; `commits()` counts it.
153
+ * - `'render'` - cutting into releases, grouping and templating. No git, so however long the
154
+ * list is this is the fast one.
155
+ */
156
+ step(label: string, phase: 'detect' | 'commits' | 'render'): void;
157
+ /**
158
+ * How far the `'commits'` phase has got - `done` of `total`, called with `(0, total)` first so
159
+ * the total is known while the count is still zero.
160
+ *
161
+ * Optional because it is the only member that fires per *commit* rather than per label: a
162
+ * reporter that only wants the phases leaves it out and pays nothing.
163
+ */
164
+ commits?(label: string, done: number, total: number): void;
165
+ /** A label is finished. `wrote` is false when the range produced no entry for it. */
166
+ done(label: string, wrote: boolean): void;
110
167
  }
111
168
  /** One package's (root included) generated changelog entry - what `getEntries`/`generate`
112
169
  * return. */
113
170
  interface Entry {
171
+ /** **Whose directory holds the file**, which under `groupBy: 'group'` is the repository root
172
+ * for every group of more than one - the entry is about all of them. The member whose config
173
+ * and tags the entry was resolved from is not carried: it is an implementation detail of the
174
+ * group, and naming one member here would read as the entry being that package's. */
114
175
  package: Package;
115
- /** Display name for this entry's heading - `"<repo dir name> repository"` for the root
116
- * package, its own name otherwise (see `getEntries`'s doc comment on `{{package}}`). */
176
+ /** Display name for this entry's heading - the group's name under `groupBy: 'group'`,
177
+ * `"<repo dir name> repository"` for the root package, its own name otherwise (see
178
+ * `getEntries`'s doc comment on `{{package}}`). */
117
179
  label: string;
118
180
  /** `options.version` when the caller gave one, otherwise resolved from git tags rather than
119
181
  * package.json - see `resolveVersion`. */
@@ -136,8 +198,12 @@ export declare namespace ChangelogService {
136
198
  other: string[];
137
199
  /** The fully rendered entry, via `.rmanrc changelog.template` (or the built-in default). */
138
200
  content: string;
139
- /** Where this entry would be (or, with `options.write`, was) written, relative to the
140
- * package's own directory - see `GetOptions.filePath`. */
201
+ /** Where this entry would be (or, with `options.write`, was) written - **absolute**, and what
202
+ * `generateToFile` writes to. A caller needing the path resolves it from here rather than
203
+ * rebuilding it from `package` and `filePath`, which a group's file cannot be rebuilt from:
204
+ * its name carries the group. */
205
+ file: string;
206
+ /** `file` relative to `package`'s own directory, for display - see `Options.filePath`. */
141
207
  filePath: string;
142
208
  }
143
209
  }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { Service } from '../core/classes/service.js';
4
4
  import { GitHelper } from '../utils/git.js';
5
5
  import { filterPackages } from '../utils/package-filter.js';
6
+ import { groupKeyOf } from '../utils/version-group.js';
6
7
  import { ChangeHashService } from './change-hash.service.js';
7
8
  import { ConventionalCommitsService } from './conventional-commits.service.js';
8
9
  /**
@@ -19,6 +20,19 @@ export class ChangelogService extends Service {
19
20
  */
20
21
  async generateToFile(options = {}) {
21
22
  const entries = await this.getEntries({ ...options, write: true });
23
+ /**
24
+ * **`rebuild` empties each file before anything is prepended, once.** Without it the
25
+ * regenerated history lands *on top of* the history already in the file - every release
26
+ * written twice, which is the one outcome a rebuild must not produce.
27
+ *
28
+ * Only files this run actually produced an entry for: a file the run has nothing to say about
29
+ * is left alone rather than deleted, since emptying it would lose what is there and put
30
+ * nothing back.
31
+ */
32
+ if (options.rebuild) {
33
+ for (const file of new Set(entries.map(e => e.file)))
34
+ fs.rmSync(file, { force: true });
35
+ }
22
36
  /**
23
37
  * **Where each file is documented up to, recorded in the file itself.** Read back by
24
38
  * `resolveBoundary` on the next run, which is what makes a second `--write` append the commits
@@ -34,7 +48,7 @@ export class ChangelogService extends Service {
34
48
  * for a file leaves the newest documented commit behind it.
35
49
  */
36
50
  for (const entry of entries) {
37
- prependToChangelogFile(entry.package, entry.filePath, entry.content, entry.documentedUpTo);
51
+ prependToChangelogFile(entry.file, entry.content, entry.documentedUpTo);
38
52
  }
39
53
  return entries;
40
54
  }
@@ -91,10 +105,15 @@ export class ChangelogService extends Service {
91
105
  // all of them the same way; under fixed versioning, npm auto-detection usually does too) - so
92
106
  // the git fetch for a given hash is cached, run once no matter how many packages share it.
93
107
  const commitsByHash = new Map();
94
- const listCommitsCached = (hash) => {
108
+ /** `label` only steers the progress report, and only for the target that *triggers* the fetch:
109
+ * several targets sharing a boundary share one fetch, so the commits are counted under
110
+ * whichever asked first. Reporting it per sharer would mean either several fetches or several
111
+ * rows counting the same work. */
112
+ const listCommitsCached = (hash, label) => {
95
113
  const key = hash ?? '';
96
114
  let promise = commitsByHash.get(key);
97
115
  if (!promise) {
116
+ const onProgress = (done, total) => options.progress?.commits?.(label, done, total);
98
117
  // No boundary at all means nothing has ever been released, so everything so far is
99
118
  // unreleased - the same fallback `VersionService` makes. (Not "not yet pushed": that reads
100
119
  // as empty the moment a first release is pushed, and for a repo with no remote at all.)
@@ -105,20 +124,48 @@ export class ChangelogService extends Service {
105
124
  * against shas that are no longer in the list - every cut silently missed, and the whole
106
125
  * range rendered as one release. Measured while writing it.
107
126
  */
108
- promise = hash ? git.listCommits({ hash }) : git.listAllCommits();
127
+ promise = hash ? git.listCommits({ hash, onProgress }) : git.listAllCommits({ onProgress });
109
128
  commitsByHash.set(key, promise);
110
129
  }
111
130
  return promise;
112
131
  };
113
- const commitsByTarget = await Promise.all(targets.map(async (pkg) => {
114
- const changelogFile = path.join(pkg.dirname, resolveFilePath(pkg, options.filePath));
115
- const from = await resolveBoundary(git, pkg, changelogFile, options);
116
- return listCommitsCached(from);
132
+ /**
133
+ * **The unit is a changelog group, not a package** - see `partitionTargets`. Under the default
134
+ * `groupBy: 'package'` every target is a group of one, so everything below reads exactly as it
135
+ * did when this loop was written against packages.
136
+ */
137
+ const groups = partitionTargets(repository, targets, options);
138
+ /** **Reported, never printed** - this service stays pure with respect to the console, which is
139
+ * not tidiness here: `version --changelog` calls it in the middle of its own output, and a
140
+ * panel painted from in here would land on top of that. */
141
+ const progress = options.progress;
142
+ progress?.start(groups.map(g => g.label));
143
+ /** Every boundary resolves at once - `Promise.all` over the groups, and `GitHelper` spawns
144
+ * through `execFile`, so nothing below serializes them. Measured on `panates/sqb`: 18 targets,
145
+ * 18 concurrent `git describe`, 1.2s wall against 1.8s of summed duration. Within one target
146
+ * the steps are a chain (tag -> registry -> file -> merge-base) and cannot be. */
147
+ const commitsByTarget = await Promise.all(groups.map(async (group) => {
148
+ progress?.step(group.label, 'detect');
149
+ const from = await resolveBoundary(git, group.home, group.file, options);
150
+ /** **Reading the range happens here, not in the loop below**, which is what makes this the
151
+ * phase a panel has to name: the boundary is resolved and then every commit after it is
152
+ * fetched, still inside this `Promise.all`. The loop below is `splitByRelease`, the
153
+ * grouping and the template - all in memory. */
154
+ progress?.step(group.label, 'commits');
155
+ return listCommitsCached(from, group.label);
117
156
  }));
118
157
  const entries = [];
119
- for (let i = 0; i < targets.length; i++) {
120
- const pkg = targets[i];
121
- const label = pkg === repository.rootPackage ? `${path.basename(repository.dirname)} repository` : pkg.name;
158
+ for (let i = 0; i < groups.length; i++) {
159
+ const group = groups[i];
160
+ /** Every per-package question - the boundary, the tag pattern, the titles, the template -
161
+ * is asked of one member, because a group is by definition the packages that release
162
+ * together and therefore answer these the same way. */
163
+ const pkg = group.home;
164
+ const label = group.label;
165
+ /** Cutting the range into releases, grouping the subjects and rendering - no git left, so
166
+ * this is the fast phase however long the list is. */
167
+ progress?.step(label, 'render');
168
+ const entriesBefore = entries.length;
122
169
  /**
123
170
  * **One entry per release in the range, not one entry per package.** The range is whatever
124
171
  * the boundary opened up, and a release tag inside it ends a release: a changelog that does
@@ -140,7 +187,9 @@ export class ChangelogService extends Service {
140
187
  continue;
141
188
  if (await isBelowStartingPoint(git, pkg, segment, floor, floorIndex))
142
189
  continue;
143
- const ownCommits = dropVersionBumps(segment.commits).filter(c => ownersOf(repository, c).has(pkg));
190
+ /** A commit belongs to a group when it belongs to **any** of its members - one line for a
191
+ * commit touching two of them, which is the whole reason a group shares a file. */
192
+ const ownCommits = dropVersionBumps(segment.commits).filter(c => [...ownersOf(repository, c)].some(owner => group.members.has(owner)));
144
193
  if (!ownCommits.length)
145
194
  continue;
146
195
  const sections = groupCommits(ownCommits, ignoreTypesConfig(pkg), titles, order, withCommitHash(pkg, options));
@@ -148,9 +197,9 @@ export class ChangelogService extends Service {
148
197
  // rather than rendering a heading with nothing real underneath it.
149
198
  if (!sections.length)
150
199
  continue;
151
- const { version, content } = await renderEntry(repository, pkg, label, sections, titles, git, options.version, segment.tag);
200
+ const { version, content } = await renderEntry(repository, pkg, label, sections, titles, git, options.version, segment.tag, group.grouped);
152
201
  entries.push({
153
- package: pkg,
202
+ package: group.filePkg,
154
203
  label,
155
204
  version,
156
205
  /** The last commit this entry covers - what the file's marker records. For a tagged
@@ -160,13 +209,32 @@ export class ChangelogService extends Service {
160
209
  sections,
161
210
  ...legacyBuckets(sections, titles),
162
211
  content,
163
- filePath: resolveFilePath(pkg, options.filePath),
212
+ file: group.file,
213
+ filePath: path.relative(group.filePkg.dirname, group.file),
164
214
  });
165
215
  }
216
+ /** `false` where the range held nothing this target owns, or `ignoreTypes` emptied it - an
217
+ * answer, not a failure, which is why it is a flag rather than a status. */
218
+ progress?.done(label, entries.length > entriesBefore);
166
219
  }
167
220
  return entries;
168
221
  }
169
222
  }
223
+ /**
224
+ * What one changelog file is about - `.rmanrc changelog.groupBy`, `'package'` when nothing says.
225
+ *
226
+ * **Read off the repository root and nowhere else.** It decides a *layout*, so there is one answer
227
+ * for the whole repository, exactly as `run`'s `concurrency` is the root's because there is one
228
+ * scheduler. Cascaded per package it would mean two packages of one group disagreeing about which
229
+ * file they share, which has no answer.
230
+ *
231
+ * `VersionService` asks too, so that `version --changelog` makes one call per file rather than one
232
+ * per package - the same entry prepended once per member otherwise.
233
+ */
234
+ export function changelogGroupBy(repository, options = {}) {
235
+ const declared = options.groupBy ?? repository.rootPackage.config?.changelog?.groupBy;
236
+ return declared === 'group' ? 'group' : 'package';
237
+ }
170
238
  /** `.rmanrc changelog.ignoreTypes` (cascaded, per-package overridable) - Conventional Commits
171
239
  * `type`s to drop entirely (e.g. `[chore, dev]`), not just fold into "General Changes". Only
172
240
  * applies to commits that actually parse as `type: ...` - a non-conventional message always
@@ -514,6 +582,59 @@ function resolveFilePath(pkg, optionsFilePath) {
514
582
  const cfg = pkg.config?.changelog?.filePath;
515
583
  return typeof cfg === 'string' && cfg ? cfg : DEFAULT_CHANGELOG_FILE;
516
584
  }
585
+ /**
586
+ * The targets cut into the files they will be written to.
587
+ *
588
+ * **One rule, not two.** A changelog belongs to the set of packages that version and release
589
+ * together - `.rmanrc group`, the same key `version` batches its plan by - and `group: false`
590
+ * already makes a package a group of itself. So a solo group's file is its package's own, and
591
+ * anything wider goes to the repository root, where it is the one file describing that release.
592
+ *
593
+ * Under the default `groupBy: 'package'` every target is made a group of one, so this returns
594
+ * exactly what the loop saw when it was written against packages. That is the whole of the
595
+ * opt-out: no repository's layout changes until it asks.
596
+ *
597
+ * The default group is unsuffixed (`CHANGELOG.md`), since a repository releasing along one line has
598
+ * nothing to distinguish it from; a named group takes its name (`CHANGELOG-core.md`). The name is
599
+ * checked where it is read - see `groupKeyOf` - so nothing has to be escaped here.
600
+ */
601
+ function partitionTargets(repository, targets, options) {
602
+ const grouping = changelogGroupBy(repository, options);
603
+ const byKey = new Map();
604
+ for (const pkg of targets) {
605
+ const key = grouping === 'group' ? groupKeyOf(pkg) : `solo:${pkg.name}`;
606
+ const members = byKey.get(key);
607
+ if (members)
608
+ members.push(pkg);
609
+ else
610
+ byKey.set(key, [pkg]);
611
+ }
612
+ const repositoryLabel = `${path.basename(repository.dirname)} repository`;
613
+ return [...byKey].map(([key, members]) => {
614
+ /** `targets` starts at the root, so a group holding it has it first - which is what makes the
615
+ * root the `home` of the default group without a second rule saying so. */
616
+ const home = members[0];
617
+ const grouped = !key.startsWith('solo:');
618
+ const named = key.startsWith('named:') ? key.slice('named:'.length) : undefined;
619
+ const filePkg = grouped ? repository.rootPackage : home;
620
+ return {
621
+ home,
622
+ filePkg,
623
+ grouped,
624
+ members: new Set(members),
625
+ label: named ?? (home === repository.rootPackage ? repositoryLabel : home.name),
626
+ file: path.join(filePkg.dirname, suffixFileName(resolveFilePath(home, options.filePath), named)),
627
+ };
628
+ });
629
+ }
630
+ /** `CHANGELOG.md` + `core` -> `CHANGELOG-core.md`, keeping whatever directory and extension
631
+ * `changelog.filePath` chose (`docs/HISTORY.md` -> `docs/HISTORY-core.md`). */
632
+ function suffixFileName(filePath, suffix) {
633
+ if (!suffix)
634
+ return filePath;
635
+ const ext = path.extname(filePath);
636
+ return path.join(path.dirname(filePath), `${path.basename(filePath, ext)}-${suffix}${ext}`);
637
+ }
517
638
  /**
518
639
  * This package's current version, from git tags rather than its (possibly stale - see the
519
640
  * `{{version}}` doc on `Changelog.getEntries`) package.json. Falls back to package.json's version
@@ -533,17 +654,27 @@ function resolveFilePath(pkg, optionsFilePath) {
533
654
  * entry before it commits and tags), so there is no tag to read a date off and the clock is the
534
655
  * only answer. That is why this is decided here, beside the version, rather than at the template.
535
656
  */
536
- async function resolveHeading(git, pkg, label, versionOverride, segmentTag) {
657
+ async function resolveHeading(git, pkg, label, versionOverride, segmentTag, grouped) {
537
658
  const today = new Date().toISOString().slice(0, 10);
538
- const expanded = ChangeHashService.tagPattern(pkg).replace('{name}', pkg.name);
659
+ const pattern = ChangeHashService.tagPattern(pkg);
660
+ const expanded = pattern.replace('{name}', pkg.name);
661
+ /**
662
+ * **A group's heading may not be one member's tag.** Under a repo-wide pattern the tag is already
663
+ * package-neutral (`v1.2.3`) and is the best heading there is. Under `{name}@*` it is
664
+ * `pkg-a@1.2.3` - one member's name over a file describing the whole group, and a different
665
+ * member's name each time the first one changes. There the group's own label carries it.
666
+ */
667
+ const ownTag = !grouped || !pattern.includes('{name}');
668
+ const groupHeading = (version) => `${label} ${version}`;
539
669
  /** A segment that a release tag closes is headed by **that** release, whatever the newest one
540
670
  * is - which is the whole point of splitting, and the reason an override cannot win here: a
541
671
  * caller naming a version is naming the *unreleased* one, the segment with no tag. */
542
672
  if (segmentTag) {
673
+ const version = ChangeHashService.extractVersion(segmentTag, expanded);
543
674
  return {
544
- version: ChangeHashService.extractVersion(segmentTag, expanded),
675
+ version,
545
676
  date: (await git.commitDate(segmentTag)) ?? today,
546
- title: segmentTag,
677
+ title: ownTag ? segmentTag : groupHeading(version),
547
678
  };
548
679
  }
549
680
  /**
@@ -553,7 +684,11 @@ async function resolveHeading(git, pkg, label, versionOverride, segmentTag) {
553
684
  * that is when it is happening.
554
685
  */
555
686
  if (versionOverride) {
556
- return { version: versionOverride, date: today, title: ChangeHashService.expandTag(pkg, versionOverride) };
687
+ return {
688
+ version: versionOverride,
689
+ date: today,
690
+ title: ownTag ? ChangeHashService.expandTag(pkg, versionOverride) : groupHeading(versionOverride),
691
+ };
557
692
  }
558
693
  /**
559
694
  * **Nothing else is a release**, so nothing else gets a release's heading. This used to read the
@@ -655,9 +790,9 @@ function ownersOf(repository, commit) {
655
790
  }
656
791
  return owners;
657
792
  }
658
- async function renderEntry(repository, pkg, label, sections, titles, git, versionOverride, segmentTag) {
793
+ async function renderEntry(repository, pkg, label, sections, titles, git, versionOverride, segmentTag, grouped) {
659
794
  const template = resolveTemplate(repository, pkg);
660
- const { version, date, title } = await resolveHeading(git, pkg, label, versionOverride, segmentTag);
795
+ const { version, date, title } = await resolveHeading(git, pkg, label, versionOverride, segmentTag, grouped);
661
796
  const content = render(template, {
662
797
  package: label,
663
798
  version,
@@ -704,6 +839,18 @@ const MARKER = /^<!-- rman:documented-up-to ([0-9a-f]{7,40}) -->[ \t]*\r?\n+/m;
704
839
  async function resolveBoundary(git, pkg, changelogFile, options) {
705
840
  if (options.from && options.from !== ChangeHashService.AUTO)
706
841
  return options.from;
842
+ /**
843
+ * **A rebuild starts from nothing, on purpose.** Both of the things that narrow a boundary are
844
+ * records of what a *previous* run wrote - the file's own marker, and `catchUpFile`'s last
845
+ * modifying commit - so consulting either would rebuild the file from where the file already is,
846
+ * which is the opposite of the request. The whole history it is, cut at every release tag by
847
+ * `splitByRelease` and floored by `changelog.startingAt`.
848
+ *
849
+ * An explicit `--from` still wins, above: that names a boundary the caller chose for this run,
850
+ * not one read back off disk.
851
+ */
852
+ if (options.rebuild)
853
+ return undefined;
707
854
  if (options.write) {
708
855
  const exists = fs.existsSync(changelogFile);
709
856
  if (!exists)
@@ -720,8 +867,7 @@ async function resolveBoundary(git, pkg, changelogFile, options) {
720
867
  /** Prepends `content` right after the top-level "# Changelog" heading if the file already has
721
868
  * one, otherwise creates the file (and any missing parent directory - `relFilePath` can nest one,
722
869
  * e.g. `'docs/CHANGELOG.md'`) with one. Leaves everything already in the file untouched below it. */
723
- function prependToChangelogFile(pkg, relFilePath, content, documentedUpTo) {
724
- const file = path.join(pkg.dirname, relFilePath);
870
+ function prependToChangelogFile(file, content, documentedUpTo) {
725
871
  const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
726
872
  const headerMatch = /^# Changelog\r?\n+/.exec(existing);
727
873
  const header = headerMatch ? headerMatch[0] : '# Changelog\n\n';
@@ -4,9 +4,12 @@ import { Service } from '../core/classes/service.js';
4
4
  import { ConfigInterpolator } from '../core/config/config-interpolator.js';
5
5
  import { Manifest } from '../core/interfaces/manifest.js';
6
6
  import { GitHelper } from '../utils/git.js';
7
+ import { ROOT_SELECTOR } from '../utils/package-filter.js';
7
8
  import { expandReleaseTag, isCalendarVersion } from '../utils/release-version.js';
9
+ import { groupKeyOf } from '../utils/version-group.js';
8
10
  import { stampVersionLabel } from '../utils/version-stamp.js';
9
11
  import { ChangeHashService } from './change-hash.service.js';
12
+ import { changelogGroupBy } from './changelog.service.js';
10
13
  import { RunService } from './run.service.js';
11
14
  import { VersionPlanService } from './version-plan.service.js';
12
15
  /**
@@ -119,12 +122,43 @@ export class VersionService extends Service {
119
122
  * `version` never required at all. Falls back to `changelog`'s own default only when this
120
123
  * package genuinely has no prior tag (a first-ever release). */
121
124
  const changelogFileByPackage = new Map();
125
+ /**
126
+ * Every entry the changelog is written for - which, unlike `bumped`, **includes a monorepo's
127
+ * root**.
128
+ */
129
+ /* **Without the root, a repository-wide commit is documented nowhere.** `ChangelogService`
130
+ * attributes a commit touching more than half the packages to the root instead of repeating it
131
+ * in all of them (`ownersOf`/`BROAD_COMMIT_THRESHOLD`) - and the root was excluded here, so
132
+ * that commit reached no file at all. Measured on `panates/sqb`'s 6.0.11: the one commit in the
133
+ * range touched all 17 packages, every package's entry was therefore empty and skipped by
134
+ * `changelog.service.ts`'s `!ownCommits.length` guard, and the release shipped with two
135
+ * changelogs updated out of seventeen and the change itself recorded in none of them.
136
+ *
137
+ * The two rules have to agree about who owns a broad commit, and the attribution is the one
138
+ * worth keeping: repeating an identical line in seventeen files is what the threshold exists to
139
+ * avoid. So the root gets its changelog written, and the repository has one file that says what
140
+ * a release contained repository-wide.
141
+ *
142
+ * **`bumped` stays as it is.** It drives the manifest writes, the stamps and `updated`, and a
143
+ * monorepo root's version is informational - writing it as a release would be the thing
144
+ * `isRealEntry` exists to prevent. This is a different question with a different answer. */
145
+ const changelogTargets = plan.filter(e => e.status === 'bump');
122
146
  if (options.changelog) {
123
- for (const entry of bumped) {
147
+ /** **One call per file, not one per package.** Under `changelog.groupBy: 'group'` a group's
148
+ * members share one file, so calling per package would prepend that group's entry once per
149
+ * member - the same release written into the file three times. */
150
+ for (const target of changelogCalls(repository, changelogTargets)) {
151
+ const entry = target.entries[0];
152
+ /** **The root is addressed structurally, never by name.** A glob does not match a
153
+ * monorepo's root, so `scope: entry.package.name` would select nothing for it - the same
154
+ * vocabulary `--scope /` and `.rmanrc`'s `"[/]"` already use. */
155
+ const scope = target.entries.map(e => repository.monorepo && e.package === repository.rootPackage ? ROOT_SELECTOR : e.package.name);
156
+ /** Every member of a group releases at the same version off the same tag, so any of them
157
+ * answers for the boundary - see `applyPlan`'s own per-group tag set. */
124
158
  const fromTag = ChangeHashService.expandTag(entry.package, entry.from);
125
159
  const from = (await git.tagExists(fromTag)) ? fromTag : undefined;
126
160
  const changelogEntries = await this.app.getService('changelog').generateToFile({
127
- scope: entry.package.name,
161
+ scope,
128
162
  fromRoot: true,
129
163
  from,
130
164
  // The tag for this release doesn't exist yet (it's created below), so changelog's own
@@ -135,8 +169,13 @@ export class VersionService extends Service {
135
169
  // to bump, so its folded-in changelog shouldn't then be silently dropped by that flag.
136
170
  includeSkipped: true,
137
171
  });
172
+ /** Recorded against **every** package the call covered, not against the entry's own
173
+ * `package` - a group's file sits at the repository root, so keying it by that would file
174
+ * the group's changelog under the root and leave the group commit without it. */
138
175
  for (const ce of changelogEntries) {
139
- changelogFileByPackage.set(ce.package.name, path.relative(repository.dirname, path.join(ce.package.dirname, ce.filePath)));
176
+ const file = path.relative(repository.dirname, ce.file);
177
+ for (const e of target.entries)
178
+ changelogFileByPackage.set(e.package.name, file);
140
179
  }
141
180
  }
142
181
  }
@@ -149,7 +188,19 @@ export class VersionService extends Service {
149
188
  const tagged = [];
150
189
  if (rootEntry?.status === 'bump') {
151
190
  const message = `chore: sync root version to ${rootEntry.to}`;
152
- const sha = await git.commit([path.relative(repository.dirname, repository.rootPackage.manifestFileName)], message);
191
+ /** The root's own `CHANGELOG.md` rides along here, for the same reason its manifest does:
192
+ * it belongs to no group, so no group commit would carry it and it would be left behind as
193
+ * an uncommitted edit that `publish` then reads as a dirty tree. */
194
+ const files = [path.relative(repository.dirname, repository.rootPackage.manifestFileName)];
195
+ const rootChangelog = changelogFileByPackage.get(repository.rootPackage.name);
196
+ /** **Unless a group commit below is already carrying it**, which is the ordinary case under
197
+ * `changelog.groupBy: 'group'`: the root is a member of the default group, so its file *is*
198
+ * the group's. Committed here it would land one commit before the tag, leaving `git show
199
+ * <tag>` without the notes for the release that tag names. */
200
+ const claimedByGroup = new Set(bumped.map(e => changelogFileByPackage.get(e.package.name)));
201
+ if (rootChangelog && !claimedByGroup.has(rootChangelog))
202
+ files.push(rootChangelog);
203
+ const sha = await git.commit(files, message);
153
204
  /** No packages: this commit carries the root's informational version and nothing releasable,
154
205
  * which is why `updated` does not count it either. */
155
206
  commits.push({ sha, message, packages: [] });
@@ -163,15 +214,19 @@ export class VersionService extends Service {
163
214
  byGroup.set(entry.groupKey, [entry]);
164
215
  }
165
216
  for (const [, groupEntries] of byGroup) {
166
- const files = groupEntries.map(e => path.relative(repository.dirname, e.package.manifestFileName));
217
+ /** A **set**: the members of a group share one changelog file under
218
+ * `changelog.groupBy: 'group'`, and `git commit` given the same path twice is a needless
219
+ * way to find out whether it minds. */
220
+ const files = new Set(groupEntries.map(e => path.relative(repository.dirname, e.package.manifestFileName)));
167
221
  for (const e of groupEntries) {
168
222
  const changelogFile = changelogFileByPackage.get(e.package.name);
169
223
  if (changelogFile)
170
- files.push(changelogFile);
171
- files.push(...(stampedByPackage.get(e.package.name) ?? []));
224
+ files.add(changelogFile);
225
+ for (const f of stampedByPackage.get(e.package.name) ?? [])
226
+ files.add(f);
172
227
  }
173
228
  const message = VersionService.buildCommitMessage(repository, groupEntries, options.message);
174
- const sha = await git.commit(files, message);
229
+ const sha = await git.commit([...files], message);
175
230
  commits.push({ sha, message, packages: groupEntries.map(e => e.package.name) });
176
231
  const tags = new Set(groupEntries.map(e => ChangeHashService.expandTag(e.package, e.to)));
177
232
  for (const tag of tags) {
@@ -391,3 +446,28 @@ function assertStampable(pkg, version) {
391
446
  `version is declared in it. Name a plugin in .rmanrc "plugins".`));
392
447
  }
393
448
  }
449
+ /**
450
+ * The plan entries cut into one call to `ChangelogService.generateToFile` per **file** it will
451
+ * write.
452
+ *
453
+ * Under the default `changelog.groupBy: 'package'` that is one call per entry, which is what this
454
+ * always was. Under `'group'` the members of a group share one file, so they share one call - and
455
+ * the grouping asked for is the changelog's (`groupKeyOf`), never the *planner's*: those two answer
456
+ * different questions and disagree about the root, which `VersionPlanService` puts in a group of its
457
+ * own (`__root__`) while `groupKeyOf` puts it wherever its config does. Batching by the planner's
458
+ * key would give a `group: true` repository two files where it asked for one.
459
+ */
460
+ function changelogCalls(repository, entries) {
461
+ if (changelogGroupBy(repository) !== 'group')
462
+ return entries.map(entry => ({ entries: [entry] }));
463
+ const byKey = new Map();
464
+ for (const entry of entries) {
465
+ const key = groupKeyOf(entry.package);
466
+ const list = byKey.get(key);
467
+ if (list)
468
+ list.push(entry);
469
+ else
470
+ byKey.set(key, [entry]);
471
+ }
472
+ return [...byKey.values()].map(grouped => ({ entries: grouped }));
473
+ }
package/utils/git.d.ts CHANGED
@@ -36,12 +36,15 @@ export declare class GitHelper {
36
36
  */
37
37
  listCommits(options?: {
38
38
  hash?: string;
39
+ onProgress?: GitHelper.CommitProgress;
39
40
  }): Promise<CommitInfo[]>;
40
41
  /** Every commit reachable from HEAD, oldest first - for a boundary-free "everything so far"
41
42
  * view (e.g. a package that's never been tagged/released at all, so there's no "since" ref to
42
43
  * measure from and `listCommits()`'s own no-hash fallback, upstream push status, doesn't apply -
43
44
  * a repo with no configured remote at all is common and shouldn't read as "nothing happened"). */
44
- listAllCommits(): Promise<CommitInfo[]>;
45
+ listAllCommits(options?: {
46
+ onProgress?: GitHelper.CommitProgress;
47
+ }): Promise<CommitInfo[]>;
45
48
  private _commitInfoFor;
46
49
  /** Tag names matching the glob `pattern` (e.g. `"v*"`, `"pkg-a@*"`), newest version first. */
47
50
  listTags(pattern: string): Promise<string[]>;
@@ -123,3 +126,8 @@ export declare class GitHelper {
123
126
  * fall back to a content-level three-way merge instead of failing outright. */
124
127
  applyPatches(patchFiles: string[]): Promise<void>;
125
128
  }
129
+ export declare namespace GitHelper {
130
+ /** Called once with `(0, total)` before the first commit, then after each one - so a caller can
131
+ * show the total while the count is still zero. */
132
+ type CommitProgress = (done: number, total: number) => void;
133
+ }
package/utils/git.js CHANGED
@@ -96,13 +96,13 @@ export class GitHelper {
96
96
  }
97
97
  shas = Array.from(cherryOut.matchAll(/[a-f0-9]{7,40}/gi)).map(m => m[0]);
98
98
  }
99
- return this._commitInfoFor(shas);
99
+ return this._commitInfoFor(shas, options?.onProgress);
100
100
  }
101
101
  /** Every commit reachable from HEAD, oldest first - for a boundary-free "everything so far"
102
102
  * view (e.g. a package that's never been tagged/released at all, so there's no "since" ref to
103
103
  * measure from and `listCommits()`'s own no-hash fallback, upstream push status, doesn't apply -
104
104
  * a repo with no configured remote at all is common and shouldn't read as "nothing happened"). */
105
- async listAllCommits() {
105
+ async listAllCommits(options) {
106
106
  let stdout;
107
107
  try {
108
108
  ({ stdout } = await execFileAsync('git', ['log', '--reverse', '--format=%H'], { cwd: this.cwd }));
@@ -111,10 +111,17 @@ export class GitHelper {
111
111
  return [];
112
112
  }
113
113
  const shas = stdout.trim() ? stdout.trim().split(/\r?\n/) : [];
114
- return this._commitInfoFor(shas);
114
+ return this._commitInfoFor(shas, options?.onProgress);
115
115
  }
116
- async _commitInfoFor(shas) {
116
+ /* **The slow loop, and the only one worth reporting.** Two `git show` per commit, one commit at
117
+ * a time - measured on `panates/sqb`, 156 invocations at peak concurrency 1 for a four-release
118
+ * range, and 1825 commits for a full rebuild. Everything around it is either concurrent across
119
+ * targets or in memory, which is why `onProgress` is here and nowhere else. */
120
+ async _commitInfoFor(shas, onProgress) {
117
121
  const commits = [];
122
+ /** Reported before the first commit, so a caller knows the total while the count is still 0 -
123
+ * otherwise a panel has nothing to draw for the whole of the first commit. */
124
+ onProgress?.(0, shas.length);
118
125
  for (const sha of shas) {
119
126
  // subject and body in one call (NUL-separated, since a commit message itself never
120
127
  // contains one) - halves the process-spawns per commit compared to two separate `git show`s.
@@ -132,6 +139,7 @@ export class GitHelper {
132
139
  body: body.trim(),
133
140
  files: files.map(f => path.join(this.cwd, f)),
134
141
  });
142
+ onProgress?.(commits.length, shas.length);
135
143
  }
136
144
  return commits;
137
145
  }
@@ -48,7 +48,13 @@ export declare class ProgressPanel {
48
48
  private renderLoop?;
49
49
  private spinnerFrame;
50
50
  private startedAt;
51
- constructor(title: string, enabled: boolean);
51
+ /**
52
+ * `stream` is where the panel is drawn - **stdout by default**, which is what `run` and `exec`
53
+ * have always used. A command whose *answer* goes to stdout passes `process.stderr`, or
54
+ * redirecting that answer into a file captures the panel's cursor-movement codes along with it:
55
+ * `rman changelog > NOTES.md` is the case, and it is the same reason the status region moved.
56
+ */
57
+ constructor(title: string, enabled: boolean, stream?: NodeJS.WriteStream);
52
58
  get enabled(): boolean;
53
59
  /** Registers an item and returns it - callers mutate the returned object directly (`status`,
54
60
  * `currentStep`, `lastLine`, ...) to drive the next render tick. */
@@ -21,9 +21,15 @@ export class ProgressPanel {
21
21
  renderLoop;
22
22
  spinnerFrame = 0;
23
23
  startedAt = 0;
24
- constructor(title, enabled) {
24
+ /**
25
+ * `stream` is where the panel is drawn - **stdout by default**, which is what `run` and `exec`
26
+ * have always used. A command whose *answer* goes to stdout passes `process.stderr`, or
27
+ * redirecting that answer into a file captures the panel's cursor-movement codes along with it:
28
+ * `rman changelog > NOTES.md` is the case, and it is the same reason the status region moved.
29
+ */
30
+ constructor(title, enabled, stream) {
25
31
  this.title = title;
26
- this.live = new LiveRegion(enabled);
32
+ this.live = new LiveRegion(enabled, stream);
27
33
  }
28
34
  get enabled() {
29
35
  return this.live.enabled;
@@ -49,16 +55,35 @@ export class ProgressPanel {
49
55
  let running = 0;
50
56
  let done = 0;
51
57
  let failedSoFar = 0;
58
+ /**
59
+ * **What the bar is filled from, and it is not `done`.** A finished item counts as a whole one;
60
+ * a *running* item counts as the fraction of its own steps it has got through, so the bar moves
61
+ * while a single item is working rather than only when one ends.
62
+ *
63
+ * Counting whole items made the bar useless exactly where it was needed most: `rman changelog`
64
+ * writes one file under `changelog.groupBy: 'group'`, so the bar sat empty at `0/1` for the
65
+ * entire run while the row beside it counted to 1825. `run` gains the same thing in the small -
66
+ * a nine-step build now advances within the package instead of jumping at the end of it.
67
+ */
68
+ let filled = 0;
52
69
  for (const item of this.items.values()) {
53
- if (item.status === 'running')
70
+ if (item.status === 'running') {
54
71
  running++;
55
- else if (item.status !== 'pending')
72
+ /** `stepIndex` is 0-based, so a step in progress is `index + 1` of `total` begun - and
73
+ * clamped, since a caller may report more steps than it first declared. */
74
+ if (item.stepsTotal && item.stepIndex != null) {
75
+ filled += Math.min(1, (item.stepIndex + 1) / item.stepsTotal);
76
+ }
77
+ }
78
+ else if (item.status !== 'pending') {
56
79
  done++;
80
+ filled += 1;
81
+ }
57
82
  if (item.status === 'failed')
58
83
  failedSoFar++;
59
84
  }
60
85
  const header = colors.bgCyan.black.bold(` ${this.title} `);
61
- const bar = renderProgressBar(done, total);
86
+ const bar = renderProgressBar(filled, total);
62
87
  const totalElapsed = formatDuration(Date.now() - this.startedAt);
63
88
  const failedText = failedSoFar > 0 ? colors.red.bold(`${failedSoFar} failed`) : colors.gray(`${failedSoFar} failed`);
64
89
  const lines = [
@@ -179,7 +204,10 @@ function truncate(text, width) {
179
204
  }
180
205
  /** Classic braille "dots" spinner (cli-spinners' default), one frame per render tick. */
181
206
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
207
+ /** `done` is fractional - see the `filled` accumulator in `render`. Clamped both ends, or a
208
+ * rounding overshoot asks `repeat` for a negative count and throws inside a redraw. */
182
209
  function renderProgressBar(done, total, width = 24) {
183
- const filled = total ? Math.round((done / total) * width) : 0;
210
+ const cells = total ? Math.round((done / total) * width) : 0;
211
+ const filled = Math.max(0, Math.min(width, cells));
184
212
  return colors.green('█'.repeat(filled)) + colors.gray('░'.repeat(width - filled));
185
213
  }
@@ -19,7 +19,7 @@ export function groupKeyOf(pkg) {
19
19
  if (g === false)
20
20
  return `solo:${pkg.name}`;
21
21
  if (typeof g === 'string' && g)
22
- return `named:${g}`;
22
+ return `named:${assertGroupName(g, pkg)}`;
23
23
  return 'default';
24
24
  }
25
25
  /**
@@ -41,3 +41,35 @@ export function versionLineCount(pkg) {
41
41
  return 1;
42
42
  return new Set(packages.map(groupKeyOf)).size;
43
43
  }
44
+ /**
45
+ * What a group may be called - a letter or digit, then letters, digits, `.`, `-` and `_`, up to
46
+ * `GROUP_NAME_MAX`.
47
+ *
48
+ * **A group name is a file name now**, since `changelog.groupBy: 'group'` writes one
49
+ * `CHANGELOG-<name>.md` per group, so it is no longer a key nothing outside rman ever sees. The
50
+ * alternative was escaping it at the one place it is written, and that is the worse half of the
51
+ * trade: an escape turns `core/api` into some `core-api.md` the repository never asked for and
52
+ * cannot search for, and it would have to be repeated by every future reader of the name. A
53
+ * refusal is one rule, stated once, before anything is written.
54
+ *
55
+ * Leading `.` and `-` are out for the two reasons a file name has: a dot-file is hidden, and a
56
+ * leading dash reads as a flag to everything that takes a path on a command line.
57
+ */
58
+ const GROUP_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
59
+ /**
60
+ * **15, and the limit is about the reader rather than any filesystem.** A group name is repeated in
61
+ * every heading of the file it names and in the file name itself; a sentence-long one stops being a
62
+ * label. Filesystems allow far more, which is exactly why a limit has to be a decision rather than
63
+ * whatever the platform happens to permit.
64
+ */
65
+ const GROUP_NAME_MAX = 15;
66
+ function assertGroupName(name, pkg) {
67
+ if (name.length > GROUP_NAME_MAX)
68
+ throw new Error(`Invalid group name "${name}" on package "${pkg.name}": at most ${GROUP_NAME_MAX} characters, ` +
69
+ `and it is ${name.length}. A group name is written into a file name (CHANGELOG-${name}.md).`);
70
+ if (!GROUP_NAME.test(name))
71
+ throw new Error(`Invalid group name "${name}" on package "${pkg.name}": start with a letter or digit, then ` +
72
+ `letters, digits, ".", "-" or "_". A group name is written into a file name ` +
73
+ `(CHANGELOG-<name>.md), so it has to be one.`);
74
+ return name;
75
+ }