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.
- package/builtins/platforms/node/services/ci.service.js +1 -1
- package/builtins/platforms/node/services/clean.service.js +1 -1
- package/commands/changelog.command.d.ts +84 -0
- package/commands/changelog.command.js +154 -11
- package/constants.js +1 -1
- package/package.json +1 -1
- package/services/changelog.service.d.ts +70 -4
- package/services/changelog.service.js +169 -23
- package/services/exec.service.js +1 -1
- package/services/run.service.js +1 -1
- package/services/version.service.js +88 -8
- package/utils/git.d.ts +9 -1
- package/utils/git.js +12 -4
- package/utils/progress-panel.d.ts +24 -4
- package/utils/progress-panel.js +66 -8
- package/utils/status-region.d.ts +38 -1
- package/utils/status-region.js +39 -27
- package/utils/version-group.js +33 -1
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 <
|
|
120
|
-
const
|
|
121
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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(
|
|
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';
|
package/services/exec.service.js
CHANGED
|
@@ -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()`
|
package/services/run.service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
171
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
57
|
-
|
|
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;
|