rman 2.5.0 → 2.6.1

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.
@@ -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';
@@ -55,7 +55,7 @@ export class ExecService extends Service {
55
55
  const dependencies = topo ? pkg.dependencies.filter(d => names.has(d.name)).map(d => d.name) : [];
56
56
  return new Task(() => execForPackage(this.app, ctx, panel.enabled, pkg.dirname, command, logger, bail ? () => rootTask?.abort() : () => { }), { name: pkg.name, dependencies });
57
57
  });
58
- panel.start();
58
+ panel.start(repository.app.statusRegion);
59
59
  let failed = false;
60
60
  try {
61
61
  /** bail:false here - each item's own task decides whether to call `rootTask.abort()`
@@ -228,7 +228,7 @@ export class RunService extends Service {
228
228
  err.logged = true;
229
229
  throw err;
230
230
  }
231
- panel.start();
231
+ panel.start(repository.app.statusRegion);
232
232
  try {
233
233
  /** bail:false here - each package's own resolved bail setting decides whether to call
234
234
  * `rootTask.abort()` itself (see `runSteps`), since power-tasks' own `bail` can't vary per package. */
@@ -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
  }
@@ -1,4 +1,5 @@
1
1
  import { LiveRegion } from './live-region.js';
2
+ import type { StatusRegion, TerminalRegion } from './status-region.js';
2
3
  export type ProgressStatus = 'pending' | 'running' | 'success' | 'failed' | 'skipped';
3
4
  export interface ProgressItem {
4
5
  readonly name: string;
@@ -41,20 +42,39 @@ export declare function formatDuration(ms: number): string;
41
42
  * callers are expected to fall back to their own plain logging in that case, the way `run`
42
43
  * falls back to its "classic" per-step log.
43
44
  */
44
- export declare class ProgressPanel {
45
+ export declare class ProgressPanel implements TerminalRegion {
45
46
  private readonly title;
46
47
  readonly live: LiveRegion;
47
48
  private readonly items;
48
49
  private renderLoop?;
50
+ /** The region this panel took the terminal from, handed back in `stop`. */
51
+ private statusRegion?;
49
52
  private spinnerFrame;
50
53
  private startedAt;
51
- constructor(title: string, enabled: boolean);
54
+ /**
55
+ * `stream` is where the panel is drawn - **stdout by default**, which is what `run` and `exec`
56
+ * have always used. A command whose *answer* goes to stdout passes `process.stderr`, or
57
+ * redirecting that answer into a file captures the panel's cursor-movement codes along with it:
58
+ * `rman changelog > NOTES.md` is the case, and it is the same reason the status region moved.
59
+ */
60
+ constructor(title: string, enabled: boolean, stream?: NodeJS.WriteStream);
52
61
  get enabled(): boolean;
53
62
  /** Registers an item and returns it - callers mutate the returned object directly (`status`,
54
63
  * `currentStep`, `lastLine`, ...) to drive the next render tick. */
55
64
  addItem(name: string, stepsTotal?: number): ProgressItem;
56
- /** Starts the redraw loop. No-op (and no timer) when disabled. */
57
- start(): void;
65
+ /**
66
+ * Starts the redraw loop. No-op (and no timer) when disabled.
67
+ *
68
+ * **Pass `statusRegion` wherever one exists** - `app.statusRegion`, which every command has while
69
+ * it runs. The panel takes the terminal over for as long as it draws and hands it back in `stop`;
70
+ * without that, two regions redraw on top of each other and the bottom lines visibly swap places
71
+ * several times a second. See `StatusRegion.suspend`.
72
+ */
73
+ start(statusRegion?: StatusRegion): void;
74
+ /** Writes `text` above the panel without corrupting it - erase, write, redraw underneath. The
75
+ * `TerminalRegion` half of the takeover, so a `runBin` child's output goes somewhere sane while
76
+ * a panel is up. */
77
+ passThrough(text: string): void;
58
78
  private render;
59
79
  /** Stops the redraw loop and erases the panel, leaving the cursor where it started. */
60
80
  stop(): void;