rman 2.12.0 → 2.14.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.
@@ -0,0 +1,114 @@
1
+ import type { RmanApplication } from '../application.js';
2
+ import type { Package } from '../classes/package.js';
3
+ import type { Repository } from '../classes/repository.js';
4
+ /**
5
+ * **How one technology answers "are this package's dependencies current, and what may they move
6
+ * to"** - the half of `rman deps` that is an ecosystem's.
7
+ *
8
+ * The command owns the repository's half: which packages are asked, printing the plan, `--json`,
9
+ * writing only under `--upgrade`, and undoing the writes when `verify` refuses them.
10
+ */
11
+ export interface DependencyUpdater {
12
+ /** What each dependency of `packages` would move to - never writes. */
13
+ getPlan(ctx: DependencyUpdater.Context, packages: readonly Package[]): Promise<DependencyUpdater.Entry[]>;
14
+ /** Writes every `'update'` entry of `plan` to its manifest, and returns how to take that back. */
15
+ applyPlan(ctx: DependencyUpdater.Context, plan: readonly DependencyUpdater.Entry[]): Promise<DependencyUpdater.Applied>;
16
+ /**
17
+ * Asks the ecosystem's own resolver whether the manifests as they now stand can be installed,
18
+ * once `applyPlan` has written them. Returns why not, or `undefined` when they can.
19
+ *
20
+ * Omit it when the ecosystem has no resolver to ask; the plan's own checks then stand alone.
21
+ */
22
+ verify?(ctx: DependencyUpdater.Context, packages: readonly Package[]): Promise<string | undefined>;
23
+ }
24
+ export declare namespace DependencyUpdater {
25
+ /** What one call is handed. */
26
+ interface Context {
27
+ readonly app: RmanApplication;
28
+ readonly repository: Repository;
29
+ readonly options: Options;
30
+ }
31
+ /** The run's own settings, read off the command line. A package's `.rmanrc "deps"` block supplies
32
+ * the rest - see `settingsFor`. */
33
+ interface Options {
34
+ /** Only the dependencies whose names match one of these globs. */
35
+ names?: string[];
36
+ /** Overrides every package's `deps.target`. */
37
+ target?: string;
38
+ /** Added to every package's `deps.reject`. */
39
+ reject?: string[];
40
+ /** Overrides every package's `deps.minAge`. */
41
+ minAge?: number;
42
+ /** How many registry lookups run at once. */
43
+ concurrency: number;
44
+ /** Reported as each dependency name's registry lookup finishes. */
45
+ onProgress?(done: number, total: number): void;
46
+ }
47
+ /** One package's settings, once the command line has been laid over its config. */
48
+ interface Settings {
49
+ /** The largest move allowed, as one of the package's version scheme's `bumpNames` - `minor`
50
+ * keeps every dependency inside its major. */
51
+ target: string;
52
+ /** `deps.targets`: a dependency-name glob, and the largest move for the names it matches -
53
+ * ahead of `target`. Empty when `--target` was given, which overrides both. */
54
+ targets: Record<string, string>;
55
+ reject: string[];
56
+ minAge: number;
57
+ /** The dependency kinds to look at, in the ecosystem's own words - `undefined` is every one. */
58
+ types?: string[];
59
+ }
60
+ /**
61
+ * One dependency of one package.
62
+ *
63
+ * - `'update'`: `target` is the new range; `reason` says why it is lower than `latest`, if it is.
64
+ * - `'held'`: a newer version exists and is allowed, but another dependency's rule refuses it -
65
+ * `reason` names that rule.
66
+ * - `'skipped'`: a newer version exists, and the package's own settings leave it out - a major
67
+ * under `target: minor`, or one younger than `minAge`. `reason` says which.
68
+ * - `'up-to-date'`: nothing newer exists.
69
+ * - `'error'`: the registry gave no answer.
70
+ */
71
+ interface Entry {
72
+ package: Package;
73
+ /** The dependency's name. */
74
+ name: string;
75
+ /** Where the package declares it, in the ecosystem's own words - `devDependencies`. */
76
+ types: string[];
77
+ /** The range as the manifest declares it. */
78
+ current: string;
79
+ status: 'update' | 'held' | 'skipped' | 'up-to-date' | 'error';
80
+ /** The range it is rewritten to, on an `'update'`. */
81
+ target?: string;
82
+ /** The newest version the package's own settings allow, before any other dependency's rule. */
83
+ latest?: string;
84
+ /** The newest version there is, whatever the settings say - what a reader compares against to
85
+ * see what was left behind. */
86
+ available?: string;
87
+ /** How far `target` moves, in the version scheme's own words - `major`. */
88
+ bump?: string;
89
+ reason?: string;
90
+ }
91
+ /** What `applyPlan` wrote. */
92
+ interface Applied {
93
+ /** Every file it changed, absolute. */
94
+ files: string[];
95
+ /** Puts every one of them back as it was. */
96
+ restore(): void;
97
+ }
98
+ /**
99
+ * Every move but the largest - `minor` under semver. The largest is the one a scheme reserves for a
100
+ * break, and taking a break is a decision a person makes, not a default.
101
+ */
102
+ function defaultTarget(bumps: readonly string[]): string;
103
+ /**
104
+ * The largest move `settings` allow for the dependency `name`, and the key that said so - the
105
+ * **last** `deps.targets` glob matching it, else `deps.target`.
106
+ */
107
+ function targetFor(settings: Settings, name: string): {
108
+ size: string;
109
+ from: string;
110
+ };
111
+ /** The settings `pkg` is planned with: `options` from the command line over its `.rmanrc "deps"`,
112
+ * and the defaults under both. */
113
+ function settingsFor(pkg: Package, options: Options): Settings;
114
+ }
@@ -0,0 +1,61 @@
1
+ import micromatch from 'micromatch';
2
+ export var DependencyUpdater;
3
+ (function (DependencyUpdater) {
4
+ /**
5
+ * Every move but the largest - `minor` under semver. The largest is the one a scheme reserves for a
6
+ * break, and taking a break is a decision a person makes, not a default.
7
+ */
8
+ /* **It was `latest` - every move - and that was the wrong default**, measured the moment it ran:
9
+ * a `^5.3.0` came back as `^7.0.2`. The values are the scheme's own `bumpNames` rather than a list
10
+ * written here, for the reason `version` gives: `patch`/`minor`/`major` are semver's words, and a
11
+ * four-part scheme would offer four. */
12
+ function defaultTarget(bumps) {
13
+ return bumps[Math.max(0, bumps.length - 2)];
14
+ }
15
+ DependencyUpdater.defaultTarget = defaultTarget;
16
+ /**
17
+ * The largest move `settings` allow for the dependency `name`, and the key that said so - the
18
+ * **last** `deps.targets` glob matching it, else `deps.target`.
19
+ */
20
+ /* Last rather than first, because that is how two `"[selector]"` blocks layer: what is written
21
+ * later wins. */
22
+ function targetFor(settings, name) {
23
+ let found;
24
+ for (const [glob, size] of Object.entries(settings.targets)) {
25
+ if (micromatch.isMatch(name, glob))
26
+ found = { size, from: `deps.targets["${glob}"]` };
27
+ }
28
+ return found ?? { size: settings.target, from: 'deps.target' };
29
+ }
30
+ DependencyUpdater.targetFor = targetFor;
31
+ /** The settings `pkg` is planned with: `options` from the command line over its `.rmanrc "deps"`,
32
+ * and the defaults under both. */
33
+ function settingsFor(pkg, options) {
34
+ const config = pkg.config.deps ?? {};
35
+ const bumps = pkg.versionScheme.bumpNames;
36
+ const target = options.target ?? config.target ?? defaultTarget(bumps);
37
+ if (!bumps.includes(target)) {
38
+ throw new Error(`Invalid "deps.target" for "${pkg.name}": "${target}" (expected one of: ${bumps.join(', ')}, ` +
39
+ `the "${pkg.versionScheme.name}" scheme's own sizes)`);
40
+ }
41
+ const targets = options.target ? {} : { ...(config.targets ?? {}) };
42
+ for (const [glob, size] of Object.entries(targets)) {
43
+ if (!bumps.includes(size)) {
44
+ throw new Error(`Invalid "deps.targets" entry "${glob}" for "${pkg.name}": "${size}" (expected one of: ${bumps.join(', ')})`);
45
+ }
46
+ }
47
+ return {
48
+ target,
49
+ targets,
50
+ reject: [...toList(config.reject), ...(options.reject ?? [])],
51
+ minAge: options.minAge ?? config.minAge ?? 0,
52
+ types: config.types?.length ? [...config.types] : undefined,
53
+ };
54
+ }
55
+ DependencyUpdater.settingsFor = settingsFor;
56
+ })(DependencyUpdater || (DependencyUpdater = {}));
57
+ function toList(value) {
58
+ if (value === undefined)
59
+ return [];
60
+ return Array.isArray(value) ? [...value] : [value];
61
+ }
@@ -4,6 +4,7 @@ import type { BinPath } from '../../utils/bin-path.js';
4
4
  import type { RmanApplication } from '../application.js';
