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.
- 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/version.service.js +88 -8
- package/utils/git.d.ts +9 -1
- package/utils/git.js +12 -4
- package/utils/progress-panel.d.ts +7 -1
- package/utils/progress-panel.js +34 -6
- package/utils/version-group.js +33 -1
|
@@ -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
|
|
197
|
+
const rebuild = !!args.rebuild;
|
|
198
|
+
const write = args.write || rebuild;
|
|
152
199
|
const logger = new Logger(args.logLevel ?? resolveRootLogLevel(repository));
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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.
|
|
1
|
+
export const version = '2.6.0';
|
package/package.json
CHANGED
|
@@ -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 -
|
|
116
|
-
* package, its own name otherwise (see
|
|
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
|
|
140
|
-
*
|
|
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.
|
|
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';
|
|
@@ -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
|
}
|
|
@@ -48,7 +48,13 @@ export declare class ProgressPanel {
|
|
|
48
48
|
private renderLoop?;
|
|
49
49
|
private spinnerFrame;
|
|
50
50
|
private startedAt;
|
|
51
|
-
|
|
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. */
|
package/utils/progress-panel.js
CHANGED
|
@@ -21,9 +21,15 @@ export class ProgressPanel {
|
|
|
21
21
|
renderLoop;
|
|
22
22
|
spinnerFrame = 0;
|
|
23
23
|
startedAt = 0;
|
|
24
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
}
|
package/utils/version-group.js
CHANGED
|
@@ -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
|
+
}
|