rman 2.12.0 → 2.13.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.
@@ -133,6 +133,10 @@ export declare namespace PublishService {
133
133
  * `undefined` when it would be published. `rman list` asks this to grey a package out.
134
134
  */
135
135
  function skipReason(pkg: Package, contentsOverride?: string): string | undefined;
136
+ /** The host of a package's `publishConfig.registry` - `npm.pkg.github.com` for GitHub Packages -
137
+ * or `undefined` where it names none, or names npm's own registry. What `rman list` shows in
138
+ * place of `npm`. */
139
+ function registryLabel(pkg: Package): string | undefined;
136
140
  function getPlan(repository: Repository, options?: Options, deps?: Deps): Promise<Entry[]>;
137
141
  /**
138
142
  * Publishes every `'publish'` entry in `plan`, topological order (already `plan`'s own order -
@@ -173,6 +173,26 @@ export var PublishService;
173
173
  return undefined;
174
174
  }
175
175
  PublishService.skipReason = skipReason;
176
+ /** The host of a package's `publishConfig.registry` - `npm.pkg.github.com` for GitHub Packages -
177
+ * or `undefined` where it names none, or names npm's own registry. What `rman list` shows in
178
+ * place of `npm`. */
179
+ /* **`publishConfig.registry` only**, which is one package's own statement. A scoped
180
+ * `@owner:registry=` in `.npmrc` also sends a package elsewhere, and is not read: npm resolves it
181
+ * itself (see `resolveRegistry`), so such a package still shows `npm`. A value that is not a URL
182
+ * shows `npm` too - `npm publish` is what will say it is wrong. */
183
+ function registryLabel(pkg) {
184
+ const registry = resolveRegistry(pkg, undefined);
185
+ if (!registry)
186
+ return undefined;
187
+ try {
188
+ const host = new URL(registry).hostname;
189
+ return host && host !== NPMJS_HOST ? host : undefined;
190
+ }
191
+ catch {
192
+ return undefined;
193
+ }
194
+ }
195
+ PublishService.registryLabel = registryLabel;
176
196
  async function getPlan(repository, options = {}, deps = {}) {
177
197
  const git = new GitHelper({ cwd: repository.dirname });
178
198
  const packages = filterPackages(repository.getPackages({ toposort: true }), options);
@@ -431,6 +451,8 @@ function resolveRegistry(pkg, override) {
431
451
  const native = pkg.manifest.raw.publishConfig?.registry;
432
452
  return override || (typeof native === 'string' && native ? native : undefined);
433
453
  }
454
+ /** npm's own registry, which `PublishService.registryLabel` leaves shown as `npm`. */
455
+ const NPMJS_HOST = 'registry.npmjs.org';
434
456
  /**
435
457
  * The retired `publish.directory` spelling, if a config still carries it.
436
458
  *
@@ -73,6 +73,8 @@ export declare class NpmPublishTarget implements PublishTarget {
73
73
  };
74
74
  /** npm's `private` rule, the same function `getPlan` asks. */
75
75
  skipReason(pkg: Package): string | undefined;
76
+ /** The registry's host where `publishConfig.registry` names one other than npm's own. */
77
+ labelFor(pkg: Package): string | undefined;
76
78
  getPlan(ctx: PublishTarget.Context): Promise<PublishService.Entry[]>;
77
79
  applyPlan(ctx: PublishTarget.Context, plan: PublishTarget.Entry[]): Promise<PublishService.Entry[]>;
78
80
  /** The shared filters plus the two flags both halves need - read here rather than in the
@@ -79,6 +79,10 @@ export class NpmPublishTarget {
79
79
  skipReason(pkg) {
80
80
  return PublishService.skipReason(pkg);
81
81
  }
82
+ /** The registry's host where `publishConfig.registry` names one other than npm's own. */
83
+ labelFor(pkg) {
84
+ return PublishService.registryLabel(pkg);
85
+ }
82
86
  getPlan(ctx) {
83
87
  return PublishService.getPlan(ctx.repository, this.planOptions(ctx));
84
88
  }
@@ -2,6 +2,7 @@ import colors from 'ansi-colors';
2
2
  import EasyTable from 'easy-table';
3
3
  import { registerCommand } from '../interfaces/rman-config.interface.js';
4
4
  import { packageFilterOptions, readPackageFilterOptions } from '../utils/package-filter.js';
5
+ import { isNamedGroupKey, isSoloGroupKey, ROOT_GROUP_KEY } from '../utils/version-group.js';
5
6
  /** `[options...]` is yargs-meaningless - its variadic marker is two dots, and this command declares
6
7
  * no positional for it. Left as it is rather than fixed in passing: dropping it makes
7
8
  * `rman list foo` start failing, which is a behaviour change and belongs in its own commit. */
@@ -82,8 +83,9 @@ const listCommand = registerCommand(app => {
82
83
  * so counting rows that are not the root reported `0 Package(s) found` there (measured). The
83
84
  * condition is the same one the service applies, and it is one line.
84
85
  */
86
+ /** Grouped, except where the order *is* the answer: `--toposort` is dependency order. */
85
87
  else
86
- printTable(items, table && app.repository.monorepo);
88
+ printTable(args.toposort ? items : byGroup(items), table && app.repository.monorepo);
87
89
  },
88
90
  };
89
91
  });