5
5
  import type { Repository } from '../classes/repository.js';
6
6
  import type { Workspace } from '../classes/workspace.js';
7
+ import type { DependencyUpdater } from './dependency-updater.js';
7
8
  import type { ManifestProvider } from './manifest.js';
8
9
  /**
9
10
  * **One technology, as a whole** - Node, Cargo, Maven. Everything rman needs in order to treat a
@@ -75,6 +76,9 @@ export interface Platform {
75
76
  /** How this technology's releases are planned - where a package's change boundary comes from
76
77
  * when it has no release tag, and how far into its group a bump reaches. */
77
78
  versionPlanner?: VersionPlanService;
79
+ /** How this technology's dependencies are checked against their registry and moved forward -
80
+ * what `rman deps` asks. Absent, `deps` leaves this technology's packages alone. */
81
+ dependencyUpdater?: DependencyUpdater;
78
82
  }
79
83
  /**
80
84
  * **A plugin is whatever a package contributes; a platform is one of the things it can contribute.**
@@ -69,6 +69,11 @@ export interface PublishTarget {
69
69
  * only what is its own, such as npm's `private`.
70
70
  */
71
71
  skipReason?(pkg: Package): string | undefined;
72
+ /**
73
+ * The name `rman list` shows for this package when it says more than `name` does - npm's is the
74
+ * registry's host, `npm.pkg.github.com` for GitHub Packages. `undefined` shows `name`.
75
+ */
76
+ labelFor?(pkg: Package): string | undefined;
72
77
  /** What this target *would* do - never publishes. Called even under `--dry-run`, which is the
73
78
  * whole point of the split. */
