rman 1.0.8 → 1.0.9

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.
@@ -20,6 +20,22 @@ export async function findLatestTag(git, pkg) {
20
20
  const expanded = pattern.replace('{name}', pkg.name);
21
21
  return pattern.includes('{name}') ? (await git.listTags(expanded))[0] : await git.describeTag(expanded);
22
22
  }
23
+ /** The forward direction of `findLatestTag`: expands `pkg`'s (cascaded) `.rmanrc
24
+ * changelog.tagPattern` into the concrete tag name `version` belongs under - `{name}` becomes the
25
+ * package's own name, `*` becomes `version`. Shared by `version` (creating the tag), `publish
26
+ * --target github` (finding the release that tag belongs to), and `detectChangeHash`'s own npm
27
+ * fallback (mapping a published version back onto a tag), so all three name tags identically. */
28
+ export function expandTag(pkg, version) {
29
+ return applyTagPattern(tagPattern(pkg), pkg.name, version);
30
+ }
31
+ /** The pattern expansion `expandTag` performs, on any pattern - `{name}` becomes `name`, `*` becomes
32
+ * `version`. Shared with the repository's own release tag, which uses a different pattern (see
33
+ * `releaseTagPattern`) but names tags the same way. */
34
+ export function applyTagPattern(pattern, name, version) {
35
+ const expanded = pattern.replace('{name}', name);
36
+ const starIdx = expanded.indexOf('*');
37
+ return starIdx === -1 ? expanded : expanded.slice(0, starIdx) + version + expanded.slice(starIdx + 1);
38
+ }
23
39
  /** Strips the pattern's literal prefix (everything before its first `*`) from `tag` to get just
24
40
  * the version part - e.g. tag `@sqb/builder@1.2.3` against pattern `@sqb/builder@*` -> `1.2.3`.
25
41
  * A pattern with no `*` is returned as its own "version" verbatim (an exact tag, nothing to strip). */