@@ -100,10 +102,54 @@ function statusLabel(status) {
100
102
  return '';
101
103
  }
102
104
  }
103
- /** Where a package ships, one target name each - grey for a target `publish` would skip it for
104
- * (`Item.skippedTargets`), so the column never reads as a promise the release will not keep. */
105
+ /** Where a package ships, one target each - its label where it has one (`Item.targetLabels`, npm's
106
+ * registry host). A target `publish` would skip it for (`Item.skippedTargets`) is left out, and a
107
+ * package left with none reads `-`, so the column never reads as a promise the release will not
108
+ * keep. Blank where no target claims the package at all - a monorepo's root, for one. */
105
109
  function publishLabel(it) {
106
- return it.publishTargets.map(name => (it.skippedTargets[name] ? colors.gray(name) : colors.green(name))).join(', ');
110
+ if (!it.publishTargets.length)
111
+ return '';
112
+ const shipping = it.publishTargets.filter(name => !it.skippedTargets[name]);
113
+ if (!shipping.length)
114
+ return colors.gray('-');
115
+ return shipping.map(name => colors.green(it.targetLabels[name] ?? name)).join(', ');
116
+ }
117
+ /**
118
+ * The rows with each release group's members together - the root first, then the groups in the
119
+ * order their first member appears, then the packages in no group (`group: false`) - the order
120
+ * `version`'s plan prints them in. Stable within a group, so the lexical order holds there.
121
+ */
122
+ /* The cost, stated: a package nested inside another can land away from it, when the two are in
123
+ * different groups - its indentation still says how deep it sits, not under which row. */
124
+ function byGroup(items) {
125
+ const root = items.filter(it => it.groupKey === ROOT_GROUP_KEY);
126
+ const groups = new Map();
127
+ const solo = [];
128
+ for (const it of items) {
129
+ if (it.groupKey === ROOT_GROUP_KEY)
130
+ continue;
131
+ if (isSoloGroupKey(it.groupKey))
132
+ solo.push(it);
133
+ else
134
+ groups.set(it.groupKey, [...(groups.get(it.groupKey) ?? []), it]);
135
+ }
136
+ return [...root, ...[...groups.values()].flat(), ...solo];
137
+ }
138
+ /** Whether the Group column says anything: a group the repository named, or the default group
139
+ * beside packages in none. One line for everything, or no grouping at all, would put the same
140
+ * answer - or nothing - on every row. */
141
+ function showsGroups(items) {
142
+ const keys = items.map(it => it.groupKey).filter(key => key !== ROOT_GROUP_KEY);
143
+ return keys.some(isNamedGroupKey) || (keys.includes('default') && keys.some(isSoloGroupKey));
144
+ }
145
+ /** A named group by its name, the default one as a grey `(default)` - `version`'s plan table reads
146
+ * the same way. Blank for the root and for a package in no group. */
147
+ function groupCell(it) {
148
+ if (isNamedGroupKey(it.groupKey))
149
+ return colors.cyan(it.group);
150
+ if (it.groupKey === 'default')
151
+ return colors.gray('(default)');
152
+ return '';
107
153
  }
108
154
  /**
109
155
  * **The tree, as a table**: the root first, then each package indented by how far below it sits.
@@ -125,10 +171,13 @@ function publishLabel(it) {
125
171
  */
126
172
  function printTable(items, withRoot) {
127
173
  const table = new EasyTable();
174
+ const withGroup = showsGroups(items);
128
175
  for (const it of items) {
129
176
  const indent = ' '.repeat(it.depth);
130
177
  table.cell('Package', indent + (it.isRoot ? colors.whiteBright(it.selector) : colors.yellowBright(it.selector)));
131
178
  table.cell('Version', colors.yellow(it.version));
179
+ if (withGroup)
180
+ table.cell('Group', groupCell(it));
132
181
  table.cell('Platform', it.platform ? colors.cyan(it.platform) : '');
133
182
  table.cell('Private', it.private ? colors.magentaBright('yes') : '');
134
183
  table.cell('Publish', publishLabel(it));
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.12.0';
1
+ export const version = '2.13.0';
@@ -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/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.13.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) {