74
79
  getPlan(ctx: PublishTarget.Context): Promise<PublishTarget.Entry[]>;
package/index.d.ts CHANGED
@@ -65,6 +65,8 @@ export { Manifest } from './core/interfaces/manifest.js';
65
65
  export { Registry } from './core/classes/registry.js';
66
66
  export { Repository } from './core/classes/repository.js';
67
67
  export { Service, type ServiceFactory, type ServiceMap } from './core/classes/service.js';
68
+ /** The `deps` seam: how a technology checks its dependencies against a registry and moves them. */
69
+ export { DependencyUpdater } from './core/interfaces/dependency-updater.js';
68
70
  export { declaredTargets, type PublishTarget, shipsTo, skipReasonFor, targetsOf, unknownTargets, } from './core/interfaces/publish-target.js';
69
71
  export type { RunConditionFn, RunStepContext, RunStepFn, RunStepObject, RunStepValue, } from './core/interfaces/run-step.js';
70
72
  /**
package/index.js CHANGED
@@ -71,6 +71,8 @@ export { Manifest } from './core/interfaces/manifest.js';
71
71
  export { Registry } from './core/classes/registry.js';
72
72
  export { Repository } from './core/classes/repository.js';
73
73
  export { Service } from './core/classes/service.js';
74
+ /** The `deps` seam: how a technology checks its dependencies against a registry and moves them. */
75
+ export { DependencyUpdater } from './core/interfaces/dependency-updater.js';
74
76
  export { declaredTargets, shipsTo, skipReasonFor, targetsOf, unknownTargets, } from './core/interfaces/publish-target.js';
75
77
  export { basePlatform, definePlatform, definePlugin, isPlatform, } from './core/interfaces/plugin.js';