@@ -47,40 +63,36 @@ export async function defaultNpmViewVersion(name, cwd) {
47
63
  * Resolves the commit/hash a package's changes should be measured "since" - the boundary
48
64
  * `changelog --from` uses, but reusable anywhere a command wants to answer "what changed for this
49
65
  * package". An explicit `options.from` (anything but `"npm"`) is returned as-is, applying the same
50
- * way to every package. Otherwise, it's auto-detected in order: (1) the package's
51
- * currently-published npm version - looked up via `npmViewVersion`, then mapped to a git tag using
52
- * `.rmanrc changelog.tagPattern` (so independent and fixed monorepo versioning schemes both work -
53
- * see `tagPattern`); (2) failing that (never published, private, no network, ...), this package's
54
- * own most recent release tag directly - the same `findLatestTag` lookup `version` itself uses, so
55
- * a package that's never been on npm (e.g. Docker-only) but has real tags from a previous `version`
56
- * run still gets a correct boundary, not just "everything ever". Either way, if `catchUpFile` is
57
- * given and exists, the result is widened to also cover anything that file hasn't caught up on yet
58
- * (see its doc comment). Returns `undefined` when nothing can be resolved at all (never published
59
- * *and* never tagged, no catch-up file - a genuinely first-ever release) - callers should fall back
60
- * to their own default in that case (e.g. `GitHelper.listCommits`'s "not yet pushed" default when
61
- * no hash is given).
66
+ * way to every package. Otherwise, it's auto-detected in order: (1) this package's own most recent
67
+ * release tag - the same network-free `findLatestTag` lookup `version`/`changed` themselves use,
68
+ * so all three commands agree on "since when" for any repo whose tags are the ones `rman version`
69
+ * actually created; (2) failing that (no tag at all yet - e.g. onboarding `rman` onto a repo with
70
+ * real npm history but no `rman`-created tags), the package's currently-published npm version -
71
+ * looked up via `npmViewVersion`, then mapped to a git tag using `.rmanrc changelog.tagPattern`
72
+ * (see `tagPattern`). Either way, if `catchUpFile` is given and exists, the result is widened to
73
+ * also cover anything that file hasn't caught up on yet (see its doc comment). Returns `undefined`
74
+ * when nothing can be resolved at all (never tagged *and* never published, no catch-up file - a
75
+ * genuinely first-ever release) - callers should fall back to their own default in that case (e.g.
76
+ * the whole history, since nothing has ever been released).
62
77
  */
63
78
  export async function detectChangeHash(git, pkg, options = {}) {
64
79
  if (options.from && options.from !== 'npm')
65
80
  return options.from;
66
- const npmViewVersion = options.npmViewVersion ?? defaultNpmViewVersion;
67
- const publishedVersion = await npmViewVersion(pkg.name, git.cwd);
68
- let npmHash;
69
- if (publishedVersion) {
70
- const pattern = tagPattern(pkg);
71
- const expanded = pattern.replace('{name}', pkg.name);
72
- const starIdx = expanded.indexOf('*');
73
- const tag = starIdx === -1 ? expanded : expanded.slice(0, starIdx) + publishedVersion + expanded.slice(starIdx + 1);
74
- npmHash = (await git.tagExists(tag)) ? tag : undefined;
81
+ let tagHash = await findLatestTag(git, pkg);
82
+ if (!tagHash) {
83
+ const npmViewVersion = options.npmViewVersion ?? defaultNpmViewVersion;
84
+ const publishedVersion = await npmViewVersion(pkg.name, git.cwd);
85
+ if (publishedVersion) {
86
+ const tag = expandTag(pkg, publishedVersion);
87
+ tagHash = (await git.tagExists(tag)) ? tag : undefined;
88
+ }
75
89
  }
76
- if (!npmHash)
77
- npmHash = await findLatestTag(git, pkg);
78
90
  const fileHash = options.catchUpFile ? await git.lastCommitTouching(options.catchUpFile) : undefined;
79
91
  if (!fileHash)
80
- return npmHash;
81
- if (!npmHash)
92
+ return tagHash;
93
+ if (!tagHash)
82
94
  return fileHash;
83
- return (await git.mergeBase(npmHash, fileHash)) ?? npmHash;
95
+ return (await git.mergeBase(tagHash, fileHash)) ?? tagHash;
84
96
  }
85
97
  const execFileAsync = promisify(execFile);
86
98
  const DEFAULT_TAG_PATTERN = 'v*';
@@ -10,6 +10,19 @@ export declare const CONVENTIONAL_PATTERN: RegExp;
10
10
  * not a real change worth describing (or worth bumping a version over on its own), so it's
11
11
  * dropped everywhere a real change is being looked for. */
12
12
  export declare const VERSION_BUMP_PATTERN: RegExp;
13
+ /**
14
+ * Whether `subject` is a release marker rather than a real change - dropped everywhere real
15
+ * changes are looked for (changelog entries, and what counts as "changed" for a version bump).
16
+ *
17
+ * Covers the bare-version form other tools use (`VERSION_BUMP_PATTERN`) plus every message shape
18
+ * `version` itself writes: its commit message (`commitMessageTemplate`, or the built-in
19
+ * `"chore(release): v{version}"` when a repo doesn't override it), the multi-version form that
20
+ * template falls back to when one commit spans several versions (`chore(release): a@1.2.0,
21
+ * b@1.3.0`), and the monorepo root's own version-sync commit. Without this, rman's own release
22
+ * commits show up in the changelogs it generates - visible whenever the boundary reaches back past
23
+ * a previous release (see `detectChangeHash`'s `catchUpFile`).
24
+ */
25
+ export declare function isReleaseCommit(subject: string, commitMessageTemplate?: string): boolean;
13
26
  export interface ParsedCommitSubject {
14
27
  type: string;
15
28
  scope?: string;
@@ -10,6 +10,32 @@ export const CONVENTIONAL_PATTERN = /^(\w+)(\(([^)]+)\))?(!)?:\s*(.+)$/;
10
10
  * not a real change worth describing (or worth bumping a version over on its own), so it's
11
11
  * dropped everywhere a real change is being looked for. */
12
12
  export const VERSION_BUMP_PATTERN = /^v?\d+\.\d+\.\d+(?:[-+][\w.]+)?$/;
13
+ /**
14
+ * Whether `subject` is a release marker rather than a real change - dropped everywhere real
15
+ * changes are looked for (changelog entries, and what counts as "changed" for a version bump).
16
+ *
17
+ * Covers the bare-version form other tools use (`VERSION_BUMP_PATTERN`) plus every message shape
18
+ * `version` itself writes: its commit message (`commitMessageTemplate`, or the built-in
19
+ * `"chore(release): v{version}"` when a repo doesn't override it), the multi-version form that
20
+ * template falls back to when one commit spans several versions (`chore(release): a@1.2.0,
21
+ * b@1.3.0`), and the monorepo root's own version-sync commit. Without this, rman's own release
22
+ * commits show up in the changelogs it generates - visible whenever the boundary reaches back past
23
+ * a previous release (see `detectChangeHash`'s `catchUpFile`).
24
+ */
25
+ export function isReleaseCommit(subject, commitMessageTemplate) {
26
+ if (VERSION_BUMP_PATTERN.test(subject))
27
+ return true;
28
+ if (ROOT_SYNC_PATTERN.test(subject))
29
+ return true;
30
+ if (MULTI_VERSION_RELEASE_PATTERN.test(subject))
31
+ return true;
32
+ // The built-in message is checked even when a repo overrides it: the override only applies to
33
+ // commits spanning a single version (see `buildCommitMessage`), and a repo that adopted one later
34
+ // still has older releases committed under the default.
35
+ if (templatePattern(DEFAULT_COMMIT_MESSAGE).test(subject))
36
+ return true;
37
+ return !!commitMessageTemplate && templatePattern(commitMessageTemplate).test(subject);
38
+ }
13
39
  /** Parses a commit subject as Conventional Commits, or `undefined` if it doesn't match at all
14
40
  * (a non-conventional message - still a real change, just with no `type` to key off of). */
15
41
  export function parseConventionalCommit(subject) {
@@ -26,6 +52,27 @@ export function parseConventionalCommit(subject) {
26
52
  export function hasBreakingChangeFooter(body) {
27
53
  return /^BREAKING[ -]CHANGE:/im.test(body);
28
54
  }
55
+ /** A semver version, as it appears inside a commit subject - the `\d+\.\d+\.\d+` core of
56
+ * `VERSION_BUMP_PATTERN`, reusable inside the larger patterns below. */
57
+ const SEMVER_SOURCE = String.raw `\d+\.\d+\.\d+(?:[-+][\w.]+)?`;
58
+ /** Mirrors `VersionService`'s own default `version.commitMessage` - kept in sync by
59
+ * `version.command.ts`'s documented default, not imported, to keep this module dependency-free. */
60
+ const DEFAULT_COMMIT_MESSAGE = 'chore(release): v{version}';
61
+ /** `VersionService.applyPlan`'s trailing commit for a monorepo root's informational version. */
62
+ const ROOT_SYNC_PATTERN = new RegExp(String.raw `^chore: sync root version to ${SEMVER_SOURCE}$`);
63
+ /** What the commit-message template falls back to when one commit covers several versions at once
64
+ * (a cross-group ripple) - `{version}` has nothing single to substitute, so each bumped package is
65
+ * listed by name instead. */
66
+ const MULTI_VERSION_RELEASE_PATTERN = new RegExp(String.raw `^chore\(release\): \S+@${SEMVER_SOURCE}(?:, \S+@${SEMVER_SOURCE})*$`);
67
+ function escapeRegExp(value) {
68
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
69
+ }
70
+ /** Turns a `version.commitMessage` template into a matcher for the commits it produces: every
71
+ * literal part escaped, each `{version}` placeholder standing in for any semver. */
72
+ function templatePattern(template) {
73
+ const source = template.split('{version}').map(escapeRegExp).join(SEMVER_SOURCE);
74
+ return new RegExp(`^${source}$`);
75
+ }
29
76
  /**
30
77
  * A `Release-As: patch|minor|major` footer in a commit `body` - lets that one commit's own
31
78
  * contribution to a detected bump severity be overridden by hand, regardless of what its subject
package/utils/git.d.ts CHANGED
@@ -57,7 +57,11 @@ export declare class GitHelper {
57
57
  /** The most recent tag matching the glob `pattern` that HEAD actually descends from (unlike
58
58
  * `listTags`, this follows commit ancestry rather than just sorting tag names - the right
59
59
  * choice for a single repo-wide tag scheme, where a package has no tag of its own). */
60
- describeTag(pattern: string): Promise<string | undefined>;
60
+ describeTag(pattern: string, ref?: string): Promise<string | undefined>;
61
+ /** The repository's very first commit (the oldest root commit, for a history with several) -
62
+ * `undefined` for a repository with no commits at all. The "since the beginning" boundary for
63
+ * a package being released for the first time, with no earlier tag to measure from. */
64
+ rootCommit(): Promise<string | undefined>;
61
65
  /** Stages and commits exactly `files` (relative to `cwd`, or absolute) with `message` - never a
62
66
  * blanket `git add -A`, so the commit only ever contains what the caller explicitly asked for. */
63
67
  commit(files: string[], message: string): Promise<void>;
@@ -71,6 +75,9 @@ export declare class GitHelper {
71
75
  remote?: string;
72
76
  tags?: boolean;
73
77
  }): Promise<void>;
78
+ /** `git remote get-url <remote>` (default `"origin"`) - `undefined` when that remote isn't
79
+ * configured at all, which is a perfectly normal state for a local-only repository. */
80
+ remoteUrl(remote?: string): Promise<string | undefined>;
74
81
  /** Raw `git diff <hash>..HEAD` text (committed changes plus any uncommitted local ones, same as
75
82
  * plain `git diff <hash>`) - unlike `listChangedSince`, the actual patch content, not just
76
83
  * which files changed. `pathspec`, if given, narrows it to just that file/directory. */
package/utils/git.js CHANGED
@@ -183,9 +183,9 @@ export class GitHelper {
183
183
  /** The most recent tag matching the glob `pattern` that HEAD actually descends from (unlike
184
184
  * `listTags`, this follows commit ancestry rather than just sorting tag names - the right
185
185
  * choice for a single repo-wide tag scheme, where a package has no tag of its own). */
186
- async describeTag(pattern) {
186
+ async describeTag(pattern, ref = 'HEAD') {
187
187
  try {
188
- const { stdout } = await execFileAsync('git', ['describe', '--tags', '--abbrev=0', '--match', pattern], {
188
+ const { stdout } = await execFileAsync('git', ['describe', '--tags', '--abbrev=0', '--match', pattern, ref], {
189
189
  cwd: this.cwd,
190
190
  });
191
191
  return stdout.trim() || undefined;
@@ -194,6 +194,19 @@ export class GitHelper {
194
194
  return undefined;
195
195
  }
196
196
  }
197
+ /** The repository's very first commit (the oldest root commit, for a history with several) -
198
+ * `undefined` for a repository with no commits at all. The "since the beginning" boundary for
199
+ * a package being released for the first time, with no earlier tag to measure from. */
200
+ async rootCommit() {
201
+ try {
202
+ const { stdout } = await execFileAsync('git', ['rev-list', '--max-parents=0', 'HEAD'], { cwd: this.cwd });
203
+ const shas = stdout.trim().split(/\r?\n/).filter(Boolean);
204
+ return shas[shas.length - 1] || undefined;
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ }
197
210
  /** Stages and commits exactly `files` (relative to `cwd`, or absolute) with `message` - never a
198
211
  * blanket `git add -A`, so the commit only ever contains what the caller explicitly asked for. */
199
212
  async commit(files, message) {
@@ -229,6 +242,17 @@ export class GitHelper {
229
242
  throw new Error(`Unable to push to "${remote}": ${e.message}`, { cause: e });
230
243
  }
231
244
  }
245
+ /** `git remote get-url <remote>` (default `"origin"`) - `undefined` when that remote isn't
246
+ * configured at all, which is a perfectly normal state for a local-only repository. */
247
+ async remoteUrl(remote = 'origin') {
248
+ try {
249
+ const { stdout } = await execFileAsync('git', ['remote', 'get-url', remote], { cwd: this.cwd });
250
+ return stdout.trim() || undefined;
251
+ }
252
+ catch {
253
+ return undefined;
254
+ }
255
+ }
232
256
  /** Raw `git diff <hash>..HEAD` text (committed changes plus any uncommitted local ones, same as
233
257
  * plain `git diff <hash>`) - unlike `listChangedSince`, the actual patch content, not just
234
258
  * which files changed. `pathspec`, if given, narrows it to just that file/directory. */
@@ -0,0 +1,50 @@
1
+ import type { Package } from '../core/package.js';
2
+ import type { GitHelper } from './git.js';
3
+ /**
4
+ * A calendar release version: `YYYY.M.D-HHmm`, every part **unpadded** (`2026.9.5-930`, not
5
+ * `2026.09.05-0930`). The padding isn't a style choice - semver forbids leading zeroes in numeric
6
+ * identifiers, so a padded month or time makes the version invalid, and a monorepo root's
7
+ * `package.json` has to hold a valid one. Unpadded still orders correctly: the date parts compare
8
+ * numerically as major/minor/patch, and the time compares numerically as a prerelease identifier
9
+ * (`930` < `1430`).
10
+ */
11
+ export declare const CALENDAR_VERSION_PATTERN: RegExp;
12
+ /** `.rmanrc version.releaseTagPattern` (root-level) - the tag naming the repository's own release,
13
+ * as opposed to the per-package/group tags `changelog.tagPattern` names. Deliberately a **separate**
14
+ * pattern with a non-`v` default: `findLatestTag` resolves a repo-wide package pattern with `git
15
+ * describe --match`, so a release tag that also matched `v*` would be picked up as some package's
16
+ * own last release - corrupting both its changelog boundary and the version its entry is headed
17
+ * with. */
18
+ export declare function releaseTagPattern(root: Package): string;
19
+ export declare function isCalendarVersion(version: string): boolean;
20
+ /** `date` as a calendar release version - see `CALENDAR_VERSION_PATTERN` for why nothing is padded.
21
+ * The time becomes a single number (`14:30` -> `1430`, `09:30` -> `930`), which is both unpadded
22
+ * by construction and ordered the way the clock is. */
23
+ export declare function formatCalendarVersion(date: Date): string;
24
+ /**
25
+ * Whether this repository's releases are identified by a calendar version rather than a shared
26
+ * semver one. Derived, never configured - a repo that picked "highest package version" would only
27
+ * be picking a bug: with two version lines the highest can stay put while a lower one releases,
28
+ * leaving the release with no identity of its own.
29
+ *
30
+ * `groupCount > 1` makes the first call: with several version lines there is no meaningful shared
31
+ * number, so any semver-looking identity would claim something untrue. The other two make it
32
+ * **sticky** - going back would *lower* the root version (`2026.9.15-1430` -> `1.4.0` compares as a
33
+ * decrease), so once a calendar release exists the repo stays on calendar even if its groups later
34
+ * collapse back to one. The last-release-tag check is the authoritative one (tags record what was
35
+ * actually released); the root's own current version covers the case where tags aren't available
36
+ * at all, e.g. a shallow clone.
37
+ */
38
+ export declare function usesCalendarVersion(options: {
39
+ groupCount: number;
40
+ rootVersion: string;
41
+ lastReleaseVersion?: string;
42
+ }): boolean;
43
+ /** The tag naming a given repository release - `releaseTagPattern` run forward, the way
44
+ * `expandTag` runs `changelog.tagPattern` forward for a package. */
45
+ export declare function expandReleaseTag(root: Package, version: string): string;
46
+ /** The version of the repository's most recent release, from its release tags (highest by version
47
+ * sort) - `undefined` for a repo that has never cut one, or whose tags aren't available (a shallow
48
+ * clone). The authoritative half of `usesCalendarVersion`'s stickiness check: tags record what was
49
+ * actually released, unlike a `package.json` that can drift. */
50
+ export declare function findLastReleaseVersion(git: GitHelper, root: Package): Promise<string | undefined>;
@@ -0,0 +1,66 @@
1
+ import { applyTagPattern, extractVersion } from './change-hash.js';
2
+ /**
3
+ * A calendar release version: `YYYY.M.D-HHmm`, every part **unpadded** (`2026.9.5-930`, not
4
+ * `2026.09.05-0930`). The padding isn't a style choice - semver forbids leading zeroes in numeric
5
+ * identifiers, so a padded month or time makes the version invalid, and a monorepo root's
6
+ * `package.json` has to hold a valid one. Unpadded still orders correctly: the date parts compare
7
+ * numerically as major/minor/patch, and the time compares numerically as a prerelease identifier
8
+ * (`930` < `1430`).
9
+ */
10
+ export const CALENDAR_VERSION_PATTERN = /^\d{4}\.\d{1,2}\.\d{1,2}-\d+$/;
11
+ /** `.rmanrc version.releaseTagPattern` (root-level) - the tag naming the repository's own release,
12
+ * as opposed to the per-package/group tags `changelog.tagPattern` names. Deliberately a **separate**
13
+ * pattern with a non-`v` default: `findLatestTag` resolves a repo-wide package pattern with `git
14
+ * describe --match`, so a release tag that also matched `v*` would be picked up as some package's
15
+ * own last release - corrupting both its changelog boundary and the version its entry is headed
16
+ * with. */
17
+ export function releaseTagPattern(root) {
18
+ const cfg = root.config?.version?.releaseTagPattern;
19
+ return typeof cfg === 'string' && cfg ? cfg : DEFAULT_RELEASE_TAG_PATTERN;
20
+ }
21
+ export function isCalendarVersion(version) {
22
+ return CALENDAR_VERSION_PATTERN.test(version);
23
+ }
24
+ /** `date` as a calendar release version - see `CALENDAR_VERSION_PATTERN` for why nothing is padded.
25
+ * The time becomes a single number (`14:30` -> `1430`, `09:30` -> `930`), which is both unpadded
26
+ * by construction and ordered the way the clock is. */
27
+ export function formatCalendarVersion(date) {
28
+ const time = date.getHours() * 100 + date.getMinutes();
29
+ return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}-${time}`;
30
+ }
31
+ /**
32
+ * Whether this repository's releases are identified by a calendar version rather than a shared
33
+ * semver one. Derived, never configured - a repo that picked "highest package version" would only
34
+ * be picking a bug: with two version lines the highest can stay put while a lower one releases,
35
+ * leaving the release with no identity of its own.
36
+ *
37
+ * `groupCount > 1` makes the first call: with several version lines there is no meaningful shared
38
+ * number, so any semver-looking identity would claim something untrue. The other two make it
39
+ * **sticky** - going back would *lower* the root version (`2026.9.15-1430` -> `1.4.0` compares as a
40
+ * decrease), so once a calendar release exists the repo stays on calendar even if its groups later
41
+ * collapse back to one. The last-release-tag check is the authoritative one (tags record what was
42
+ * actually released); the root's own current version covers the case where tags aren't available
43
+ * at all, e.g. a shallow clone.
44
+ */
45
+ export function usesCalendarVersion(options) {
46
+ if (options.lastReleaseVersion && isCalendarVersion(options.lastReleaseVersion))
47
+ return true;
48
+ if (isCalendarVersion(options.rootVersion))
49
+ return true;
50
+ return options.groupCount > 1;
51
+ }
52
+ /** The tag naming a given repository release - `releaseTagPattern` run forward, the way
53
+ * `expandTag` runs `changelog.tagPattern` forward for a package. */
54
+ export function expandReleaseTag(root, version) {
55
+ return applyTagPattern(releaseTagPattern(root), root.name, version);
56
+ }
57
+ /** The version of the repository's most recent release, from its release tags (highest by version
58
+ * sort) - `undefined` for a repo that has never cut one, or whose tags aren't available (a shallow
59
+ * clone). The authoritative half of `usesCalendarVersion`'s stickiness check: tags record what was
60
+ * actually released, unlike a `package.json` that can drift. */
61
+ export async function findLastReleaseVersion(git, root) {
62
+ const glob = releaseTagPattern(root).replace('{name}', root.name);
63
+ const tag = (await git.listTags(glob))[0];
64
+ return tag ? extractVersion(tag, glob) : undefined;
65
+ }
66
+ const DEFAULT_RELEASE_TAG_PATTERN = 'release-*';