76
78
  /** The numbering seam. `VersionScheme` is abstract - `highestVersion`/`highestBump`/`smallestBump`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "2.12.0",
4
+ "version": "2.14.0",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -1,6 +1,7 @@
1
1
  import type { Package } from '../core/classes/package.js';
2
2
  import type { Repository } from '../core/classes/repository.js';
3
3
  import { Service } from '../core/classes/service.js';
4
+ import { GitHelper } from '../utils/git.js';
4
5
  import { type PackageFilterOptions } from '../utils/package-filter.js';
5
6
  /**
6
7
  * A service class - see `ListService` for the shape and `Service` for the three measured
@@ -55,6 +56,12 @@ export declare class ChangelogService extends Service {
55
56
  * `options.fromRoot` says otherwise (see `Repository.currentPackage`).
56
57
  */
57
58
  getEntries(options?: ChangelogService.Options): Promise<ChangelogService.Entry[]>;
59
+ /**
60
+ * Where each member package's directory was over time, newest first - `since` is the commit that
61
+ * moved it there, absent for the oldest. A package that never moved has one entry, its own
62
+ * directory. Read from the moves of the package's manifest (`GitHelper.moveHistory`).
63
+ */
64
+ protected packageHomes(git: GitHelper): Promise<PackageHomes>;
58
65
  }
59
66
  /**
60
67
  * What one changelog file is about - `.rmanrc changelog.groupBy`, `'package'` when nothing says.
@@ -68,6 +75,13 @@ export declare class ChangelogService extends Service {
68
75
  * per package - the same entry prepended once per member otherwise.
69
76
  */
70
77
  export declare function changelogGroupBy(repository: Repository, options?: ChangelogService.Options): 'package' | 'group';
78
+ /** Where a package's directory was over time, newest first - see `ChangelogService.packageHomes`. */
79
+ type PackageHomes = Map<Package, Home[]>;
80
+ interface Home {
81
+ dir: string;
82
+ /** The commit that moved the package here; absent for its first directory. */
83
+ since?: string;
84
+ }
71
85
  export declare namespace ChangelogService {
72
86
  interface Options extends PackageFilterOptions {
73
87
  /** Generate the changelog since this commit/hash - applied the same way to every package.
@@ -212,3 +226,4 @@ declare module '../core/classes/service.js' {
212
226
  changelog: ChangelogService;
213
227
  }
214
228
  }
229
+ export {};
@@ -154,6 +154,9 @@ export class ChangelogService extends Service {
154
154
  progress?.step(group.label, 'commits');
155
155
  return listCommitsCached(from, group.label);
156
156
  }));
157
+ /** Where each package's directory was over time - so a commit made before a package moved is
158
+ * still its own. See `packageHomes`. */
159
+ const homes = await this.packageHomes(git);
157
160
  const entries = [];
158
161
  for (let i = 0; i < groups.length; i++) {
159
162
  const group = groups[i];
@@ -177,6 +180,7 @@ export class ChangelogService extends Service {
177
180
  * falls inside the range and there is exactly one segment - which is what `version
178
181
  * --changelog` and `github-release` see too, since both pass an explicit boundary.
179
182
  */
183
+ const dirsAt = homesAt(homes, commitsByTarget[i]);
180
184
  const floor = await resolveStartingPoint(git, pkg, options);
181
185
  const floorIndex = floor?.sha ? commitsByTarget[i].findIndex(c => c.sha === floor.sha) : -1;
182
186
  const withUnreleased = resolveUnreleased(pkg, options);
@@ -189,7 +193,7 @@ export class ChangelogService extends Service {
189
193
  continue;
190
194
  /** A commit belongs to a group when it belongs to **any** of its members - one line for a
191
195
  * 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)));
196
+ const ownCommits = dropVersionBumps(segment.commits).filter(c => [...ownersOf(repository, c, dirsAt(c))].some(owner => group.members.has(owner)));
193
197
  if (!ownCommits.length)
194
198
  continue;
195
199
  const sections = groupCommits(ownCommits, ignoreTypesConfig(pkg), titles, order, withCommitHash(pkg, options));
@@ -219,6 +223,33 @@ export class ChangelogService extends Service {
219
223
  }
220
224
  return entries;
221
225
  }
226
+ /**
227
+ * Where each member package's directory was over time, newest first - `since` is the commit that
228
+ * moved it there, absent for the oldest. A package that never moved has one entry, its own
229
+ * directory. Read from the moves of the package's manifest (`GitHelper.moveHistory`).
230
+ */
231
+ /* **Why a commit cannot simply be matched against today's directories**: measured on
232
+ * `panates/syncbridge`, whose last commit moved every package under `packages/<scope>/`. Every
233
+ * earlier commit touched the old paths, which no package sits at any more, so all of them were
234
+ * attributed to the root - `changelog --write` under `groupBy: package` wrote one root file and
235
+ * nothing for any package, while git still held 259 commits of hl7's and 283 of iomt's.
236
+ *
237
+ * **The manifest, not the files**: one file per package says where its directory was, so this is
238
+ * one `git log --follow` per package, run at once - 40ms each there, 120ms on `panates/sqb`. A
239
+ * single-package repository has nothing to follow: its package is the root, which does not move. */
240
+ async packageHomes(git) {
241
+ const homes = new Map();
242
+ const packages = this.repository.monorepo ? this.repository.packages : [];
243
+ await Promise.all(packages.map(async (pkg) => {
244
+ if (!pkg.manifestFileName)
245
+ return;
246
+ const moves = await git.moveHistory(pkg.manifestFileName);
247
+ const periods = [{ dir: pkg.dirname, since: moves[0]?.sha }];
248
+ moves.forEach((move, k) => periods.push({ dir: path.dirname(move.from), since: moves[k + 1]?.sha }));
249
+ homes.set(pkg, periods);
250
+ }));
251
+ return homes;
252
+ }
222
253
  }
223
254
  /**
224
255
  * What one changelog file is about - `.rmanrc changelog.groupBy`, `'package'` when nothing says.
@@ -856,16 +887,45 @@ async function splitByRelease(git, pkg, commits) {
856
887
  * fallback for anything outside every package (e.g. root-level config files). Mirrors
857
888
  * `Repository.currentPackage`'s longest-prefix logic, but always resolves to *something*
858
889
  * (root), rather than `undefined`, since every file belongs to some changelog. */
859
- function owningPackage(repository, file) {
890
+ function owningPackage(repository, file, dirOf) {
860
891
  let best = repository.rootPackage;
892
+ let bestDir = repository.rootPackage.dirname;
861
893
  for (const pkg of repository.packages) {
862
- const rel = path.relative(pkg.dirname, file);
894
+ const dir = dirOf(pkg);
895
+ const rel = path.relative(dir, file);
863
896
  const isSelfOrDescendant = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
864
- if (isSelfOrDescendant && pkg.dirname.length > best.dirname.length)
897
+ if (isSelfOrDescendant && dir.length > bestDir.length) {
865
898
  best = pkg;
899
+ bestDir = dir;
900
+ }
866
901
  }
867
902
  return best;
868
903
  }
904
+ /**
905
+ * For one fetched commit list, the function that answers where each package's directory was when a
906
+ * given commit was made.
907
+ */
908
+ /* **Ordered by position in the list, not by asking git**: the list is oldest first and runs to HEAD,
909
+ * so a move inside it is compared by index, and a move *not* in it happened at or before the range's
910
+ * boundary - every commit in the range is after it. One `merge-base --is-ancestor` per commit and
911
+ * package would be the exact answer and costs a process each; this costs a Map. */
912
+ function homesAt(homes, commits) {
913
+ const index = new Map(commits.map((c, i) => [c.sha, i]));
914
+ return commit => pkg => {
915
+ const periods = homes.get(pkg);
916
+ if (!periods)
917
+ return pkg.dirname;
918
+ const at = index.get(commit.sha) ?? commits.length;
919
+ for (const period of periods) {
920
+ if (!period.since)
921
+ return period.dir;
922
+ const movedAt = index.get(period.since);
923
+ if (movedAt === undefined || movedAt <= at)
924
+ return period.dir;
925
+ }
926
+ return periods[periods.length - 1].dir;
927
+ };
928
+ }
869
929
  /** A commit touching more than this fraction of all packages (a repo-wide relicense, a doc
870
930
  * update stamped into every package's README, ...) is treated as a repo-wide change rather than
871
931
  * attributed to each of them - see `ownersOf`. Without this, one such commit would show up
@@ -883,10 +943,10 @@ const BROAD_COMMIT_MIN_PACKAGES = 3;
883
943
  * doc pass across every package, ...), not something that belongs in each package's own release
884
944
  * notes individually - it's attributed to the root alone.
885
945
  */
886
- function ownersOf(repository, commit) {
946
+ function ownersOf(repository, commit, dirOf) {
887
947
  const owners = new Set();
888
948
  for (const f of commit.files)
889
- owners.add(owningPackage(repository, f));
949
+ owners.add(owningPackage(repository, f, dirOf));
890
950
  const totalPackages = repository.packages.length;
891
951
  const nonRootOwners = [...owners].filter(p => p !== repository.rootPackage).length;
892
952
  if (totalPackages > 0 &&
@@ -63,6 +63,15 @@ export declare namespace ListService {
63
63
  * `--toposort` - which reorders the rows and leaves the nesting where it is.
64
64
  */
65
65
  depth: number;
66
+ /**
67
+ * Which release group the package versions with - `.rmanrc group`, the key `version` batches its
68
+ * plan by: `default` for the implicit repo-wide group, `named:<name>` for a named one,
69
+ * `solo:<package>` for `group: false`, and `__root__` for a monorepo's root, which belongs to
70
+ * none. The same spelling `version --json` reports.
71
+ */
72
+ groupKey: string;
73
+ /** `groupKey`'s name - the group's own name, `default`, the package's for a solo one, `root`. */
74
+ group: string;
66
75
  /** Whether this row *is* the root package - present only when `includeRoot` asked for it, or in
67
76
  * a single-package repository where the root is the one member. */
68
77
  isRoot: boolean;
@@ -81,6 +90,10 @@ export declare namespace ListService {
81
90
  * registry, so `{}` means "a candidate", not "will publish": whether the version is already out
82
91
  * there is `publish --dry-run`'s answer. */
83
92
  skippedTargets: Record<PublishTargetName, string>;
93
+ /** What the table shows in place of a target's name, where the target says more for this
94
+ * package - npm's registry host when `publishConfig.registry` names one other than npm's own
95
+ * (`{ npm: 'npm.pkg.github.com' }`). A label only: `publishTargets` and `--target` keep the name. */
96
+ targetLabels: Record<PublishTargetName, string>;
84
97
  /** Present only when `"docker"` is one of `publishTargets` and `publish.docker` is configured -
85
98
  * the raw `.rmanrc` config, unresolved (no namespace prefixing - see `DockerPublishService`). */
86
99
  docker?: DockerPublishOptions;
@@ -2,6 +2,7 @@ import path from 'path';
2
2
  import { Service } from '../core/classes/service.js';
3
3
  import { skipReasonFor, targetsOf } from '../core/interfaces/publish-target.js';
4
4
  import { filterPackages } from '../utils/package-filter.js';
5
+ import { groupKeyOf, isNamedGroupKey, isSoloGroupKey, ROOT_GROUP_KEY } from '../utils/version-group.js';
5
6
  /**
6
7
  * **A class, and the shape every service follows now.**
7
8
  *
@@ -36,13 +37,21 @@ export class ListService extends Service {
36
37
  /** **A monorepo's root ships nowhere**: `publish` never makes it a candidate
37
38
  * (`repository.getPackages()` leaves it out), so listing the target a root *would* claim
38
39
  * says something no command does. */
39
- const targets = p === repository.rootPackage && repository.monorepo ? [] : targetsOf(this.app, p);
40
+ const monorepoRoot = p === repository.rootPackage && repository.monorepo;
41
+ const targets = monorepoRoot ? [] : targetsOf(this.app, p);
42
+ /** The root's own key, as in `version`'s plan: a monorepo's root is never a group member - its
43
+ * number is the repository's identity. */
44
+ const groupKey = monorepoRoot ? ROOT_GROUP_KEY : groupKeyOf(p);
40
45
  const publishTargets = targets.map(t => t.name);
41
46
  const skippedTargets = {};
47
+ const targetLabels = {};
42
48
  for (const target of targets) {
43
49
  const reason = skipReasonFor(p, target);
44
50
  if (reason)
45
51
  skippedTargets[target.name] = reason;
52
+ const label = target.labelFor?.(p);
53
+ if (label && label !== target.name)
54
+ targetLabels[target.name] = label;
46
55
  }
47
56
  return {
48
57
  name: p.name,
@@ -50,6 +59,8 @@ export class ListService extends Service {
50
59
  version: p.version,
51
60
  platform: p.provider,
52
61
  depth: depthOf(p),
62
+ groupKey,
63
+ group: groupNameOf(groupKey),
53
64
  isRoot: p === repository.rootPackage,
54
65
  location: path.relative(repository.dirname, p.dirname) || '.',
55
66
  private: p.isPrivate,
@@ -57,6 +68,7 @@ export class ListService extends Service {
57
68
  dependencies: p.dependencies.map(d => d.name),
58
69
  publishTargets: [...publishTargets],
59
70
  skippedTargets,
71
+ targetLabels,
60
72
  docker: publishTargets.includes('docker') ? p.config.publish?.docker : undefined,
61
73
  };
62
74
  };
@@ -90,3 +102,13 @@ function depthOf(pkg) {
90
102
  depth++;
91
103
  return depth;
92
104
  }
105
+ /** The name behind a group key - the same reading `VersionPlanService.groupLabel` makes. */
106
+ function groupNameOf(key) {
107
+ if (key === ROOT_GROUP_KEY)
108
+ return 'root';
109
+ if (isNamedGroupKey(key))
110
+ return key.slice('named:'.length);
111
+ if (isSoloGroupKey(key))
112
+ return key.slice('solo:'.length);
113
+ return key;
114
+ }
package/utils/git.d.ts CHANGED
@@ -12,6 +12,8 @@ export interface CommitInfo {
12
12
  }
13
13
  export declare class GitHelper {
14
14
  cwd: string;
15
+ /** `movedShare`'s per-commit rename lists. */
16
+ private _renames;
15
17
  constructor(options?: GitOptions);
16
18
  /** The current branch name, or `undefined` in detached HEAD state (or outside a git repository) -
17
19
  * what `branch-guard.ts` checks `allowBranch`/`ignoreBranch` against. */
@@ -67,6 +69,21 @@ export declare class GitHelper {
67
69
  * never been committed. Answers "since when has this file last been updated", independent of
68
70
  * any tag/version scheme - e.g. to check whether a changelog file has fallen behind. */
69
71
  lastCommitTouching(file: string): Promise<string | undefined>;
72
+ /**
73
+ * Every time `file` was **moved together with its directory**, newest first - `{ sha, from, to }`,
74
+ * absolute paths of the file, and the commit that moved it. Empty for a file that never moved, or
75
+ * one git has no history of. Meant for a package manifest: where it went, its package went.
76
+ */
77
+ moveHistory(file: string): Promise<{
78
+ sha: string;
79
+ from: string;
80
+ to: string;
81
+ }[]>;
82
+ /**
83
+ * Of the files `fromDir` held just before `sha`, the share `sha` renamed into `toDir` - `0` to `1`.
84
+ * Both directories relative to `cwd`, in git's `/` form.
85
+ */
86
+ movedShare(sha: string, fromDir: string, toDir: string): Promise<number>;
70
87
  /** The best common ancestor of `a` and `b` - `undefined` if none exists (unrelated histories,
71
88
  * an invalid ref, ...). */
72
89
  mergeBase(a: string, b: string): Promise<string | undefined>;
package/utils/git.js CHANGED
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
  import { promisify } from 'node:util';
4
4
  export class GitHelper {
5
5
  cwd;
6
+ /** `movedShare`'s per-commit rename lists. */
7
+ _renames = new Map();
6
8
  constructor(options) {
7
9
  this.cwd = options?.cwd || process.cwd();
8
10
  }
@@ -203,6 +205,88 @@ export class GitHelper {
203
205
  return undefined;
204
206
  }
205
207
  }
208
+ /**
209
+ * Every time `file` was **moved together with its directory**, newest first - `{ sha, from, to }`,
210
+ * absolute paths of the file, and the commit that moved it. Empty for a file that never moved, or
211
+ * one git has no history of. Meant for a package manifest: where it went, its package went.
212
+ */
213
+ /* **Renames only, and the walk stops at the first copy.** `git log --follow` also crosses a *copy*
214
+ * (`C`), and for a manifest that is the dangerous case: package manifests resemble one another, so a
215
+ * new package created from an old one's `package.json` is followed into that package's history.
216
+ * Measured on `panates/syncbridge`: `@syncbridge/hl7`'s manifest came back as `C054` from
217
+ * `packages/common/package.json` - a different package, `@panates-prv/syncbridge-common`, which was
218
+ * not even deleted in that commit - and following it would have handed hl7 137 commits of another
219
+ * package's history. A rename (`R`) means the old file is gone, which a copy does not.
220
+ *
221
+ * **And a rename counts only where the directory went too** - more than half of the files the old
222
+ * directory held landing in the new one (`movedShare`). Two manifests are similar enough to pair
223
+ * when a directory is *split*: in the same repository `packages/builtins` was broken into ten
224
+ * packages in one commit, git paired its `package.json` with `packages-ext/serialport`'s at
225
+ * `R052`, and serialport had received 3 of its 43 files. The old directory belongs to no one of
226
+ * them, so the walk stops there and its history stays the root's - unattributed rather than
227
+ * guessed. A real move clears this easily: `iomt-connectors` took 101 of its 110 files. */
228
+ async moveHistory(file) {
229
+ let stdout;
230
+ try {
231
+ ({ stdout } = await execFileAsync('git', ['log', '--follow', '--format=%x00%H', '--name-status', '--', path.relative(this.cwd, file)], { cwd: this.cwd, maxBuffer: 64 * 1024 * 1024 }));
232
+ }
233
+ catch {
234
+ return [];
235
+ }
236
+ const moves = [];
237
+ for (const entry of stdout.split('\0').filter(Boolean)) {
238
+ const [sha, ...lines] = entry.split(/\r?\n/).filter(Boolean);
239
+ const [status = '', from, to] = (lines[0] ?? '').split('\t');
240
+ if (status.startsWith('R') && from && to) {
241
+ const fromDir = path.posix.dirname(from);
242
+ const toDir = path.posix.dirname(to);
243
+ /** Renamed within its own directory: the package did not move. */
244
+ if (fromDir === toDir)
245
+ continue;
246
+ if ((await this.movedShare(sha.trim(), fromDir, toDir)) <= 0.5)
247
+ break;
248
+ moves.push({ sha: sha.trim(), from: path.join(this.cwd, from), to: path.join(this.cwd, to) });
249
+ }
250
+ else if (status.startsWith('C') || status === 'A')
251
+ break;
252
+ }
253
+ return moves;
254
+ }
255
+ /**
256
+ * Of the files `fromDir` held just before `sha`, the share `sha` renamed into `toDir` - `0` to `1`.
257
+ * Both directories relative to `cwd`, in git's `/` form.
258
+ */
259
+ /* The rename list is cached per commit: a commit that moves every package is asked about once per
260
+ * package, and it is one `git show` however many ask. */
261
+ async movedShare(sha, fromDir, toDir) {
262
+ let renames = this._renames.get(sha);
263
+ if (!renames) {
264
+ renames = execFileAsync('git', ['show', '-M', '--name-status', '--format=', sha], {
265
+ cwd: this.cwd,
266
+ maxBuffer: 64 * 1024 * 1024,
267
+ }).then(({ stdout }) => stdout
268
+ .split(/\r?\n/)
269
+ .map(line => line.split('\t'))
270
+ .filter(([status, from, to]) => status?.startsWith('R') && from && to)
271
+ .map(([, from, to]) => ({ from, to })), () => []);
272
+ this._renames.set(sha, renames);
273
+ }
274
+ let held;
275
+ try {
276
+ const { stdout } = await execFileAsync('git', ['ls-tree', '-r', '--name-only', `${sha}^`, '--', `${fromDir}/`], {
277
+ cwd: this.cwd,
278
+ maxBuffer: 64 * 1024 * 1024,
279
+ });
280
+ held = stdout.split(/\r?\n/).filter(Boolean);
281
+ }
282
+ catch {
283
+ return 0;
284
+ }
285
+ if (!held.length)
286
+ return 0;
287
+ const into = (await renames).filter(r => r.from.startsWith(`${fromDir}/`) && r.to.startsWith(`${toDir}/`)).length;
288
+ return into / held.length;
289
+ }
206
290
  /** The best common ancestor of `a` and `b` - `undefined` if none exists (unrelated histories,
207
291
  * an invalid ref, ...). */
208
292
  async mergeBase(a, b) {