@reelout/core 0.1.1 → 0.2.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/dist/analyzer.js +38 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/builder.js +52 -0
- package/dist/builder.js.map +1 -0
- package/dist/changelog.js +58 -0
- package/dist/changelog.js.map +1 -0
- package/dist/formatError-BXNdyRfu.js +23 -0
- package/dist/formatError-BXNdyRfu.js.map +1 -0
- package/dist/index.js +175 -1
- package/dist/index.js.map +1 -0
- package/dist/types-la_KkjCS.js +1 -0
- package/dist/types.js +2 -0
- package/dist/workflow-BoVmbvH6.js +2342 -0
- package/dist/workflow-BoVmbvH6.js.map +1 -0
- package/package.json +27 -3
- package/src/analyzer.ts +63 -0
- package/src/builder.ts +104 -0
- package/src/cache/TTLCache.ts +100 -0
- package/src/cache/index.ts +4 -0
- package/src/changelog.ts +97 -0
- package/src/config/const.ts +97 -0
- package/src/config/index.ts +11 -0
- package/src/config/isSemVerGitTag.ts +25 -0
- package/src/config/normalizeCommitFilter.ts +37 -0
- package/src/config/normalizeReleaseRule.ts +42 -0
- package/src/config/normalizeWorkspace.ts +34 -0
- package/src/config/resolvePlugin.ts +56 -0
- package/src/config/resolveReleaseTarget.ts +71 -0
- package/src/config/sanitizePrereleaseLabel.ts +18 -0
- package/src/git/branch/getCurrentGitBranch.ts +18 -0
- package/src/git/branch/index.ts +4 -0
- package/src/git/commit/createGitCommit.ts +29 -0
- package/src/git/commit/extractMentions.ts +34 -0
- package/src/git/commit/getCurrentGitCommit.ts +14 -0
- package/src/git/commit/getParsedCommit.ts +53 -0
- package/src/git/commit/index.ts +9 -0
- package/src/git/commit/parseCommit.ts +99 -0
- package/src/git/commit/parseGitPrettyFormat.ts +76 -0
- package/src/git/history/countGitHistory.ts +28 -0
- package/src/git/history/expandGitHistory.ts +108 -0
- package/src/git/history/index.ts +9 -0
- package/src/git/history/mergeGitHistory.ts +76 -0
- package/src/git/history/restoreFullGitHistory.ts +19 -0
- package/src/git/history/showGitHistory.ts +80 -0
- package/src/git/history/showParsedGitHistory.ts +30 -0
- package/src/git/index.ts +9 -0
- package/src/git/repository/ensureGitUserConfig.ts +31 -0
- package/src/git/repository/getGitConfigValue.ts +27 -0
- package/src/git/repository/getGitRemote.ts +20 -0
- package/src/git/repository/index.ts +10 -0
- package/src/git/repository/isGitShallowRepository.ts +21 -0
- package/src/git/repository/pushGitChanges.ts +25 -0
- package/src/git/repository/resolveGitRepository.ts +40 -0
- package/src/git/repository/withShallowLock.ts +62 -0
- package/src/git/revision/detectGitRelationship.ts +43 -0
- package/src/git/revision/index.ts +7 -0
- package/src/git/revision/isGitAncestor.ts +33 -0
- package/src/git/revision/readGitFile.ts +29 -0
- package/src/git/revision/resolveGitRevision.ts +23 -0
- package/src/git/tag/createGitTag.ts +25 -0
- package/src/git/tag/deleteGitTag.ts +17 -0
- package/src/git/tag/ensureGitTag.ts +63 -0
- package/src/git/tag/generateGitTagName.ts +43 -0
- package/src/git/tag/getCurrentGitTag.ts +23 -0
- package/src/git/tag/getGitTagMessage.ts +35 -0
- package/src/git/tag/getLatestGitTag.ts +22 -0
- package/src/git/tag/index.ts +13 -0
- package/src/git/tag/isGitTagConnected.ts +33 -0
- package/src/git/tag/isGitTagPointed.ts +38 -0
- package/src/git/tag/isGitTagPresent.ts +17 -0
- package/src/index.ts +8 -2
- package/src/types.ts +217 -0
- package/src/util/buildTrailerPattern.ts +20 -0
- package/src/util/compact.ts +14 -0
- package/src/util/execCommand.ts +130 -0
- package/src/util/execGit.ts +18 -0
- package/src/util/formatError.ts +21 -0
- package/src/util/formatFile.ts +41 -0
- package/src/util/index.ts +16 -0
- package/src/util/interpolate.ts +141 -0
- package/src/util/isCI.ts +23 -0
- package/src/util/isDefined.ts +12 -0
- package/src/util/isMatch.ts +43 -0
- package/src/util/readJson.ts +47 -0
- package/src/util/unique.ts +26 -0
- package/src/util/writeJson.ts +28 -0
- package/src/workflow/analysis/analyzePackageChanges.ts +54 -0
- package/src/workflow/analysis/index.ts +9 -0
- package/src/workflow/analysis/isCommitForPackage.ts +23 -0
- package/src/workflow/analysis/promotePrereleaseVersions.ts +50 -0
- package/src/workflow/analysis/propagateDependencyUpdates.ts +71 -0
- package/src/workflow/analysis/sliceUnreleased.ts +36 -0
- package/src/workflow/analysis/streamPackageCommits.ts +34 -0
- package/src/workflow/changelog/buildChangelog.ts +79 -0
- package/src/workflow/changelog/buildReleaseNotes.ts +50 -0
- package/src/workflow/changelog/extractLatestVersion.ts +22 -0
- package/src/workflow/changelog/extractPlainText.ts +38 -0
- package/src/workflow/changelog/extractPreviousVersion.ts +77 -0
- package/src/workflow/changelog/extractSemVer.ts +33 -0
- package/src/workflow/changelog/index.ts +12 -0
- package/src/workflow/changelog/iterateChangelogVersions.ts +70 -0
- package/src/workflow/changelog/prependChangelog.ts +59 -0
- package/src/workflow/changelog/writeChangelog.ts +27 -0
- package/src/workflow/committing/generateReleaseCommitMessage.ts +96 -0
- package/src/workflow/committing/index.ts +5 -0
- package/src/workflow/committing/isReleaseCommit.ts +52 -0
- package/src/workflow/discovery/buildDependencyGraph.ts +43 -0
- package/src/workflow/discovery/discoverPackageTag.ts +98 -0
- package/src/workflow/discovery/index.ts +9 -0
- package/src/workflow/discovery/resolvePackageStableTag.ts +88 -0
- package/src/workflow/discovery/resolvePackageTag.ts +45 -0
- package/src/workflow/discovery/sortPackages.ts +73 -0
- package/src/workflow/discovery/verifyPackageTag.ts +43 -0
- package/src/workflow/index.ts +10 -0
- package/src/workflow/report/generateReleaseReport.ts +76 -0
- package/src/workflow/report/index.ts +5 -0
- package/src/workflow/report/printReleaseReport.ts +97 -0
- package/src/workflow/runner/index.ts +4 -0
- package/src/workflow/runner/runWorkflow.ts +120 -0
- package/src/workflow/versioning/bumpVersion.ts +67 -0
- package/src/workflow/versioning/deriveReleaseType.ts +33 -0
- package/src/workflow/versioning/determineReleaseType.ts +35 -0
- package/src/workflow/versioning/index.ts +8 -0
- package/src/workflow/versioning/isStableVersion.ts +31 -0
- package/src/workflow/versioning/pickPriorityReleaseType.ts +27 -0
|
@@ -0,0 +1,2342 @@
|
|
|
1
|
+
import { colors } from "consola/utils";
|
|
2
|
+
import semver from "semver";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import prettier from "prettier";
|
|
6
|
+
import { minimatch } from "minimatch";
|
|
7
|
+
import fs, { readFile } from "node:fs/promises";
|
|
8
|
+
import { CommitParser } from "conventional-commits-parser";
|
|
9
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
|
+
import GitHost from "hosted-git-info";
|
|
11
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
12
|
+
import assert from "node:assert";
|
|
13
|
+
import createPreset from "conventional-changelog-conventionalcommits";
|
|
14
|
+
import { writeChangelogString } from "conventional-changelog-writer";
|
|
15
|
+
import { toMarkdown } from "mdast-util-to-markdown";
|
|
16
|
+
//#region src/workflow/versioning/bumpVersion.ts
|
|
17
|
+
/**
|
|
18
|
+
* Bump a semantic version according to release type and prerelease settings.
|
|
19
|
+
*
|
|
20
|
+
* Increments the version using the provided release type. When a prerelease ID
|
|
21
|
+
* is provided, generates a prerelease version with that identifier. Throws an
|
|
22
|
+
* error when the version is invalid or the initial version is missing.
|
|
23
|
+
*
|
|
24
|
+
* @param pkg Package object containing current version
|
|
25
|
+
* @param releaseType Semantic version component to increment (`'major'`, `'minor'`, `'patch'`)
|
|
26
|
+
* @param prereleaseId Optional prerelease identifier (e.g., `'alpha'`, `'beta'`)
|
|
27
|
+
* @returns Next semantic version string, or `null` when `releaseType` is falsy
|
|
28
|
+
* @throws If the version is invalid or initial version is missing
|
|
29
|
+
*/
|
|
30
|
+
function bumpVersion(pkg, releaseType, prereleaseId) {
|
|
31
|
+
if (!releaseType) return null;
|
|
32
|
+
if (!pkg.version) {
|
|
33
|
+
if (pkg.private) return null;
|
|
34
|
+
throw new Error("An initial version is required to compute the next version.");
|
|
35
|
+
}
|
|
36
|
+
const parsed = semver.parse(pkg.version);
|
|
37
|
+
if (!parsed) throw new Error(`The version "${colors.bold(pkg.version)}" is not a valid SemVer.`);
|
|
38
|
+
prereleaseId ||= void 0;
|
|
39
|
+
let type = releaseType;
|
|
40
|
+
if (prereleaseId) {
|
|
41
|
+
type = `pre${releaseType}`;
|
|
42
|
+
if (parsed.prerelease.length > 0) {
|
|
43
|
+
if (releaseType === "major" && parsed.minor === 0 && parsed.patch === 0 || releaseType === "minor" && parsed.patch === 0 || releaseType === "patch") type = "prerelease";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return semver.inc(parsed, type, { loose: void 0 }, prereleaseId, "0");
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/workflow/versioning/determineReleaseType.ts
|
|
50
|
+
/**
|
|
51
|
+
* Determine the release type for a single commit.
|
|
52
|
+
*
|
|
53
|
+
* Automatically returns `'major'` if the commit is marked as a breaking change.
|
|
54
|
+
* Otherwise, routes the commit through the custom `classifier` and ensures the
|
|
55
|
+
* resolved value is a valid semantic versioning release type.
|
|
56
|
+
*
|
|
57
|
+
* @param classifier Function mapping commits to release types
|
|
58
|
+
* @param commit Parsed commit to evaluate
|
|
59
|
+
* @returns `ReleaseType` string, or `null` when no matching rule applies
|
|
60
|
+
*/
|
|
61
|
+
function determineReleaseType(classifier, commit) {
|
|
62
|
+
if (commit.notes.length > 0) return "major";
|
|
63
|
+
const type = classifier(commit);
|
|
64
|
+
switch (type) {
|
|
65
|
+
case "major":
|
|
66
|
+
case "minor":
|
|
67
|
+
case "patch": return type;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/workflow/versioning/pickPriorityReleaseType.ts
|
|
73
|
+
/**
|
|
74
|
+
* Determine the highest-priority release type between two candidates.
|
|
75
|
+
*
|
|
76
|
+
* Compares release types by precedence order: `major` > `minor` > `patch`.
|
|
77
|
+
* Returns the higher-precedence type, or `null` if neither applies.
|
|
78
|
+
*
|
|
79
|
+
* @param curr Current accumulated release type
|
|
80
|
+
* @param next Candidate release type to consider
|
|
81
|
+
* @returns Highest-precedence `ReleaseType`, or `null` if neither applies
|
|
82
|
+
*/
|
|
83
|
+
function pickPriorityReleaseType(curr, next) {
|
|
84
|
+
for (const type of [
|
|
85
|
+
"major",
|
|
86
|
+
"minor",
|
|
87
|
+
"patch"
|
|
88
|
+
]) if (curr === type || next === type) return type;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/workflow/versioning/deriveReleaseType.ts
|
|
93
|
+
/**
|
|
94
|
+
* Derive the combined release type from a commit history collection.
|
|
95
|
+
*
|
|
96
|
+
* Processes commits sequentially, accumulating the highest-precedence release
|
|
97
|
+
* type found by evaluating each individual commit and comparing it against the
|
|
98
|
+
* current maximum via `pickPriorityReleaseType`.
|
|
99
|
+
*
|
|
100
|
+
* @param classifier Function mapping commits to release types
|
|
101
|
+
* @param history Array of parsed commits to evaluate
|
|
102
|
+
* @returns `ReleaseType` string, or `null` when no matching rule applies
|
|
103
|
+
*/
|
|
104
|
+
function deriveReleaseType(classifier, history) {
|
|
105
|
+
let releaseType = null;
|
|
106
|
+
for (const commit of history) releaseType = pickPriorityReleaseType(releaseType, determineReleaseType(classifier, commit));
|
|
107
|
+
return releaseType;
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/workflow/versioning/isStableVersion.ts
|
|
111
|
+
/**
|
|
112
|
+
* Determine whether a version string represents a stable SemVer release.
|
|
113
|
+
*
|
|
114
|
+
* A version is considered stable when:
|
|
115
|
+
* - It is a valid SemVer string.
|
|
116
|
+
* - Its major version is greater than 0 (not 0.x.x).
|
|
117
|
+
* - It contains no prerelease identifiers (no `-alpha`, `-beta`, `-rc`, etc.).
|
|
118
|
+
*
|
|
119
|
+
* Build metadata (the `+` suffix) does not affect stability.
|
|
120
|
+
*
|
|
121
|
+
* @param version Semantic version string to check
|
|
122
|
+
* @returns `true` if `version` is a stable SemVer; otherwise `false`
|
|
123
|
+
* @throws If `version` is not a valid SemVer string
|
|
124
|
+
*/
|
|
125
|
+
function isStableVersion(version) {
|
|
126
|
+
const parsed = semver.parse(version);
|
|
127
|
+
if (!parsed) throw new Error(`The version "${colors.bold(version)}" is not a valid SemVer.`);
|
|
128
|
+
return parsed.major > 0 && parsed.prerelease.length === 0;
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/config/const.ts
|
|
132
|
+
var ALL_FILES = "**/*";
|
|
133
|
+
var DEFAULT_CHANGELOG_FILE = "CHANGELOG.md";
|
|
134
|
+
var GIT_TRAILER_RELEASE_AGENT = "Release-Agent";
|
|
135
|
+
/**
|
|
136
|
+
* 1. Primitive Building Blocks
|
|
137
|
+
*
|
|
138
|
+
* Base regular expression patterns for wildcards, alphanumeric strings with hyphens,
|
|
139
|
+
* and standard non-negative integers.
|
|
140
|
+
*/
|
|
141
|
+
var WILDCARD = "\\*";
|
|
142
|
+
var ALPHANUM_DASH = "[0-9a-zA-Z-]+";
|
|
143
|
+
var NUMERIC_IDENTIFIER = "0|[1-9]\\d*";
|
|
144
|
+
/**
|
|
145
|
+
* 2. Package Name & Prefix Constructs
|
|
146
|
+
*
|
|
147
|
+
* Matches package name prefixes (unscoped or scoped) or a simple `v` prefix.
|
|
148
|
+
*
|
|
149
|
+
* Capture Groups:
|
|
150
|
+
* - Group 1: Package name (e.g., `my-package` or `@scope/my-package`)
|
|
151
|
+
*/
|
|
152
|
+
var PKG_NAME_PART = `(?:${WILDCARD}|${ALPHANUM_DASH})`;
|
|
153
|
+
var OPTIONAL_VERSION_PREFIX = `(?:v|${`(${PKG_NAME_PART}|@${PKG_NAME_PART}\\/${PKG_NAME_PART})`}@)`;
|
|
154
|
+
/**
|
|
155
|
+
* 3. Core SemVer Components
|
|
156
|
+
*
|
|
157
|
+
* Matches standard `MAJOR.MINOR.PATCH` versions or wildcard variants (e.g., `1.0.0`, `*.*.*`).
|
|
158
|
+
*
|
|
159
|
+
* Capture Groups:
|
|
160
|
+
* - Group 3: Major version
|
|
161
|
+
* - Group 4: Minor version
|
|
162
|
+
* - Group 5: Patch version
|
|
163
|
+
*/
|
|
164
|
+
var CAPTURED_VERSION_DIGIT = `(${WILDCARD}|${NUMERIC_IDENTIFIER})`;
|
|
165
|
+
var SEMVER_CORE = `${CAPTURED_VERSION_DIGIT}\\.${CAPTURED_VERSION_DIGIT}\\.${CAPTURED_VERSION_DIGIT}`;
|
|
166
|
+
var PRERELEASE_IDENTIFIER = `(?:${WILDCARD}|${NUMERIC_IDENTIFIER}|(\\d*[a-zA-Z-][0-9a-zA-Z-]*))`;
|
|
167
|
+
var FULL_SEMVER_PATTERN = `${OPTIONAL_VERSION_PREFIX}?${`(${SEMVER_CORE}${`(?:-(${PRERELEASE_IDENTIFIER}(?:\\.${PRERELEASE_IDENTIFIER})*))`}?${`(?:\\+(${ALPHANUM_DASH}(?:\\.${ALPHANUM_DASH})*))`}?)`}`;
|
|
168
|
+
/**
|
|
169
|
+
* Regular expression to match and capture Semantic Versioning (SemVer) strings,
|
|
170
|
+
* including optional package prefixes (e.g., `@scope/pkg@`) and `v` prefixes.
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```ts
|
|
174
|
+
* SEMVER_REGEX.test('1.0.0') // true
|
|
175
|
+
* SEMVER_REGEX.test('v1.2.3-beta.1+sha.1234') // true
|
|
176
|
+
* SEMVER_REGEX.test('@scope/package@1.0.0') // true
|
|
177
|
+
* ```
|
|
178
|
+
*
|
|
179
|
+
* @see {@link https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string SemVer Specification}
|
|
180
|
+
*/
|
|
181
|
+
var SEMVER_REGEX = new RegExp(FULL_SEMVER_PATTERN);
|
|
182
|
+
var RELEASE_LIFECYCLE_HOOK_LIST = [
|
|
183
|
+
"configure",
|
|
184
|
+
"discover",
|
|
185
|
+
"analyze",
|
|
186
|
+
"build",
|
|
187
|
+
"write",
|
|
188
|
+
"commit",
|
|
189
|
+
"publish",
|
|
190
|
+
"finalize"
|
|
191
|
+
];
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/config/isSemVerGitTag.ts
|
|
194
|
+
/**
|
|
195
|
+
* Determine whether a tag string is a valid SemVer-style Git tag.
|
|
196
|
+
*
|
|
197
|
+
* Anchors `SEMVER_REGEX.source` for full-string matching and applies the
|
|
198
|
+
* repository's Git-tag acceptance rules.
|
|
199
|
+
*
|
|
200
|
+
* @param tag Tag string to validate
|
|
201
|
+
* @returns `true` if `tag` is accepted as a SemVer-style Git tag; otherwise `false`
|
|
202
|
+
*/
|
|
203
|
+
function isSemVerGitTag(tag) {
|
|
204
|
+
const match = new RegExp(`^${SEMVER_REGEX.source}$`).exec(tag);
|
|
205
|
+
if (!match) return false;
|
|
206
|
+
const isScopedPackage = Boolean(match[1]);
|
|
207
|
+
const hasVersionPrefix = tag.startsWith("v");
|
|
208
|
+
return isScopedPackage || hasVersionPrefix;
|
|
209
|
+
}
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/util/buildTrailerPattern.ts
|
|
212
|
+
/**
|
|
213
|
+
* Build a regular expression to match trailer lines using custom markers.
|
|
214
|
+
*
|
|
215
|
+
* Combines the provided marker strings with a standard alphanumeric pattern
|
|
216
|
+
* to construct a regular expression that matches trailer definitions.
|
|
217
|
+
*
|
|
218
|
+
* @param markers Optional array of custom trailer marker strings
|
|
219
|
+
* @returns A regular expression matching trailer lines
|
|
220
|
+
*/
|
|
221
|
+
function buildTrailerPattern(markers = []) {
|
|
222
|
+
return new RegExp(`^(${markers.map(RegExp.escape).concat("[A-Za-z0-9_-]+").join("|")})[ \\t]*:[ \\t]*(.*)$`);
|
|
223
|
+
}
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/util/isDefined.ts
|
|
226
|
+
/**
|
|
227
|
+
* Type guard that asserts a value is neither `null` nor `undefined`.
|
|
228
|
+
*
|
|
229
|
+
* @param value Value to test
|
|
230
|
+
* @returns `true` when `value` is not `null` or `undefined`
|
|
231
|
+
*/
|
|
232
|
+
function isDefined(value) {
|
|
233
|
+
return value != null;
|
|
234
|
+
}
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/util/compact.ts
|
|
237
|
+
/**
|
|
238
|
+
* Remove `null` and `undefined` values from an array.
|
|
239
|
+
*
|
|
240
|
+
* @param array The array to compact
|
|
241
|
+
* @returns A new array that does not contain `null` or `undefined` values
|
|
242
|
+
*/
|
|
243
|
+
function compact(array) {
|
|
244
|
+
return array.filter(isDefined);
|
|
245
|
+
}
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/util/execCommand.ts
|
|
248
|
+
(/* @__PURE__ */ new Date("2026-01-01T00:00:00Z")).valueOf();
|
|
249
|
+
/**
|
|
250
|
+
* Execute a command asynchronously and return its standard output.
|
|
251
|
+
*
|
|
252
|
+
* @param cwd Working directory for the command
|
|
253
|
+
* @param command Binary to execute
|
|
254
|
+
* @param args Command-line arguments
|
|
255
|
+
* @returns Promise resolving to the stdout string
|
|
256
|
+
*/
|
|
257
|
+
function execCommand(cwd, command, args) {
|
|
258
|
+
const env = { ...process.env };
|
|
259
|
+
let childProcess;
|
|
260
|
+
const promise = new Promise((resolve, reject) => {
|
|
261
|
+
childProcess = spawn(command, args, {
|
|
262
|
+
cwd,
|
|
263
|
+
env
|
|
264
|
+
});
|
|
265
|
+
childProcess.stdout.setEncoding("utf-8");
|
|
266
|
+
childProcess.stderr.setEncoding("utf-8");
|
|
267
|
+
let stdout = "";
|
|
268
|
+
let stderr = "";
|
|
269
|
+
childProcess.stdout.on("data", (data) => {
|
|
270
|
+
stdout += data;
|
|
271
|
+
});
|
|
272
|
+
childProcess.stderr.on("data", (data) => {
|
|
273
|
+
stderr += data;
|
|
274
|
+
});
|
|
275
|
+
childProcess.on("error", (err) => {
|
|
276
|
+
reject(err);
|
|
277
|
+
});
|
|
278
|
+
childProcess.on("close", (code, signal) => {
|
|
279
|
+
if (code === 0) return resolve(stdout);
|
|
280
|
+
const errorMessage = `Command ${quoteCommandForMessage([command, ...args].join(" "))} failed (${code !== null ? `exit code ${code}` : signal ? `signal ${signal}` : "unknown status"})`;
|
|
281
|
+
const errorDiagnostics = (stderr || stdout).trim();
|
|
282
|
+
const err = new Error([errorMessage, errorDiagnostics].filter(Boolean).join(errorDiagnostics.includes("\n") ? "\n" : ": "), { cause: {
|
|
283
|
+
code,
|
|
284
|
+
signal,
|
|
285
|
+
diagnostics: errorDiagnostics
|
|
286
|
+
} });
|
|
287
|
+
Object.defineProperty(err, "code", { value: code });
|
|
288
|
+
reject(err);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
promise.child = childProcess;
|
|
292
|
+
return promise;
|
|
293
|
+
}
|
|
294
|
+
function quoteCommandForMessage(command) {
|
|
295
|
+
if (!command.includes("'")) return `'${command}'`;
|
|
296
|
+
if (!command.includes("\"")) return `"${command}"`;
|
|
297
|
+
return JSON.stringify(command);
|
|
298
|
+
}
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region src/util/execGit.ts
|
|
301
|
+
/**
|
|
302
|
+
* Run a git command in a working directory and return its standard output.
|
|
303
|
+
*
|
|
304
|
+
* @param cwd Working directory for the git command
|
|
305
|
+
* @param args Git CLI arguments
|
|
306
|
+
* @returns Promise resolving to the stdout string
|
|
307
|
+
*/
|
|
308
|
+
function execGit(cwd, args) {
|
|
309
|
+
return execCommand(cwd, "git", args);
|
|
310
|
+
}
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/util/formatFile.ts
|
|
313
|
+
/**
|
|
314
|
+
* Format `content` for `filePath` using Prettier when possible.
|
|
315
|
+
*
|
|
316
|
+
* Resolves project/editorconfig settings and formats the provided content.
|
|
317
|
+
* Returns the original content on error or when no parser is inferred.
|
|
318
|
+
*
|
|
319
|
+
* @param filePath Path used to infer parser and config
|
|
320
|
+
* @param content Source content to format
|
|
321
|
+
* @returns Formatted content or original content on failure
|
|
322
|
+
*/
|
|
323
|
+
async function formatFile(filePath, content) {
|
|
324
|
+
try {
|
|
325
|
+
const info = await prettier.getFileInfo(filePath, { resolveConfig: true });
|
|
326
|
+
const config = await prettier.resolveConfig(filePath, { editorconfig: true });
|
|
327
|
+
if (info.inferredParser == null) return content;
|
|
328
|
+
return await prettier.format(content, {
|
|
329
|
+
...config,
|
|
330
|
+
parser: info.inferredParser,
|
|
331
|
+
filepath: filePath
|
|
332
|
+
});
|
|
333
|
+
} catch {
|
|
334
|
+
return content;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/util/interpolate.ts
|
|
339
|
+
var BACKSLASH = "\\";
|
|
340
|
+
var LEFT_BRACE = "{";
|
|
341
|
+
var RIGHT_BRACE = "}";
|
|
342
|
+
var DOT = ".";
|
|
343
|
+
var ESCAPE_CHARS = [
|
|
344
|
+
BACKSLASH,
|
|
345
|
+
LEFT_BRACE,
|
|
346
|
+
RIGHT_BRACE
|
|
347
|
+
];
|
|
348
|
+
var BANNED_KEYS = [
|
|
349
|
+
"__proto__",
|
|
350
|
+
"constructor",
|
|
351
|
+
"prototype"
|
|
352
|
+
];
|
|
353
|
+
/**
|
|
354
|
+
* Interpolate placeholders in `template` using values from `data`.
|
|
355
|
+
*
|
|
356
|
+
* - Placeholders use `{path.to.value}` (dot notation).
|
|
357
|
+
* - Backslash escapes are supported for `{`, `}`, `\`, and `.` inside tokens.
|
|
358
|
+
* - Tokens that reference banned keys (`__proto__`, `constructor`, `prototype`)
|
|
359
|
+
* are left unexpanded to avoid prototype pollution.
|
|
360
|
+
* - Only own properties are followed when resolving paths; if any step is
|
|
361
|
+
* missing, the original token text is preserved.
|
|
362
|
+
* - Resolved non-object, non-null values are stringified and inserted.
|
|
363
|
+
* - Unclosed tokens and a trailing backslash are preserved verbatim.
|
|
364
|
+
*
|
|
365
|
+
* @param template Template string containing placeholders like `{user.name}`
|
|
366
|
+
* @param data Object used to resolve placeholder paths
|
|
367
|
+
* @returns The template with placeholders replaced by corresponding values
|
|
368
|
+
*/
|
|
369
|
+
function interpolate(template, data) {
|
|
370
|
+
let result = "";
|
|
371
|
+
let i = 0;
|
|
372
|
+
let char;
|
|
373
|
+
let nextChar;
|
|
374
|
+
while (char = template[i]) if (char === BACKSLASH) {
|
|
375
|
+
nextChar = template[i + 1];
|
|
376
|
+
if (nextChar !== void 0 && ESCAPE_CHARS.includes(nextChar)) {
|
|
377
|
+
result += nextChar;
|
|
378
|
+
i += 2;
|
|
379
|
+
} else {
|
|
380
|
+
result += char;
|
|
381
|
+
i += 1;
|
|
382
|
+
}
|
|
383
|
+
} else if (char === LEFT_BRACE) {
|
|
384
|
+
const keys = [];
|
|
385
|
+
let key = "";
|
|
386
|
+
let token = "";
|
|
387
|
+
let closed = false;
|
|
388
|
+
i += 1;
|
|
389
|
+
while (char = template[i]) {
|
|
390
|
+
if (char === RIGHT_BRACE) {
|
|
391
|
+
closed = true;
|
|
392
|
+
keys.push(key);
|
|
393
|
+
i += 1;
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
if (char === BACKSLASH) {
|
|
397
|
+
nextChar = template[i + 1];
|
|
398
|
+
if (nextChar !== void 0 && [...ESCAPE_CHARS, DOT].includes(nextChar)) {
|
|
399
|
+
key += nextChar;
|
|
400
|
+
token += nextChar;
|
|
401
|
+
i += 2;
|
|
402
|
+
} else {
|
|
403
|
+
key += char;
|
|
404
|
+
token += char;
|
|
405
|
+
i += 1;
|
|
406
|
+
}
|
|
407
|
+
} else if (char === DOT) {
|
|
408
|
+
keys.push(key);
|
|
409
|
+
key = "";
|
|
410
|
+
token += char;
|
|
411
|
+
i += 1;
|
|
412
|
+
} else {
|
|
413
|
+
key += char;
|
|
414
|
+
token += char;
|
|
415
|
+
i += 1;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (closed) {
|
|
419
|
+
let value = data;
|
|
420
|
+
for (const key of keys) {
|
|
421
|
+
const isSafeKey = !BANNED_KEYS.includes(key);
|
|
422
|
+
if (value !== null && typeof value === "object" && Object.hasOwn(value, key) && isSafeKey) value = value[key];
|
|
423
|
+
else {
|
|
424
|
+
value = void 0;
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (value == null || typeof value === "object") result += LEFT_BRACE + token + RIGHT_BRACE;
|
|
429
|
+
else result += String(value);
|
|
430
|
+
} else result += LEFT_BRACE + token;
|
|
431
|
+
} else {
|
|
432
|
+
result += char;
|
|
433
|
+
i += 1;
|
|
434
|
+
}
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/util/isMatch.ts
|
|
439
|
+
/**
|
|
440
|
+
* Determine whether a target string matches glob patterns.
|
|
441
|
+
*
|
|
442
|
+
* Matches positive and negative glob patterns using dotfile support. Automatically
|
|
443
|
+
* expands a single negative pattern by prepending a global wildcard match.
|
|
444
|
+
*
|
|
445
|
+
* @param target Target string to evaluate against glob patterns
|
|
446
|
+
* @param patterns Single glob pattern or list of patterns to evaluate
|
|
447
|
+
* @returns True if target matches positive patterns and satisfies negative patterns
|
|
448
|
+
*/
|
|
449
|
+
function isMatch(target, patterns) {
|
|
450
|
+
if (typeof patterns === "string") if (patterns.startsWith("!")) patterns = ["**/*", patterns];
|
|
451
|
+
else patterns = [patterns];
|
|
452
|
+
const positives = [];
|
|
453
|
+
const negatives = [];
|
|
454
|
+
for (const pattern of patterns) if (pattern.startsWith("!")) negatives.push(pattern);
|
|
455
|
+
else positives.push(pattern);
|
|
456
|
+
return positives.some((pattern) => minimatch(target, pattern, { dot: true })) && negatives.every((pattern) => minimatch(target, pattern, { dot: true }));
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/util/readJson.ts
|
|
460
|
+
/**
|
|
461
|
+
* Read and parse a JSON file.
|
|
462
|
+
*
|
|
463
|
+
* Throws a descriptive error when the file cannot be read or parsed, or when
|
|
464
|
+
* the parsed value is not an object.
|
|
465
|
+
*
|
|
466
|
+
* @param filePath Path to the JSON file
|
|
467
|
+
* @returns Parsed JSON object
|
|
468
|
+
* @throws If the file cannot be read, parsed, or is not an object
|
|
469
|
+
*/
|
|
470
|
+
async function readJson(filePath) {
|
|
471
|
+
let content;
|
|
472
|
+
try {
|
|
473
|
+
content = await fs.readFile(filePath, "utf-8");
|
|
474
|
+
} catch (err) {
|
|
475
|
+
throw new Error(`JSON file at "${colors.bold(filePath)}" could not be read.`, { cause: err });
|
|
476
|
+
}
|
|
477
|
+
let json;
|
|
478
|
+
try {
|
|
479
|
+
json = JSON.parse(content);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
throw new Error(`JSON file at "${colors.bold(filePath)}" could not be parsed.`, { cause: err });
|
|
482
|
+
}
|
|
483
|
+
if (typeof json !== "object" || json === null) throw new Error(`JSON file at "${colors.bold(filePath)}" does not contain a valid object.`);
|
|
484
|
+
return json;
|
|
485
|
+
}
|
|
486
|
+
//#endregion
|
|
487
|
+
//#region src/util/writeJson.ts
|
|
488
|
+
/**
|
|
489
|
+
* Write a JSON `payload` to `filePath`, formatting the content first.
|
|
490
|
+
*
|
|
491
|
+
* Ensures the directory exists and writes the formatted JSON.
|
|
492
|
+
*
|
|
493
|
+
* @param filePath Path to write the JSON file
|
|
494
|
+
* @param payload JSON-serializable object to write
|
|
495
|
+
* @returns Promise that resolves when the file is written
|
|
496
|
+
*/
|
|
497
|
+
async function writeJson(filePath, payload) {
|
|
498
|
+
let content = JSON.stringify(payload, null, 2);
|
|
499
|
+
content = await formatFile(filePath, content);
|
|
500
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
501
|
+
await fs.writeFile(filePath, content);
|
|
502
|
+
}
|
|
503
|
+
//#endregion
|
|
504
|
+
//#region src/config/sanitizePrereleaseLabel.ts
|
|
505
|
+
/**
|
|
506
|
+
* Sanitize a string into a safe, semver-compliant prerelease label.
|
|
507
|
+
*
|
|
508
|
+
* Replaces sequential non-alphanumeric characters or consecutive hyphens
|
|
509
|
+
* with a single hyphen, and strips leading or trailing hyphens.
|
|
510
|
+
*
|
|
511
|
+
* @param label Raw string to sanitize
|
|
512
|
+
* @returns Safe string for a prerelease label
|
|
513
|
+
*/
|
|
514
|
+
function sanitizePrereleaseLabel(label) {
|
|
515
|
+
return label.replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
516
|
+
}
|
|
517
|
+
//#endregion
|
|
518
|
+
//#region src/config/resolveReleaseTarget.ts
|
|
519
|
+
/**
|
|
520
|
+
* Resolve prerelease label and release channel for a ref.
|
|
521
|
+
*
|
|
522
|
+
* Resolution priority for each key:
|
|
523
|
+
* 1. Explicit `options` (string values are used verbatim; `false` disables the key).
|
|
524
|
+
* 2. First matching `rule` (rules are checked in order).
|
|
525
|
+
* 3. Fallback: derive and sanitize a label from `ref.name`.
|
|
526
|
+
*
|
|
527
|
+
* Resolved string values are interpolated with `data` before being returned.
|
|
528
|
+
*
|
|
529
|
+
* @param ref Reference context containing `type` and `name`
|
|
530
|
+
* @param rules Ordered list of release rules to match against
|
|
531
|
+
* @param options User overrides for `prereleaseLabel` and `releaseChannel`
|
|
532
|
+
* @param data Values used to interpolate resolved template strings
|
|
533
|
+
* @returns Partial release rule with resolved `prereleaseLabel` and/or `releaseChannel`
|
|
534
|
+
*/
|
|
535
|
+
function resolveReleaseTarget(ref, rules, options, data) {
|
|
536
|
+
const matchedRule = rules.find((rule) => rule.type === ref.type && isMatch(ref.name, rule.pattern));
|
|
537
|
+
const resolve = (key) => {
|
|
538
|
+
const value1 = options[key];
|
|
539
|
+
if (value1 === false) return;
|
|
540
|
+
if (value1 && typeof value1 === "string") return value1;
|
|
541
|
+
const value2 = matchedRule?.[key] || value1;
|
|
542
|
+
if (!value2) return;
|
|
543
|
+
if (typeof value2 === "string") return value2;
|
|
544
|
+
return sanitizePrereleaseLabel(ref.name);
|
|
545
|
+
};
|
|
546
|
+
const prereleaseLabel = resolve("prereleaseLabel");
|
|
547
|
+
const releaseChannel = resolve("releaseChannel");
|
|
548
|
+
return {
|
|
549
|
+
prereleaseLabel: prereleaseLabel && interpolate(prereleaseLabel, data),
|
|
550
|
+
releaseChannel: releaseChannel && interpolate(releaseChannel, data)
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
//#endregion
|
|
554
|
+
//#region src/workflow/analysis/isCommitForPackage.ts
|
|
555
|
+
/**
|
|
556
|
+
* Test whether a commit modifies files in a package.
|
|
557
|
+
*
|
|
558
|
+
* @param pkg Package with an explicit file pattern or base directory
|
|
559
|
+
* @param commit Parsed commit containing changed file paths
|
|
560
|
+
* @returns `true` if any changed file matches the package pattern
|
|
561
|
+
*/
|
|
562
|
+
function isCommitForPackage(pkg, commit) {
|
|
563
|
+
return commit.files.some((file) => {
|
|
564
|
+
const { dir } = pkg;
|
|
565
|
+
return isMatch(file, pkg.files || path.join(dir, "**/*"));
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
//#endregion
|
|
569
|
+
//#region src/workflow/analysis/analyzePackageChanges.ts
|
|
570
|
+
/**
|
|
571
|
+
* Analyze commit history and compute release types for each package plan.
|
|
572
|
+
*
|
|
573
|
+
* Iterates through commits, associates them with affected packages, and
|
|
574
|
+
* calculates each package's release type using the provided classifier.
|
|
575
|
+
*
|
|
576
|
+
* @param classifier Function mapping commits to release types
|
|
577
|
+
* @param plans Array of release plans to update
|
|
578
|
+
* @param history Commit history as iterable or async iterable
|
|
579
|
+
* @returns Updated array of plans with computed `releaseType`
|
|
580
|
+
*/
|
|
581
|
+
async function analyzePackageChanges(classifier, plans, history) {
|
|
582
|
+
for await (const commit of history) plans = plans.map((plan) => {
|
|
583
|
+
if (!isCommitForPackage(plan.package, commit)) return plan;
|
|
584
|
+
return {
|
|
585
|
+
...plan,
|
|
586
|
+
history: plan.history.concat(commit)
|
|
587
|
+
};
|
|
588
|
+
});
|
|
589
|
+
return plans.map((plan) => {
|
|
590
|
+
let { release } = plan;
|
|
591
|
+
const releaseType = pickPriorityReleaseType(release?.type, deriveReleaseType(classifier, plan.history));
|
|
592
|
+
if (releaseType) release = {
|
|
593
|
+
...release,
|
|
594
|
+
type: releaseType
|
|
595
|
+
};
|
|
596
|
+
return {
|
|
597
|
+
...plan,
|
|
598
|
+
release
|
|
599
|
+
};
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
//#endregion
|
|
603
|
+
//#region src/workflow/analysis/promotePrereleaseVersions.ts
|
|
604
|
+
/**
|
|
605
|
+
* Promote prerelease versions when they have no explicit release type.
|
|
606
|
+
*
|
|
607
|
+
* Analyzes prerelease and zero-major versions to determine an appropriate
|
|
608
|
+
* release type for graduation:
|
|
609
|
+
* - Prerelease versions are promoted based on their highest incremented component
|
|
610
|
+
* (patch, minor, or major)
|
|
611
|
+
* - Zero-major versions (0.y.z) are promoted to major
|
|
612
|
+
* - Stable versions (1.0.0+) remain unchanged
|
|
613
|
+
* - Plans with explicit releases are preserved as-is
|
|
614
|
+
*
|
|
615
|
+
* @param plans Release plans to analyze
|
|
616
|
+
* @returns Plans with inferred release types for prerelease versions
|
|
617
|
+
*/
|
|
618
|
+
async function promotePrereleaseVersions(plans) {
|
|
619
|
+
return plans.map((plan) => {
|
|
620
|
+
if (plan.release) return plan;
|
|
621
|
+
if (!plan.package.tag) return plan;
|
|
622
|
+
const parsed = semver.parse(plan.package.version);
|
|
623
|
+
if (!parsed) return plan;
|
|
624
|
+
let type;
|
|
625
|
+
if (parsed.prerelease.length) if (parsed.patch !== 0) type = "patch";
|
|
626
|
+
else if (parsed.minor !== 0) type = "minor";
|
|
627
|
+
else if (parsed.major !== 0) type = "major";
|
|
628
|
+
else type = "major";
|
|
629
|
+
else if (parsed.major === 0) type = "major";
|
|
630
|
+
if (type) return {
|
|
631
|
+
...plan,
|
|
632
|
+
release: { type }
|
|
633
|
+
};
|
|
634
|
+
return plan;
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
//#endregion
|
|
638
|
+
//#region src/git/branch/getCurrentGitBranch.ts
|
|
639
|
+
/**
|
|
640
|
+
* Get the current Git branch name.
|
|
641
|
+
*
|
|
642
|
+
* Uses `execGit` to run `git symbolic-ref -q --short HEAD` in the provided
|
|
643
|
+
* working directory and trims the result. Returns an empty string when the
|
|
644
|
+
* repository is in a detached HEAD state.
|
|
645
|
+
*
|
|
646
|
+
* @param cwd The working directory to run the Git command in
|
|
647
|
+
* @returns The current branch name as a string
|
|
648
|
+
*/
|
|
649
|
+
async function getCurrentGitBranch(cwd) {
|
|
650
|
+
return (await execGit(cwd, [
|
|
651
|
+
"symbolic-ref",
|
|
652
|
+
"-q",
|
|
653
|
+
"--short",
|
|
654
|
+
"HEAD"
|
|
655
|
+
])).trim();
|
|
656
|
+
}
|
|
657
|
+
//#endregion
|
|
658
|
+
//#region src/git/commit/createGitCommit.ts
|
|
659
|
+
/**
|
|
660
|
+
* Stage all changes and create a git commit with the specified message.
|
|
661
|
+
*
|
|
662
|
+
* Validates the commit message and working directory status before staging
|
|
663
|
+
* all changes and executing the commit command.
|
|
664
|
+
*
|
|
665
|
+
* @param cwd The target directory path for executing git commands
|
|
666
|
+
* @param message The message to associate with the commit
|
|
667
|
+
* @returns A promise that resolves when the operation completes
|
|
668
|
+
*/
|
|
669
|
+
async function createGitCommit(cwd, message) {
|
|
670
|
+
if (!message) return;
|
|
671
|
+
await execGit(cwd, ["add", "."]);
|
|
672
|
+
if (!(await execGit(cwd, ["status", "--porcelain"])).trim()) return;
|
|
673
|
+
await execGit(cwd, [
|
|
674
|
+
"commit",
|
|
675
|
+
"--message",
|
|
676
|
+
message
|
|
677
|
+
]);
|
|
678
|
+
}
|
|
679
|
+
//#endregion
|
|
680
|
+
//#region src/git/commit/extractMentions.ts
|
|
681
|
+
var ALPHANUMERIC = "[a-zA-Z0-9]";
|
|
682
|
+
var SEPARATOR = "[-_]";
|
|
683
|
+
var USERNAME = `${ALPHANUMERIC}+(?:${SEPARATOR}+${ALPHANUMERIC}+)*`;
|
|
684
|
+
var EMAIL_LOCAL = "[a-zA-Z0-9._%+-]";
|
|
685
|
+
var DOMAIN_SUFFIX = "[a-zA-Z0-9.-]*\\.[a-zA-Z]{2,}";
|
|
686
|
+
var SCOPED_PACKAGE_SPEC_SUFFIX = `/${USERNAME}@[0-9]+\\.[0-9]+\\.[0-9]+`;
|
|
687
|
+
/**
|
|
688
|
+
* Extract unique mention handles from the input text string.
|
|
689
|
+
*
|
|
690
|
+
* Evaluates `text` against boundary rules to isolate valid handles while
|
|
691
|
+
* excluding email addresses, domain names, and scoped package specs, then
|
|
692
|
+
* de-duplicates the resulting handles.
|
|
693
|
+
*
|
|
694
|
+
* @param text The input string to parse for mentions
|
|
695
|
+
* @returns An array of unique mention handles
|
|
696
|
+
*/
|
|
697
|
+
function extractMentions(text) {
|
|
698
|
+
if (!text) return [];
|
|
699
|
+
const regex = new RegExp(`(?<!${EMAIL_LOCAL})@(${USERNAME})\\b(?!${SEPARATOR}|${DOMAIN_SUFFIX}|${SCOPED_PACKAGE_SPEC_SUFFIX})`, "g");
|
|
700
|
+
const matches = text.matchAll(regex);
|
|
701
|
+
const mentions = new Set(matches.map((match) => match[1]));
|
|
702
|
+
return Array.from(mentions);
|
|
703
|
+
}
|
|
704
|
+
//#endregion
|
|
705
|
+
//#region src/git/revision/resolveGitRevision.ts
|
|
706
|
+
/**
|
|
707
|
+
* Resolve a Git revision or commit-ish expression to its full commit hash.
|
|
708
|
+
*
|
|
709
|
+
* Runs `git rev-parse --verify <revision>^{commit}` under the hood. Peels annotated
|
|
710
|
+
* tags, branches, short SHAs, and relative expressions down to a verified commit object.
|
|
711
|
+
*
|
|
712
|
+
* @param cwd The repository working directory
|
|
713
|
+
* @param revision The Git revision or expression to resolve
|
|
714
|
+
* @returns A promise that resolves to the full, trimmed commit SHA-1 string
|
|
715
|
+
*/
|
|
716
|
+
async function resolveGitRevision(cwd, revision) {
|
|
717
|
+
return (await execGit(cwd, [
|
|
718
|
+
"rev-parse",
|
|
719
|
+
"--verify",
|
|
720
|
+
`${revision}^{commit}`
|
|
721
|
+
])).trim();
|
|
722
|
+
}
|
|
723
|
+
//#endregion
|
|
724
|
+
//#region src/git/commit/getCurrentGitCommit.ts
|
|
725
|
+
/**
|
|
726
|
+
* Resolve the current commit hash for HEAD in the repository.
|
|
727
|
+
*
|
|
728
|
+
* @param cwd The repository working directory
|
|
729
|
+
* @returns A promise that resolves to the full, trimmed commit SHA-1 string
|
|
730
|
+
*/
|
|
731
|
+
function getCurrentGitCommit(cwd) {
|
|
732
|
+
return resolveGitRevision(cwd, "HEAD");
|
|
733
|
+
}
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region src/cache/TTLCache.ts
|
|
736
|
+
/**
|
|
737
|
+
* A simple time-to-live cache that automatically evicts entries after a TTL.
|
|
738
|
+
*
|
|
739
|
+
* - Entries are removed automatically when their TTL expires.
|
|
740
|
+
* - Reading an entry resets its TTL to the original value.
|
|
741
|
+
* - Setting an existing key clears its previous timer and resets the TTL.
|
|
742
|
+
*
|
|
743
|
+
* @template K Type of cache keys
|
|
744
|
+
* @template V Type of cache values
|
|
745
|
+
*/
|
|
746
|
+
var TTLCache = class {
|
|
747
|
+
constructor() {
|
|
748
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Store a value under `key` for `ttl` milliseconds.
|
|
752
|
+
*
|
|
753
|
+
* If the key already exists its previous timer is cleared and the TTL is reset.
|
|
754
|
+
*
|
|
755
|
+
* @param key Cache key
|
|
756
|
+
* @param value Value to store
|
|
757
|
+
* @param ttl Time to live in milliseconds (default: 60_000)
|
|
758
|
+
*/
|
|
759
|
+
set(key, value, ttl = 6e4) {
|
|
760
|
+
const ref = this.cache.get(key);
|
|
761
|
+
if (ref) clearTimeout(ref.timer);
|
|
762
|
+
const timer = setTimeout(() => {
|
|
763
|
+
this.cache.delete(key);
|
|
764
|
+
}, ttl).unref();
|
|
765
|
+
this.cache.set(key, {
|
|
766
|
+
value,
|
|
767
|
+
timer,
|
|
768
|
+
ttl
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Retrieve the value for `key`, or `undefined` if not present.
|
|
773
|
+
*
|
|
774
|
+
* Reading an entry resets its TTL to the original `ttl` provided when it was set.
|
|
775
|
+
*
|
|
776
|
+
* @param key Cache key
|
|
777
|
+
* @returns The stored value or `undefined` if not present
|
|
778
|
+
*/
|
|
779
|
+
get(key) {
|
|
780
|
+
const ref = this.cache.get(key);
|
|
781
|
+
if (!ref) return void 0;
|
|
782
|
+
this.set(key, ref.value, ref.ttl);
|
|
783
|
+
return ref.value;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Check whether `key` exists in the cache.
|
|
787
|
+
*
|
|
788
|
+
* @param key Cache key
|
|
789
|
+
* @returns `true` if present; otherwise `false`
|
|
790
|
+
*/
|
|
791
|
+
has(key) {
|
|
792
|
+
return this.cache.has(key);
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Remove `key` from the cache and clear its eviction timer.
|
|
796
|
+
*
|
|
797
|
+
* @param key Cache key
|
|
798
|
+
* @returns `true` if the key was present and removed; otherwise `false`
|
|
799
|
+
*/
|
|
800
|
+
delete(key) {
|
|
801
|
+
const ref = this.cache.get(key);
|
|
802
|
+
if (!ref) return false;
|
|
803
|
+
clearTimeout(ref.timer);
|
|
804
|
+
return this.cache.delete(key);
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Clear all entries and their timers.
|
|
808
|
+
*/
|
|
809
|
+
clear() {
|
|
810
|
+
for (const { timer } of this.cache.values()) clearTimeout(timer);
|
|
811
|
+
this.cache.clear();
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
//#endregion
|
|
815
|
+
//#region src/git/commit/parseCommit.ts
|
|
816
|
+
var BREAKING_HEADER_REGEX = /^([\w-]+)(?:\(([^)]+)\))?!: (.*)$/;
|
|
817
|
+
var BREAKING_MARKERS = ["BREAKING CHANGE", "BREAKING-CHANGE"];
|
|
818
|
+
/**
|
|
819
|
+
* Parse a git log entry into a structured commit object.
|
|
820
|
+
*
|
|
821
|
+
* Combines the commit subject and body, extracts conventional commit metadata
|
|
822
|
+
* using custom trailer markers and breaking header patterns, and normalizes
|
|
823
|
+
* dates to ISO format strings.
|
|
824
|
+
*
|
|
825
|
+
* @param entry The raw git log entry to parse
|
|
826
|
+
* @param markers Optional array of custom trailer marker strings
|
|
827
|
+
* @returns A structured commit object containing parsed details, notes, and mentions
|
|
828
|
+
*/
|
|
829
|
+
async function parseCommit(entry, markers = []) {
|
|
830
|
+
const message = [entry.subject, entry.body].filter(Boolean).join("\n\n");
|
|
831
|
+
const commit = new CommitParser({
|
|
832
|
+
breakingHeaderPattern: BREAKING_HEADER_REGEX,
|
|
833
|
+
noteKeywords: [],
|
|
834
|
+
notesPattern: () => buildTrailerPattern(BREAKING_MARKERS.concat(markers))
|
|
835
|
+
}).parse(message);
|
|
836
|
+
const notes = [];
|
|
837
|
+
const trailers = [];
|
|
838
|
+
for (const note of commit.notes) {
|
|
839
|
+
if (BREAKING_MARKERS.includes(note.title.toUpperCase())) notes.push(note);
|
|
840
|
+
trailers.push({
|
|
841
|
+
key: note.title,
|
|
842
|
+
value: note.text
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
if (notes.length === 0 && BREAKING_HEADER_REGEX.test(commit.header)) {
|
|
846
|
+
const note = {
|
|
847
|
+
title: "BREAKING CHANGE",
|
|
848
|
+
text: commit.subject
|
|
849
|
+
};
|
|
850
|
+
notes.push(note);
|
|
851
|
+
trailers.push({
|
|
852
|
+
key: note.title,
|
|
853
|
+
value: note.text
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
const mentions = extractMentions(message);
|
|
857
|
+
return {
|
|
858
|
+
...commit,
|
|
859
|
+
hash: entry.hash,
|
|
860
|
+
parent: entry.parent,
|
|
861
|
+
authorDate: new Date(entry.authorDate).toISOString(),
|
|
862
|
+
committerDate: new Date(entry.committerDate).toISOString(),
|
|
863
|
+
type: commit.type?.toLowerCase() || null,
|
|
864
|
+
scope: commit.scope || null,
|
|
865
|
+
subject: commit.subject || null,
|
|
866
|
+
header: commit.header,
|
|
867
|
+
body: commit.body,
|
|
868
|
+
footer: commit.footer,
|
|
869
|
+
notes,
|
|
870
|
+
mentions,
|
|
871
|
+
trailers,
|
|
872
|
+
files: entry.files
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
//#endregion
|
|
876
|
+
//#region src/git/commit/parseGitPrettyFormat.ts
|
|
877
|
+
/**
|
|
878
|
+
* Field separator used in the git pretty format to produce a single-line
|
|
879
|
+
*, machine-parseable record.
|
|
880
|
+
*/
|
|
881
|
+
var GIT_PRETTY_FIELD_SEPARATOR = "";
|
|
882
|
+
/**
|
|
883
|
+
* Pretty format string passed to `git log --pretty=format:`. Produces seven
|
|
884
|
+
* fields separated by `GIT_PRETTY_FIELD_SEPARATOR`:
|
|
885
|
+
* hash, parents, author date, committer date, subject, body, files.
|
|
886
|
+
*/
|
|
887
|
+
var GIT_PRETTY_FORMAT = [
|
|
888
|
+
"%H",
|
|
889
|
+
"%P",
|
|
890
|
+
"%aI",
|
|
891
|
+
"%cI",
|
|
892
|
+
"%s",
|
|
893
|
+
"%b",
|
|
894
|
+
""
|
|
895
|
+
].join("");
|
|
896
|
+
/**
|
|
897
|
+
* Parse a single line produced by `git log --pretty=format:${GIT_PRETTY_FORMAT}`.
|
|
898
|
+
*
|
|
899
|
+
* The input is expected to contain seven fields separated by
|
|
900
|
+
* `GIT_PRETTY_FIELD_SEPARATOR`:
|
|
901
|
+
* 1. `hash` (commit hash)
|
|
902
|
+
* 2. `parent` (space-separated parent hashes)
|
|
903
|
+
* 3. `authorDate` (ISO 8601)
|
|
904
|
+
* 4. `committerDate` (ISO 8601)
|
|
905
|
+
* 5. `subject`
|
|
906
|
+
* 6. `body`
|
|
907
|
+
* 7. `files` (newline-separated list)
|
|
908
|
+
*
|
|
909
|
+
* The function trims fields, converts parent hashes into an array, normalizes
|
|
910
|
+
* dates to ISO strings, and splits `files` into an array. Missing optional
|
|
911
|
+
* fields are normalized to sensible defaults.
|
|
912
|
+
*
|
|
913
|
+
* @param raw Raw pretty-format string produced by git
|
|
914
|
+
* @returns Parsed `GitLogEntry` with `hash`, `parent`, `authorDate`, `committerDate`, `subject`, `body`, and `files`
|
|
915
|
+
* @throws If required fields (hash, authorDate, committerDate, subject) are missing or malformed
|
|
916
|
+
*/
|
|
917
|
+
function parseGitPrettyFormat(raw) {
|
|
918
|
+
let [hash, parentHashes, authorDate, committerDate, subject, body, files] = raw.split("");
|
|
919
|
+
hash = hash?.trim();
|
|
920
|
+
parentHashes = parentHashes?.trim();
|
|
921
|
+
authorDate = authorDate?.trim();
|
|
922
|
+
committerDate = committerDate?.trim();
|
|
923
|
+
subject = subject?.trim();
|
|
924
|
+
body = body?.trim();
|
|
925
|
+
files = files?.trim();
|
|
926
|
+
if (!hash || !authorDate || !committerDate || typeof subject !== "string") throw new Error(`The input "${colors.bold(raw)}" is not in the expected pretty format.`);
|
|
927
|
+
return {
|
|
928
|
+
hash,
|
|
929
|
+
parent: parentHashes ? parentHashes.split(/\s+/) : [],
|
|
930
|
+
authorDate: new Date(authorDate).toISOString(),
|
|
931
|
+
committerDate: new Date(committerDate).toISOString(),
|
|
932
|
+
subject,
|
|
933
|
+
body: body ?? "",
|
|
934
|
+
files: files ? files.split("\n") : []
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
//#endregion
|
|
938
|
+
//#region src/git/commit/getParsedCommit.ts
|
|
939
|
+
var cache = new TTLCache();
|
|
940
|
+
/**
|
|
941
|
+
* Retrieve and parse a commit by its reference.
|
|
942
|
+
*
|
|
943
|
+
* Fetches the raw git log entry for the specified reference, checks the cache
|
|
944
|
+
* for an existing entry, and parses the log data if a cache miss occurs.
|
|
945
|
+
*
|
|
946
|
+
* @param cwd The current working directory path
|
|
947
|
+
* @param ref The git reference to fetch
|
|
948
|
+
* @param markers Optional array of custom trailer marker strings
|
|
949
|
+
* @returns A structured commit object or null if the operation fails
|
|
950
|
+
*/
|
|
951
|
+
async function getParsedCommit(cwd, ref, markers = []) {
|
|
952
|
+
try {
|
|
953
|
+
const entry = parseGitPrettyFormat(await execGit(cwd, [
|
|
954
|
+
"log",
|
|
955
|
+
"-1",
|
|
956
|
+
ref,
|
|
957
|
+
"--name-only",
|
|
958
|
+
`--pretty=format:${GIT_PRETTY_FORMAT}`
|
|
959
|
+
]));
|
|
960
|
+
const key = `${entry.hash}:${entry.parent}:${markers}`;
|
|
961
|
+
const cached = cache.get(key);
|
|
962
|
+
if (cached) return cached;
|
|
963
|
+
const parsed = await parseCommit(entry, markers);
|
|
964
|
+
cache.set(key, parsed);
|
|
965
|
+
return parsed;
|
|
966
|
+
} catch {
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
//#endregion
|
|
971
|
+
//#region src/git/history/countGitHistory.ts
|
|
972
|
+
/**
|
|
973
|
+
* Count mainline commits reachable from a given reference.
|
|
974
|
+
*
|
|
975
|
+
* Uses `git rev-list --count --first-parent` to calculate the length of the
|
|
976
|
+
* primary history trunk, excluding commits introduced by side-branch merges.
|
|
977
|
+
*
|
|
978
|
+
* @param cwd Repository working directory
|
|
979
|
+
* @param ref Commit reference to count from (defaults to `HEAD`)
|
|
980
|
+
* @returns Number of commits along the primary branch line up to `ref`
|
|
981
|
+
*/
|
|
982
|
+
async function countGitHistory(cwd, ref = "HEAD") {
|
|
983
|
+
const stdout = await execGit(cwd, [
|
|
984
|
+
"rev-list",
|
|
985
|
+
"--count",
|
|
986
|
+
"--first-parent",
|
|
987
|
+
ref
|
|
988
|
+
]);
|
|
989
|
+
return Number.parseInt(stdout.trim(), 10);
|
|
990
|
+
}
|
|
991
|
+
//#endregion
|
|
992
|
+
//#region src/git/repository/isGitShallowRepository.ts
|
|
993
|
+
/**
|
|
994
|
+
* Determine whether the repository at `cwd` is a shallow clone.
|
|
995
|
+
*
|
|
996
|
+
* Runs `git rev-parse --is-shallow-repository` to detect shallow clones.
|
|
997
|
+
* Useful before operations that require full history.
|
|
998
|
+
*
|
|
999
|
+
* @param cwd Repository working directory
|
|
1000
|
+
* @returns `true` if shallow, `false` if full clone, or `null` on error
|
|
1001
|
+
*/
|
|
1002
|
+
async function isGitShallowRepository(cwd) {
|
|
1003
|
+
return (await execGit(cwd, ["rev-parse", "--is-shallow-repository"])).trim() === "true";
|
|
1004
|
+
}
|
|
1005
|
+
//#endregion
|
|
1006
|
+
//#region src/git/repository/withShallowLock.ts
|
|
1007
|
+
var lockQueue = /* @__PURE__ */ new Map();
|
|
1008
|
+
var context = new AsyncLocalStorage();
|
|
1009
|
+
/**
|
|
1010
|
+
* Serialize access to a shallow Git repository while running a callback.
|
|
1011
|
+
*
|
|
1012
|
+
* If `cwd` is a shallow clone, callers from different async contexts are
|
|
1013
|
+
* executed one at a time; nested calls within the same async context run
|
|
1014
|
+
* immediately without waiting. If the repository becomes non-shallow before
|
|
1015
|
+
* a queued turn runs, downstream waiters are released early.
|
|
1016
|
+
*
|
|
1017
|
+
* @param cwd Repository working directory path string
|
|
1018
|
+
* @param callback Function to execute while the lock is held
|
|
1019
|
+
* @returns Promise resolving to the result of `callback`
|
|
1020
|
+
*/
|
|
1021
|
+
async function withShallowLock(cwd, callback) {
|
|
1022
|
+
if (!await isGitShallowRepository(cwd)) return callback();
|
|
1023
|
+
const active = context.getStore();
|
|
1024
|
+
if (active?.has(cwd)) return callback();
|
|
1025
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
1026
|
+
const previous = lockQueue.get(cwd) ?? Promise.resolve();
|
|
1027
|
+
const queued = previous.then(() => promise);
|
|
1028
|
+
lockQueue.set(cwd, queued);
|
|
1029
|
+
await previous;
|
|
1030
|
+
if (!await isGitShallowRepository(cwd)) resolve();
|
|
1031
|
+
try {
|
|
1032
|
+
return await context.run(new Set(active).add(cwd), callback);
|
|
1033
|
+
} finally {
|
|
1034
|
+
resolve();
|
|
1035
|
+
if (lockQueue.get(cwd) === queued) lockQueue.delete(cwd);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
//#endregion
|
|
1039
|
+
//#region src/git/revision/isGitAncestor.ts
|
|
1040
|
+
/**
|
|
1041
|
+
* Determine whether `ancestor` is an ancestor of `descendant`.
|
|
1042
|
+
*
|
|
1043
|
+
* Runs `git merge-base --is-ancestor <ancestor> <descendant>` in the repository at `cwd`.
|
|
1044
|
+
* Resolves to `true` if `ancestor` is an ancestor, `false` if not.
|
|
1045
|
+
* Rethrows errors for invalid refs or other failures.
|
|
1046
|
+
*
|
|
1047
|
+
* @param cwd Repository working directory
|
|
1048
|
+
* @param ancestor Candidate ancestor ref
|
|
1049
|
+
* @param descendant Candidate descendant ref
|
|
1050
|
+
* @returns Resolves to `true` if `ancestor` is an ancestor of `descendant`, `false` otherwise
|
|
1051
|
+
* @throws If a ref is invalid or the Git command fails
|
|
1052
|
+
*/
|
|
1053
|
+
function isGitAncestor(cwd, ancestor, descendant) {
|
|
1054
|
+
return execGit(cwd, [
|
|
1055
|
+
"merge-base",
|
|
1056
|
+
"--is-ancestor",
|
|
1057
|
+
ancestor,
|
|
1058
|
+
descendant
|
|
1059
|
+
]).then(() => true).catch((err) => {
|
|
1060
|
+
if (err?.code === 1) return false;
|
|
1061
|
+
throw err;
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
//#endregion
|
|
1065
|
+
//#region src/git/history/expandGitHistory.ts
|
|
1066
|
+
/**
|
|
1067
|
+
* Deepen a shallow clone so additional commits become available.
|
|
1068
|
+
*
|
|
1069
|
+
* Attempts a single `--shallow-since` fetch to reach `until` (if provided),
|
|
1070
|
+
* otherwise deepens by `pageSize`, accounting for existing depth when `from`
|
|
1071
|
+
* is supplied. Operation is serialized to avoid concurrent deepens.
|
|
1072
|
+
*
|
|
1073
|
+
* @param cwd Repository working directory
|
|
1074
|
+
* @param options Options: { pageSize?, from?, until? }
|
|
1075
|
+
* @returns true if history was deepened or already sufficient, otherwise false
|
|
1076
|
+
*/
|
|
1077
|
+
async function expandGitHistory(cwd, options = {}) {
|
|
1078
|
+
if (!await isGitShallowRepository(cwd)) return false;
|
|
1079
|
+
return withShallowLock(cwd, async () => {
|
|
1080
|
+
const { until } = options;
|
|
1081
|
+
let { from } = options;
|
|
1082
|
+
let { pageSize = 50 } = options;
|
|
1083
|
+
if (until) {
|
|
1084
|
+
if (await isGitAncestor(cwd, until, "HEAD")) return true;
|
|
1085
|
+
const commitDate = (await execGit(cwd, [
|
|
1086
|
+
"show",
|
|
1087
|
+
"-s",
|
|
1088
|
+
"--format=%cI",
|
|
1089
|
+
`${until}^{commit}`
|
|
1090
|
+
])).trim();
|
|
1091
|
+
try {
|
|
1092
|
+
await execGit(cwd, [
|
|
1093
|
+
"fetch",
|
|
1094
|
+
`--shallow-since=${commitDate}`,
|
|
1095
|
+
"--update-shallow",
|
|
1096
|
+
"--filter=blob:none"
|
|
1097
|
+
]);
|
|
1098
|
+
return true;
|
|
1099
|
+
} catch {}
|
|
1100
|
+
}
|
|
1101
|
+
if (from) {
|
|
1102
|
+
from = await resolveGitRevision(cwd, from);
|
|
1103
|
+
const depth = await countGitHistory(cwd, from);
|
|
1104
|
+
if (depth > pageSize) return true;
|
|
1105
|
+
pageSize -= depth - 1;
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
await execGit(cwd, [
|
|
1109
|
+
"fetch",
|
|
1110
|
+
`--deepen=${pageSize}`,
|
|
1111
|
+
"--update-shallow",
|
|
1112
|
+
"--filter=blob:none",
|
|
1113
|
+
...from && !await isGitAncestor(cwd, from, "HEAD") ? [
|
|
1114
|
+
"origin",
|
|
1115
|
+
from,
|
|
1116
|
+
"HEAD"
|
|
1117
|
+
] : []
|
|
1118
|
+
]);
|
|
1119
|
+
return true;
|
|
1120
|
+
} catch {
|
|
1121
|
+
return false;
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
//#endregion
|
|
1126
|
+
//#region src/git/revision/detectGitRelationship.ts
|
|
1127
|
+
var GIT_REL_FLAGS = {
|
|
1128
|
+
NONE: 0,
|
|
1129
|
+
IS_ANCESTOR: 1,
|
|
1130
|
+
IS_DESCENDANT: 2,
|
|
1131
|
+
IS_EQUAL: 3
|
|
1132
|
+
};
|
|
1133
|
+
/**
|
|
1134
|
+
* Determine the relationship between two git refs.
|
|
1135
|
+
*
|
|
1136
|
+
* Runs `git merge-base --is-ancestor` in both directions and returns a
|
|
1137
|
+
* numeric bitmask using `GIT_REL_FLAGS`. A successful check (exit code 0)
|
|
1138
|
+
* indicates an ancestor relationship; non-zero exit is treated as "not ancestor".
|
|
1139
|
+
*
|
|
1140
|
+
* @param cwd Repository working directory
|
|
1141
|
+
* @param refA First ref (candidate ancestor)
|
|
1142
|
+
* @param refB Second ref (candidate descendant)
|
|
1143
|
+
* @returns Bitmask of type `GitRelFlags`
|
|
1144
|
+
*/
|
|
1145
|
+
async function detectGitRelationship(cwd, refA, refB) {
|
|
1146
|
+
let flags = GIT_REL_FLAGS.NONE;
|
|
1147
|
+
const [aIsAncestor, bIsAncestor] = await Promise.all([isGitAncestor(cwd, refA, refB), isGitAncestor(cwd, refB, refA)]);
|
|
1148
|
+
if (aIsAncestor) flags |= GIT_REL_FLAGS.IS_ANCESTOR;
|
|
1149
|
+
if (bIsAncestor) flags |= GIT_REL_FLAGS.IS_DESCENDANT;
|
|
1150
|
+
return flags;
|
|
1151
|
+
}
|
|
1152
|
+
//#endregion
|
|
1153
|
+
//#region src/git/history/mergeGitHistory.ts
|
|
1154
|
+
/**
|
|
1155
|
+
* Merge two newest-first commit lists into a single deduplicated newest-first list.
|
|
1156
|
+
*
|
|
1157
|
+
* Compares commits by hash, uses git relationship (ancestor/descendant) when needed,
|
|
1158
|
+
* and falls back to committerDate for unrelated commits.
|
|
1159
|
+
*
|
|
1160
|
+
* @param cwd Repository working directory used for relationship checks
|
|
1161
|
+
* @param a Commits from the first source (newest-first)
|
|
1162
|
+
* @param b Commits from the second source (newest-first)
|
|
1163
|
+
* @returns Merged commits in newest-first order with duplicates removed
|
|
1164
|
+
*/
|
|
1165
|
+
async function mergeGitHistory(cwd, a, b) {
|
|
1166
|
+
const merged = [];
|
|
1167
|
+
const a2 = [...a];
|
|
1168
|
+
const b2 = [...b];
|
|
1169
|
+
let i = a2.shift();
|
|
1170
|
+
let j = b2.shift();
|
|
1171
|
+
while (i && j) switch (i.hash === j.hash ? GIT_REL_FLAGS.IS_EQUAL : await detectGitRelationship(cwd, i.hash, j.hash)) {
|
|
1172
|
+
case GIT_REL_FLAGS.IS_EQUAL:
|
|
1173
|
+
merged.push(i);
|
|
1174
|
+
i = a2.shift();
|
|
1175
|
+
j = b2.shift();
|
|
1176
|
+
break;
|
|
1177
|
+
case GIT_REL_FLAGS.IS_ANCESTOR:
|
|
1178
|
+
merged.push(j);
|
|
1179
|
+
j = b2.shift();
|
|
1180
|
+
break;
|
|
1181
|
+
case GIT_REL_FLAGS.IS_DESCENDANT:
|
|
1182
|
+
merged.push(i);
|
|
1183
|
+
i = a2.shift();
|
|
1184
|
+
break;
|
|
1185
|
+
case GIT_REL_FLAGS.NONE: if (i.committerDate < j.committerDate) {
|
|
1186
|
+
merged.push(j);
|
|
1187
|
+
j = b2.shift();
|
|
1188
|
+
} else {
|
|
1189
|
+
merged.push(i);
|
|
1190
|
+
i = a2.shift();
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
if (i) merged.push(i);
|
|
1194
|
+
if (j) merged.push(j);
|
|
1195
|
+
return merged.concat(a2).concat(b2);
|
|
1196
|
+
}
|
|
1197
|
+
//#endregion
|
|
1198
|
+
//#region src/git/history/restoreFullGitHistory.ts
|
|
1199
|
+
/**
|
|
1200
|
+
* Convert a shallow clone into a full clone by fetching additional history.
|
|
1201
|
+
*
|
|
1202
|
+
* Runs `git fetch --unshallow`. Returns `true` when the operation succeeds
|
|
1203
|
+
* and `false` on error.
|
|
1204
|
+
*
|
|
1205
|
+
* @param cwd Repository working directory
|
|
1206
|
+
* @returns `true` when the operation succeeded, otherwise `false`
|
|
1207
|
+
*/
|
|
1208
|
+
function restoreFullGitHistory(cwd) {
|
|
1209
|
+
return execGit(cwd, ["fetch", "--unshallow"]).then(() => true).catch(() => false);
|
|
1210
|
+
}
|
|
1211
|
+
//#endregion
|
|
1212
|
+
//#region src/git/history/showGitHistory.ts
|
|
1213
|
+
/**
|
|
1214
|
+
* Yield commit SHAs from repository history in paginated order.
|
|
1215
|
+
*
|
|
1216
|
+
* Ensures commits are available via `expandGitHistory`, resolves `until` to a
|
|
1217
|
+
* commit id, and pages from `from` toward `until`, yielding each commit SHA.
|
|
1218
|
+
* If a git command fails the generator ends silently.
|
|
1219
|
+
*
|
|
1220
|
+
* @param cwd Repository working directory
|
|
1221
|
+
* @param options Supports `from`, `until`, `pageSize`, and `filePath`
|
|
1222
|
+
* @yields Commit SHA strings in paginated order
|
|
1223
|
+
*/
|
|
1224
|
+
async function* showGitHistory(cwd, options = {}) {
|
|
1225
|
+
try {
|
|
1226
|
+
const { filePath, pageSize = 50 } = options;
|
|
1227
|
+
let from = options.from || "HEAD";
|
|
1228
|
+
let until = options.until || void 0;
|
|
1229
|
+
if (until) until = await resolveGitRevision(cwd, until);
|
|
1230
|
+
while (true) {
|
|
1231
|
+
await expandGitHistory(cwd, {
|
|
1232
|
+
from,
|
|
1233
|
+
until,
|
|
1234
|
+
pageSize
|
|
1235
|
+
});
|
|
1236
|
+
const args = [
|
|
1237
|
+
"rev-list",
|
|
1238
|
+
`--max-count=${pageSize}`,
|
|
1239
|
+
from
|
|
1240
|
+
];
|
|
1241
|
+
if (until) args.push(`^${until}`);
|
|
1242
|
+
if (filePath) args.push("--", ...(Array.isArray(filePath) ? filePath : [filePath]).filter(Boolean));
|
|
1243
|
+
const list = (await execGit(cwd, args)).trim().split("\n").filter(Boolean);
|
|
1244
|
+
for (const item of list.slice(0, -1)) yield item;
|
|
1245
|
+
const tail = list.at(-1);
|
|
1246
|
+
if (!tail) return;
|
|
1247
|
+
if (tail === from) {
|
|
1248
|
+
yield tail;
|
|
1249
|
+
from = `${tail}^`;
|
|
1250
|
+
from = await resolveGitRevision(cwd, from);
|
|
1251
|
+
} else from = tail;
|
|
1252
|
+
}
|
|
1253
|
+
} catch {}
|
|
1254
|
+
}
|
|
1255
|
+
//#endregion
|
|
1256
|
+
//#region src/git/history/showParsedGitHistory.ts
|
|
1257
|
+
/**
|
|
1258
|
+
* Yield parsed commits sequentially from repository history.
|
|
1259
|
+
*
|
|
1260
|
+
* Fetches commits using `showGitHistory` and parses each SHA with
|
|
1261
|
+
* `getParsedCommit`. Yields successfully parsed `Commit` objects on demand and
|
|
1262
|
+
* omits unparsable commits.
|
|
1263
|
+
*
|
|
1264
|
+
* @param cwd Repository working directory
|
|
1265
|
+
* @param options Filter and pagination options passed to `showGitHistory`
|
|
1266
|
+
* @yields Parsed `Commit` objects in historical order
|
|
1267
|
+
*/
|
|
1268
|
+
async function* showParsedGitHistory(cwd, options) {
|
|
1269
|
+
for await (const commit of showGitHistory(cwd, options)) {
|
|
1270
|
+
const parsed = await getParsedCommit(cwd, commit);
|
|
1271
|
+
if (parsed) yield parsed;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
//#endregion
|
|
1275
|
+
//#region src/git/repository/getGitConfigValue.ts
|
|
1276
|
+
/**
|
|
1277
|
+
* Get a local git configuration value for `key` in the repository at `cwd`.
|
|
1278
|
+
*
|
|
1279
|
+
* Returns the config value when present; otherwise `undefined`.
|
|
1280
|
+
*
|
|
1281
|
+
* @param cwd Repository working directory
|
|
1282
|
+
* @param key Git config key to read
|
|
1283
|
+
* @returns The config value or `undefined` if not set
|
|
1284
|
+
*/
|
|
1285
|
+
async function getGitConfigValue(cwd, key) {
|
|
1286
|
+
try {
|
|
1287
|
+
const value = (await execGit(cwd, [
|
|
1288
|
+
"config",
|
|
1289
|
+
"--local",
|
|
1290
|
+
"--get",
|
|
1291
|
+
key
|
|
1292
|
+
])).trim();
|
|
1293
|
+
return value ? value : void 0;
|
|
1294
|
+
} catch {
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
//#endregion
|
|
1299
|
+
//#region src/git/repository/ensureGitUserConfig.ts
|
|
1300
|
+
/**
|
|
1301
|
+
* Ensure git `user.name` and `user.email` are configured for the repository at `cwd`.
|
|
1302
|
+
*
|
|
1303
|
+
* No-op when the values are already present.
|
|
1304
|
+
*
|
|
1305
|
+
* @param cwd Repository working directory
|
|
1306
|
+
* @param user Object containing `name` and `email` values to set when missing
|
|
1307
|
+
* @returns Promise that resolves when configuration is ensured
|
|
1308
|
+
*/
|
|
1309
|
+
async function ensureGitUserConfig(cwd, user) {
|
|
1310
|
+
if (await getGitConfigValue(cwd, "user.name") && await getGitConfigValue(cwd, "user.email")) return;
|
|
1311
|
+
await execGit(cwd, [
|
|
1312
|
+
"config",
|
|
1313
|
+
"user.name",
|
|
1314
|
+
user.name
|
|
1315
|
+
]);
|
|
1316
|
+
await execGit(cwd, [
|
|
1317
|
+
"config",
|
|
1318
|
+
"user.email",
|
|
1319
|
+
user.email
|
|
1320
|
+
]);
|
|
1321
|
+
}
|
|
1322
|
+
//#endregion
|
|
1323
|
+
//#region src/git/repository/getGitRemote.ts
|
|
1324
|
+
/**
|
|
1325
|
+
* Get the URL for the named remote (defaults to `origin`).
|
|
1326
|
+
*
|
|
1327
|
+
* @param cwd Repository working directory
|
|
1328
|
+
* @param remoteName Remote name (defaults to `'origin'`)
|
|
1329
|
+
* @returns Remote URL string or `null` when unavailable
|
|
1330
|
+
*/
|
|
1331
|
+
function getGitRemote(cwd, remoteName = "origin") {
|
|
1332
|
+
return execGit(cwd, [
|
|
1333
|
+
"remote",
|
|
1334
|
+
"get-url",
|
|
1335
|
+
remoteName
|
|
1336
|
+
]).then((stdout) => stdout.trim()).catch(() => null);
|
|
1337
|
+
}
|
|
1338
|
+
//#endregion
|
|
1339
|
+
//#region src/git/repository/pushGitChanges.ts
|
|
1340
|
+
/**
|
|
1341
|
+
* Push a branch and optional tags to the `origin` remote using an atomic push.
|
|
1342
|
+
*
|
|
1343
|
+
* @param cwd Repository working directory
|
|
1344
|
+
* @param branch Branch name to push
|
|
1345
|
+
* @param additionalTags Optional list of tag names to push alongside the branch
|
|
1346
|
+
* @returns Promise that resolves when the push completes
|
|
1347
|
+
*/
|
|
1348
|
+
async function pushGitChanges(cwd, branch, ...additionalTags) {
|
|
1349
|
+
await execGit(cwd, [
|
|
1350
|
+
"push",
|
|
1351
|
+
"origin",
|
|
1352
|
+
...compact([branch, ...additionalTags]),
|
|
1353
|
+
"--atomic"
|
|
1354
|
+
]);
|
|
1355
|
+
}
|
|
1356
|
+
//#endregion
|
|
1357
|
+
//#region src/git/repository/resolveGitRepository.ts
|
|
1358
|
+
/**
|
|
1359
|
+
* Resolve Git repository host, owner, and project metadata from the `origin` remote URL.
|
|
1360
|
+
*
|
|
1361
|
+
* @param cwd Repository working directory
|
|
1362
|
+
* @returns Promise that resolves to a `GitRepository` object, or `null` when unavailable
|
|
1363
|
+
*/
|
|
1364
|
+
async function resolveGitRepository(cwd) {
|
|
1365
|
+
const origin = await getGitRemote(cwd);
|
|
1366
|
+
if (!origin) return null;
|
|
1367
|
+
const info = GitHost.fromUrl(origin);
|
|
1368
|
+
if (info && typeof info.type === "string" && typeof info.user === "string" && typeof info.project === "string") return {
|
|
1369
|
+
host: info.type,
|
|
1370
|
+
owner: info.user,
|
|
1371
|
+
repo: info.project,
|
|
1372
|
+
url: info.browse()
|
|
1373
|
+
};
|
|
1374
|
+
return null;
|
|
1375
|
+
}
|
|
1376
|
+
//#endregion
|
|
1377
|
+
//#region src/git/revision/readGitFile.ts
|
|
1378
|
+
/**
|
|
1379
|
+
* Read a file's contents from a specific Git revision.
|
|
1380
|
+
*
|
|
1381
|
+
* Runs `git show <ref>:<filePath>` and returns the command output as a
|
|
1382
|
+
* string. If the file does not exist at the given revision or the Git
|
|
1383
|
+
* command fails for any reason, the function returns `null`.
|
|
1384
|
+
*
|
|
1385
|
+
* The returned string is the raw file contents as emitted by Git; callers
|
|
1386
|
+
* that need binary-safe handling should decode or process the bytes
|
|
1387
|
+
* accordingly. This function intentionally swallows Git errors and signals
|
|
1388
|
+
* absence via a `null` result rather than throwing.
|
|
1389
|
+
*
|
|
1390
|
+
* @param cwd Repository working directory
|
|
1391
|
+
* @param ref Commit-ish reference (commit SHA, tag, or branch) to read from
|
|
1392
|
+
* @param filePath Path to the file relative to the repository root
|
|
1393
|
+
* @returns File contents as a string, or `null` if the file is unavailable at the ref
|
|
1394
|
+
*/
|
|
1395
|
+
function readGitFile(cwd, ref, filePath) {
|
|
1396
|
+
return execGit(cwd, ["show", `${ref}:${filePath}`]).catch(() => null);
|
|
1397
|
+
}
|
|
1398
|
+
//#endregion
|
|
1399
|
+
//#region src/git/tag/createGitTag.ts
|
|
1400
|
+
/**
|
|
1401
|
+
* Create a git tag in the repository at `cwd`.
|
|
1402
|
+
*
|
|
1403
|
+
* Creates an annotated tag when `message` is provided.
|
|
1404
|
+
*
|
|
1405
|
+
* @param cwd Repository working directory
|
|
1406
|
+
* @param tag Tag name to create
|
|
1407
|
+
* @param message Optional tag message (creates an annotated tag when present)
|
|
1408
|
+
* @returns Promise that resolves when the tag is created
|
|
1409
|
+
*/
|
|
1410
|
+
async function createGitTag(cwd, tag, message) {
|
|
1411
|
+
await execGit(cwd, ["tag", ...message ? [
|
|
1412
|
+
"-a",
|
|
1413
|
+
tag,
|
|
1414
|
+
"-m",
|
|
1415
|
+
message,
|
|
1416
|
+
"--cleanup=whitespace"
|
|
1417
|
+
] : [tag]]);
|
|
1418
|
+
}
|
|
1419
|
+
//#endregion
|
|
1420
|
+
//#region src/git/tag/deleteGitTag.ts
|
|
1421
|
+
/**
|
|
1422
|
+
* Delete a local git tag in the repository at `cwd`.
|
|
1423
|
+
*
|
|
1424
|
+
* @param cwd Repository working directory
|
|
1425
|
+
* @param tag Tag name to delete
|
|
1426
|
+
* @returns `true` when deletion succeeded, otherwise `false`
|
|
1427
|
+
*/
|
|
1428
|
+
function deleteGitTag(cwd, tag) {
|
|
1429
|
+
return execGit(cwd, [
|
|
1430
|
+
"tag",
|
|
1431
|
+
"-d",
|
|
1432
|
+
tag
|
|
1433
|
+
]).then(() => true).catch(() => false);
|
|
1434
|
+
}
|
|
1435
|
+
//#endregion
|
|
1436
|
+
//#region src/git/tag/isGitTagPresent.ts
|
|
1437
|
+
/**
|
|
1438
|
+
* Determine whether a specific git tag exists locally in the repository.
|
|
1439
|
+
*
|
|
1440
|
+
* @param cwd Working directory for the git command
|
|
1441
|
+
* @param tag Name of the tag to evaluate
|
|
1442
|
+
* @returns True if the tag reference exists locally, false otherwise
|
|
1443
|
+
*/
|
|
1444
|
+
function isGitTagPresent(cwd, tag) {
|
|
1445
|
+
return resolveGitRevision(cwd, `refs/tags/${tag}`).then(() => true).catch(() => false);
|
|
1446
|
+
}
|
|
1447
|
+
//#endregion
|
|
1448
|
+
//#region src/git/tag/ensureGitTag.ts
|
|
1449
|
+
var MIN_HISTORY_DEPTH = 2;
|
|
1450
|
+
var ORIGIN_NOT_REPO = `fatal: 'origin' does not appear to be a git repository`;
|
|
1451
|
+
var REMOTE_REF_NOT_FOUND = `fatal: couldn't find remote ref`;
|
|
1452
|
+
/**
|
|
1453
|
+
* Ensure a tag exists locally, fetching it shallowly from `origin` if needed.
|
|
1454
|
+
*
|
|
1455
|
+
* Acquires a shallow-fetch lock, verifies local presence and history depth,
|
|
1456
|
+
* attempts a shallow fetch of `refs/tags/<tag>` when required, and removes a
|
|
1457
|
+
* local tag if the remote reports the tag is missing.
|
|
1458
|
+
*
|
|
1459
|
+
* @param cwd Working directory
|
|
1460
|
+
* @param tag Tag name (for example `v1.2.0`)
|
|
1461
|
+
* @returns `true` if the tag is present or was fetched; otherwise `false`
|
|
1462
|
+
*/
|
|
1463
|
+
function ensureGitTag(cwd, tag) {
|
|
1464
|
+
return withShallowLock(cwd, async () => {
|
|
1465
|
+
const tagRef = `refs/tags/${tag}`;
|
|
1466
|
+
if (await isGitTagPresent(cwd, tag) && await countGitHistory(cwd, tagRef) >= MIN_HISTORY_DEPTH) return true;
|
|
1467
|
+
try {
|
|
1468
|
+
await execGit(cwd, [
|
|
1469
|
+
"fetch",
|
|
1470
|
+
"origin",
|
|
1471
|
+
`${tagRef}:${tagRef}`,
|
|
1472
|
+
`--depth=${MIN_HISTORY_DEPTH}`,
|
|
1473
|
+
"--update-shallow",
|
|
1474
|
+
"--force"
|
|
1475
|
+
]);
|
|
1476
|
+
return true;
|
|
1477
|
+
} catch (err) {
|
|
1478
|
+
const errorMessage = err?.message;
|
|
1479
|
+
if (errorMessage?.includes(ORIGIN_NOT_REPO)) return await isGitTagPresent(cwd, tag);
|
|
1480
|
+
if (errorMessage?.includes(REMOTE_REF_NOT_FOUND)) {
|
|
1481
|
+
if (await isGitTagPresent(cwd, tag)) await deleteGitTag(cwd, tag);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return false;
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
//#endregion
|
|
1488
|
+
//#region src/git/tag/generateGitTagName.ts
|
|
1489
|
+
function generateGitTagName(entry, name, version) {
|
|
1490
|
+
if (!version) return null;
|
|
1491
|
+
if (entry) return `v${version}`;
|
|
1492
|
+
if (!name) throw new Error("Package name is required to build a tag name.");
|
|
1493
|
+
return `${name}@${version}`;
|
|
1494
|
+
}
|
|
1495
|
+
//#endregion
|
|
1496
|
+
//#region src/git/tag/getCurrentGitTag.ts
|
|
1497
|
+
/**
|
|
1498
|
+
* Get the tag name that exactly matches the current `HEAD`, if any.
|
|
1499
|
+
*
|
|
1500
|
+
* @param cwd Repository working directory
|
|
1501
|
+
* @returns Tag name or `null` when no exact tag matches HEAD
|
|
1502
|
+
*/
|
|
1503
|
+
async function getCurrentGitTag(cwd) {
|
|
1504
|
+
try {
|
|
1505
|
+
const tag = (await execGit(cwd, [
|
|
1506
|
+
"describe",
|
|
1507
|
+
"--tags",
|
|
1508
|
+
"--exact-match"
|
|
1509
|
+
])).trim();
|
|
1510
|
+
/* istanbul ignore next */
|
|
1511
|
+
return tag ? tag : null;
|
|
1512
|
+
} catch {
|
|
1513
|
+
return null;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
//#endregion
|
|
1517
|
+
//#region src/git/tag/getGitTagMessage.ts
|
|
1518
|
+
/**
|
|
1519
|
+
* Get the message of an annotated git tag in the repository at `cwd`.
|
|
1520
|
+
*
|
|
1521
|
+
* @param cwd Repository working directory
|
|
1522
|
+
* @param tag Tag name to query
|
|
1523
|
+
* @returns The tag message string when found, otherwise `undefined`
|
|
1524
|
+
*/
|
|
1525
|
+
async function getGitTagMessage(cwd, tag) {
|
|
1526
|
+
try {
|
|
1527
|
+
const [objectType, message] = (await execGit(cwd, [
|
|
1528
|
+
"tag",
|
|
1529
|
+
"-l",
|
|
1530
|
+
"--format=%(objecttype)%00%(contents)",
|
|
1531
|
+
tag
|
|
1532
|
+
])).split("\0");
|
|
1533
|
+
if (objectType?.trim() !== "tag") return;
|
|
1534
|
+
return message?.trim() || void 0;
|
|
1535
|
+
} catch {
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
//#endregion
|
|
1540
|
+
//#region src/git/tag/getLatestGitTag.ts
|
|
1541
|
+
/**
|
|
1542
|
+
* Get the most recent tag reachable from `HEAD`.
|
|
1543
|
+
*
|
|
1544
|
+
* @param cwd Repository working directory
|
|
1545
|
+
* @returns Latest tag string or `null` when none is found
|
|
1546
|
+
*/
|
|
1547
|
+
async function getLatestGitTag(cwd) {
|
|
1548
|
+
try {
|
|
1549
|
+
const tag = (await execGit(cwd, [
|
|
1550
|
+
"describe",
|
|
1551
|
+
"--tags",
|
|
1552
|
+
"--abbrev=0"
|
|
1553
|
+
])).trim();
|
|
1554
|
+
/* istanbul ignore next */
|
|
1555
|
+
return tag ? tag : null;
|
|
1556
|
+
} catch {
|
|
1557
|
+
return null;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
//#endregion
|
|
1561
|
+
//#region src/git/tag/isGitTagConnected.ts
|
|
1562
|
+
/**
|
|
1563
|
+
* Determine whether a specific git tag commit is reachable from the current HEAD.
|
|
1564
|
+
*
|
|
1565
|
+
* Uses `git merge-base --is-ancestor refs/tags/<tag>^{commit} HEAD` to verify
|
|
1566
|
+
* that the tag's commit is an ancestor of the current reference. Any non-zero
|
|
1567
|
+
* exit status or execution error is treated as "not reachable".
|
|
1568
|
+
*
|
|
1569
|
+
* @param cwd Working directory where the git command will run
|
|
1570
|
+
* @param tag Tag name to evaluate (for example, "v1.2.0")
|
|
1571
|
+
* @returns True if the tag commit is reachable from HEAD, false otherwise
|
|
1572
|
+
*/
|
|
1573
|
+
async function isGitTagConnected(cwd, tag) {
|
|
1574
|
+
try {
|
|
1575
|
+
await execGit(cwd, [
|
|
1576
|
+
"merge-base",
|
|
1577
|
+
"--is-ancestor",
|
|
1578
|
+
`refs/tags/${tag}^{commit}`,
|
|
1579
|
+
"HEAD"
|
|
1580
|
+
]);
|
|
1581
|
+
return true;
|
|
1582
|
+
} catch {
|
|
1583
|
+
return false;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
//#endregion
|
|
1587
|
+
//#region src/git/tag/isGitTagPointed.ts
|
|
1588
|
+
/**
|
|
1589
|
+
* Check whether a given tag points to the provided commit.
|
|
1590
|
+
*
|
|
1591
|
+
* @param cwd Repository working directory
|
|
1592
|
+
* @param tag Tag name to resolve
|
|
1593
|
+
* @param commit Commit hash to compare against
|
|
1594
|
+
* @returns `true` when the tag points at `commit`, otherwise `false`
|
|
1595
|
+
*/
|
|
1596
|
+
async function isGitTagPointed(cwd, tag, commit) {
|
|
1597
|
+
try {
|
|
1598
|
+
await execGit(cwd, [
|
|
1599
|
+
"merge-base",
|
|
1600
|
+
"--is-ancestor",
|
|
1601
|
+
`refs/tags/${tag}^{commit}`,
|
|
1602
|
+
commit
|
|
1603
|
+
]);
|
|
1604
|
+
await execGit(cwd, [
|
|
1605
|
+
"merge-base",
|
|
1606
|
+
"--is-ancestor",
|
|
1607
|
+
commit,
|
|
1608
|
+
`refs/tags/${tag}^{commit}`
|
|
1609
|
+
]);
|
|
1610
|
+
return true;
|
|
1611
|
+
} catch {
|
|
1612
|
+
return false;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
//#endregion
|
|
1616
|
+
//#region src/workflow/discovery/buildDependencyGraph.ts
|
|
1617
|
+
/**
|
|
1618
|
+
* Build a dependency graph mapping package names to dependent release plans.
|
|
1619
|
+
*
|
|
1620
|
+
* Each key is a package name and the value is a set of `ReleasePlan`s that
|
|
1621
|
+
* list that package as a dependency.
|
|
1622
|
+
*
|
|
1623
|
+
* @param plans Array of release plans to analyze
|
|
1624
|
+
* @returns Dependency graph mapping package name -> set of dependent plans
|
|
1625
|
+
*/
|
|
1626
|
+
function buildDependencyGraph(plans) {
|
|
1627
|
+
const graph = /* @__PURE__ */ new Map();
|
|
1628
|
+
const packageNames = new Set(plans.map((plan) => plan.package.name).filter((name) => !!name));
|
|
1629
|
+
for (const plan of plans) for (const depName of plan.package.dependencies) {
|
|
1630
|
+
let set = graph.get(depName);
|
|
1631
|
+
if (!set && packageNames.has(depName)) graph.set(depName, set = /* @__PURE__ */ new Set());
|
|
1632
|
+
if (set) set.add(plan);
|
|
1633
|
+
}
|
|
1634
|
+
return graph;
|
|
1635
|
+
}
|
|
1636
|
+
//#endregion
|
|
1637
|
+
//#region src/workflow/changelog/extractPlainText.ts
|
|
1638
|
+
/**
|
|
1639
|
+
* Extract trimmed plain text from a Heading, Paragraph, or PhrasingContent node.
|
|
1640
|
+
*
|
|
1641
|
+
* Performs a depth-first traversal, concatenating string `value` tokens in
|
|
1642
|
+
* document order. Non-string values are ignored. Returns `null` for empty or
|
|
1643
|
+
* missing input.
|
|
1644
|
+
*
|
|
1645
|
+
* @param node Node to extract text from
|
|
1646
|
+
* @returns The trimmed plain-text string, or null if empty
|
|
1647
|
+
*/
|
|
1648
|
+
function extractPlainText(node) {
|
|
1649
|
+
if (!node) return null;
|
|
1650
|
+
const queue = [node];
|
|
1651
|
+
let result = "";
|
|
1652
|
+
while (node = queue.shift()) {
|
|
1653
|
+
if ("value" in node && typeof node.value === "string") result += node.value;
|
|
1654
|
+
/* istanbul ignore else */
|
|
1655
|
+
if ("children" in node && Array.isArray(node.children)) queue.unshift(...node.children);
|
|
1656
|
+
}
|
|
1657
|
+
return result.trim() || null;
|
|
1658
|
+
}
|
|
1659
|
+
//#endregion
|
|
1660
|
+
//#region src/workflow/changelog/extractSemVer.ts
|
|
1661
|
+
var ANCHORED_SEMVER_REGEX = new RegExp(`^${SEMVER_REGEX.source}$`);
|
|
1662
|
+
var BRACKETED_SEMVER_REGEX = new RegExp(`\\[${SEMVER_REGEX.source}\\]`);
|
|
1663
|
+
/**
|
|
1664
|
+
* Extract SemVer core strings from a line.
|
|
1665
|
+
*
|
|
1666
|
+
* Splits the line on runs of spaces or tabs and on `..`/`...`. For each token,
|
|
1667
|
+
* first attempts an anchored SemVer match (must match the whole token), then
|
|
1668
|
+
* a bracketed match that finds a SemVer inside `[...]`. Returns the captured
|
|
1669
|
+
* SemVer core (capture group 2) for each successful match; falsy results are
|
|
1670
|
+
* filtered out.
|
|
1671
|
+
*
|
|
1672
|
+
* @param line Line to scan for SemVer cores
|
|
1673
|
+
* @returns Array of extracted SemVer core strings; empty if none
|
|
1674
|
+
*/
|
|
1675
|
+
function extractSemVer(line) {
|
|
1676
|
+
return compact(line.split(/[ \t]+|\.{2,3}/).map((token) => {
|
|
1677
|
+
return (ANCHORED_SEMVER_REGEX.exec(token) ?? BRACKETED_SEMVER_REGEX.exec(token))?.at(2);
|
|
1678
|
+
}));
|
|
1679
|
+
}
|
|
1680
|
+
//#endregion
|
|
1681
|
+
//#region src/workflow/changelog/iterateChangelogVersions.ts
|
|
1682
|
+
var SENTENCE_TERMINAL_REGEX = /\p{Sentence_Terminal}$/u;
|
|
1683
|
+
/**
|
|
1684
|
+
* Iterate over Semantic Version strings extracted from a Markdown changelog.
|
|
1685
|
+
*
|
|
1686
|
+
* Parses `changelogContent` into an AST and evaluates nodes sequentially using
|
|
1687
|
+
* custom extractors. Evaluates heading nodes first before checking short
|
|
1688
|
+
* paragraph nodes. Stops execution after the first strategy yields valid
|
|
1689
|
+
* versions.
|
|
1690
|
+
*
|
|
1691
|
+
* @param changelogContent Raw Markdown text of the changelog
|
|
1692
|
+
* @yields Valid SemVer strings found in headings or candidate paragraphs
|
|
1693
|
+
*/
|
|
1694
|
+
function* iterateChangelogVersions(changelogContent) {
|
|
1695
|
+
const root = fromMarkdown(changelogContent);
|
|
1696
|
+
const nodeExtractors = [(node) => {
|
|
1697
|
+
return node.type === "heading" ? extractPlainText(node) : null;
|
|
1698
|
+
}, (node) => {
|
|
1699
|
+
if (node.type !== "paragraph") return null;
|
|
1700
|
+
const paragraphText = extractPlainText(node);
|
|
1701
|
+
if (!paragraphText || paragraphText.length > 48 || SENTENCE_TERMINAL_REGEX.test(paragraphText)) return null;
|
|
1702
|
+
return paragraphText;
|
|
1703
|
+
}];
|
|
1704
|
+
for (const extractText of nodeExtractors) {
|
|
1705
|
+
let isVersionFound = false;
|
|
1706
|
+
for (const node of root.children) {
|
|
1707
|
+
const extractedText = extractText(node);
|
|
1708
|
+
const candidateVersion = extractedText ? semver.sort(extractSemVer(extractedText)).pop() : null;
|
|
1709
|
+
if (candidateVersion && semver.valid(candidateVersion)) {
|
|
1710
|
+
isVersionFound = true;
|
|
1711
|
+
yield candidateVersion;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
if (isVersionFound) return;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
//#endregion
|
|
1718
|
+
//#region src/workflow/changelog/extractLatestVersion.ts
|
|
1719
|
+
/**
|
|
1720
|
+
* Find the highest semantic version from the first version-bearing element.
|
|
1721
|
+
*
|
|
1722
|
+
* Pulls the first available version string from the parsed markdown iterator.
|
|
1723
|
+
* Short-circuits execution immediately upon locating a match to avoid passing
|
|
1724
|
+
* through the remaining document structure.
|
|
1725
|
+
*
|
|
1726
|
+
* @param changelogContent The raw markdown string representing the changelog
|
|
1727
|
+
* @returns The highest valid semantic version string found or null
|
|
1728
|
+
*/
|
|
1729
|
+
function extractLatestVersion(changelogContent) {
|
|
1730
|
+
for (const version of iterateChangelogVersions(changelogContent)) return version;
|
|
1731
|
+
return null;
|
|
1732
|
+
}
|
|
1733
|
+
//#endregion
|
|
1734
|
+
//#region src/workflow/discovery/verifyPackageTag.ts
|
|
1735
|
+
/**
|
|
1736
|
+
* Verify a Git tag against package specifications and history connection.
|
|
1737
|
+
*
|
|
1738
|
+
* Ensures the target tag exists locally before parsing its commit data. Rejects
|
|
1739
|
+
* tags that do not target a commit matching the given `pkg`. Expands the history
|
|
1740
|
+
* graph up to the tag reference before verifying structural connectivity.
|
|
1741
|
+
*
|
|
1742
|
+
* @param cwd Repository working directory
|
|
1743
|
+
* @param pkg Package descriptor containing version and location context
|
|
1744
|
+
* @param tagName The name of the Git tag to verify
|
|
1745
|
+
* @returns A promise resolving to true if the tag is valid and connected
|
|
1746
|
+
*/
|
|
1747
|
+
async function verifyPackageTag(cwd, pkg, tagName) {
|
|
1748
|
+
await ensureGitTag(cwd, tagName);
|
|
1749
|
+
const tagRef = `refs/tags/${tagName}`;
|
|
1750
|
+
const tagCommit = await getParsedCommit(cwd, tagRef);
|
|
1751
|
+
if (!tagCommit || !isCommitForPackage(pkg, tagCommit)) return false;
|
|
1752
|
+
await expandGitHistory(cwd, { until: tagRef });
|
|
1753
|
+
return isGitTagConnected(cwd, tagName);
|
|
1754
|
+
}
|
|
1755
|
+
//#endregion
|
|
1756
|
+
//#region src/workflow/discovery/discoverPackageTag.ts
|
|
1757
|
+
/**
|
|
1758
|
+
* Discover and validate the Git tag for a package.
|
|
1759
|
+
*
|
|
1760
|
+
* Returns the existing tag if already present on the `pkg` object. Resolves
|
|
1761
|
+
* the local changelog file path, extracts the current version, and generates
|
|
1762
|
+
* the expected Git tag name. Verifies the tag against the repository and
|
|
1763
|
+
* ensures its historical changelog version matches the current local version.
|
|
1764
|
+
*
|
|
1765
|
+
* @param cwd The current working directory
|
|
1766
|
+
* @param pkg The package configuration object
|
|
1767
|
+
* @returns The validated `Tag` object or null if validation fails
|
|
1768
|
+
*/
|
|
1769
|
+
async function discoverPackageTag(cwd, pkg) {
|
|
1770
|
+
if (pkg.tag) return pkg.tag;
|
|
1771
|
+
const changelogFile = pkg.changelogFile || "CHANGELOG.md";
|
|
1772
|
+
const changelogFilePath = path.resolve(cwd, pkg.dir, changelogFile);
|
|
1773
|
+
let localChangelogContent;
|
|
1774
|
+
try {
|
|
1775
|
+
localChangelogContent = await readFile(changelogFilePath, "utf-8");
|
|
1776
|
+
} catch {}
|
|
1777
|
+
if (!localChangelogContent) return null;
|
|
1778
|
+
const currentVersion = extractLatestVersion(localChangelogContent);
|
|
1779
|
+
if (!currentVersion) return null;
|
|
1780
|
+
let expectedTagName;
|
|
1781
|
+
try {
|
|
1782
|
+
expectedTagName = generateGitTagName(pkg.entry, pkg.name, currentVersion);
|
|
1783
|
+
} catch {}
|
|
1784
|
+
if (!expectedTagName) return null;
|
|
1785
|
+
if (!await verifyPackageTag(cwd, pkg, expectedTagName)) return null;
|
|
1786
|
+
const historicalChangelogContent = await readGitFile(cwd, await resolveGitRevision(cwd, `refs/tags/${expectedTagName}`), path.relative(cwd, changelogFilePath));
|
|
1787
|
+
if (!historicalChangelogContent) return null;
|
|
1788
|
+
const historicalChangelogVersion = extractLatestVersion(historicalChangelogContent);
|
|
1789
|
+
if (!historicalChangelogVersion || historicalChangelogVersion !== currentVersion) return null;
|
|
1790
|
+
return {
|
|
1791
|
+
name: expectedTagName,
|
|
1792
|
+
message: await getGitTagMessage(cwd, expectedTagName)
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
//#endregion
|
|
1796
|
+
//#region src/workflow/discovery/resolvePackageStableTag.ts
|
|
1797
|
+
/**
|
|
1798
|
+
* Resolve the latest stable Git tag for a package.
|
|
1799
|
+
*
|
|
1800
|
+
* Reads changelog release versions for `pkg` and verifies strict descending
|
|
1801
|
+
* semver order. Ignores prerelease versions unless transitioning across major
|
|
1802
|
+
* or prerelease boundaries, and verifies that the matching Git tag exists.
|
|
1803
|
+
*
|
|
1804
|
+
* @param cwd Absolute path to the root working directory
|
|
1805
|
+
* @param pkg Package configuration object
|
|
1806
|
+
* @returns Verified tag metadata object or `null` if no stable tag is resolved
|
|
1807
|
+
* @throws If a version is invalid, versions are not in strict descending
|
|
1808
|
+
* order, or the corresponding Git tag is missing
|
|
1809
|
+
*/
|
|
1810
|
+
async function resolvePackageStableTag(cwd, pkg) {
|
|
1811
|
+
const changelogFile = pkg.changelogFile || "CHANGELOG.md";
|
|
1812
|
+
const changelogFilePath = path.resolve(cwd, pkg.dir, changelogFile);
|
|
1813
|
+
let changelogContent;
|
|
1814
|
+
try {
|
|
1815
|
+
changelogContent = await readFile(changelogFilePath, "utf-8");
|
|
1816
|
+
} catch (error) {
|
|
1817
|
+
/* istanbul ignore else */
|
|
1818
|
+
if (error?.code === "ENOENT") return null;
|
|
1819
|
+
/* istanbul ignore next */
|
|
1820
|
+
throw error;
|
|
1821
|
+
}
|
|
1822
|
+
let precedingVersion = null;
|
|
1823
|
+
for (const version of iterateChangelogVersions(changelogContent)) {
|
|
1824
|
+
const currentVersion = semver.parse(version);
|
|
1825
|
+
assert(currentVersion, `Version "${version}" is not a valid semantic version.`);
|
|
1826
|
+
if (precedingVersion && semver.gte(currentVersion, precedingVersion)) throw new Error(`Version "${version}" must be strictly lower than preceding version "${precedingVersion.raw}".`);
|
|
1827
|
+
if (!semver.prerelease(currentVersion) && (currentVersion.major >= 1 || precedingVersion && semver.prerelease(precedingVersion))) {
|
|
1828
|
+
const tagName = generateGitTagName(pkg.entry, pkg.name, version);
|
|
1829
|
+
if (!await verifyPackageTag(cwd, pkg, tagName)) throw new Error(`Git tag "${tagName}" is not associated with package "${pkg.name ?? ""}" in directory "${pkg.dir}".`);
|
|
1830
|
+
return {
|
|
1831
|
+
name: tagName,
|
|
1832
|
+
message: await getGitTagMessage(cwd, tagName)
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
precedingVersion = currentVersion;
|
|
1836
|
+
}
|
|
1837
|
+
return null;
|
|
1838
|
+
}
|
|
1839
|
+
//#endregion
|
|
1840
|
+
//#region src/workflow/discovery/resolvePackageTag.ts
|
|
1841
|
+
/**
|
|
1842
|
+
* Resolve the release tag for a single package version.
|
|
1843
|
+
*
|
|
1844
|
+
* Returns the cached `pkg.tag` immediately if it already exists. Generates an
|
|
1845
|
+
* expected tag name from the package metadata and verifies its integrity using
|
|
1846
|
+
* `verifyPackageTag`.
|
|
1847
|
+
*
|
|
1848
|
+
* @param cwd Repository working directory
|
|
1849
|
+
* @param pkg Package descriptor containing version and location context
|
|
1850
|
+
* @returns A promise resolving to the tag metadata or null when not found
|
|
1851
|
+
*/
|
|
1852
|
+
async function resolvePackageTag(cwd, pkg) {
|
|
1853
|
+
if (pkg.tag) return pkg.tag;
|
|
1854
|
+
let expectedTagName;
|
|
1855
|
+
try {
|
|
1856
|
+
expectedTagName = generateGitTagName(pkg.entry, pkg.name, pkg.version);
|
|
1857
|
+
} catch {}
|
|
1858
|
+
if (!expectedTagName) return null;
|
|
1859
|
+
if (await verifyPackageTag(cwd, pkg, expectedTagName)) return {
|
|
1860
|
+
name: expectedTagName,
|
|
1861
|
+
message: await getGitTagMessage(cwd, expectedTagName)
|
|
1862
|
+
};
|
|
1863
|
+
return null;
|
|
1864
|
+
}
|
|
1865
|
+
//#endregion
|
|
1866
|
+
//#region src/workflow/analysis/propagateDependencyUpdates.ts
|
|
1867
|
+
/**
|
|
1868
|
+
* Propagate release updates through package dependencies.
|
|
1869
|
+
*
|
|
1870
|
+
* Marks packages that depend on a releasing package for a `patch` release
|
|
1871
|
+
* when they do not already have a release type.
|
|
1872
|
+
*
|
|
1873
|
+
* @param plans Array of release plans to update
|
|
1874
|
+
* @returns Updated array of release plans
|
|
1875
|
+
*/
|
|
1876
|
+
async function propagateDependencyUpdates(cwd, plans) {
|
|
1877
|
+
const graph = buildDependencyGraph(plans);
|
|
1878
|
+
const scheduled = /* @__PURE__ */ new Map();
|
|
1879
|
+
const stack = [];
|
|
1880
|
+
for (const plan of plans) if (plan.release?.type) {
|
|
1881
|
+
const visited = /* @__PURE__ */ new Set([plan]);
|
|
1882
|
+
stack.push(plan);
|
|
1883
|
+
let curr;
|
|
1884
|
+
while (curr = stack.pop()) if (curr.package.name) {
|
|
1885
|
+
for (const dependent of graph.get(curr.package.name) ?? []) if (!visited.has(dependent)) {
|
|
1886
|
+
visited.add(dependent);
|
|
1887
|
+
stack.push(dependent);
|
|
1888
|
+
scheduled.set(dependent, await mergeGitHistory(cwd, scheduled.get(dependent) ?? dependent.history, plan.history));
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
return plans.map((plan) => {
|
|
1893
|
+
const history = scheduled.get(plan);
|
|
1894
|
+
if (history) {
|
|
1895
|
+
plan = {
|
|
1896
|
+
...plan,
|
|
1897
|
+
history
|
|
1898
|
+
};
|
|
1899
|
+
if (!plan.release?.type) plan.release = { type: "patch" };
|
|
1900
|
+
}
|
|
1901
|
+
return plan;
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
//#endregion
|
|
1905
|
+
//#region src/workflow/committing/isReleaseCommit.ts
|
|
1906
|
+
/**
|
|
1907
|
+
* Determine whether a commit represents a release or matches a release marker.
|
|
1908
|
+
*
|
|
1909
|
+
* Resolves string commit identifiers to parsed commit objects, checks if any
|
|
1910
|
+
* associated trailer matches the marker predicate, and falls back to verifying
|
|
1911
|
+
* exact tag matches for the `commit` hash.
|
|
1912
|
+
*
|
|
1913
|
+
* @param cwd The working directory path for Git commands
|
|
1914
|
+
* @param commit The commit object, hash string, or null/undefined
|
|
1915
|
+
* @param marker The trailer key string or a predicate function to evaluate
|
|
1916
|
+
* @returns True if the commit is a release commit, otherwise false
|
|
1917
|
+
*/
|
|
1918
|
+
async function isReleaseCommit(cwd, commit, marker = GIT_TRAILER_RELEASE_AGENT) {
|
|
1919
|
+
if (typeof commit === "string") commit = await getParsedCommit(cwd, commit, typeof marker === "string" ? [marker] : []);
|
|
1920
|
+
if (!commit) return false;
|
|
1921
|
+
const predicate = typeof marker === "string" ? (trailer) => trailer.key === marker && !!trailer.value : marker;
|
|
1922
|
+
if (commit.trailers.some(predicate)) return true;
|
|
1923
|
+
return execGit(cwd, [
|
|
1924
|
+
"describe",
|
|
1925
|
+
"--tags",
|
|
1926
|
+
"--exact-match",
|
|
1927
|
+
commit.hash
|
|
1928
|
+
]).then(() => true).catch(() => false);
|
|
1929
|
+
}
|
|
1930
|
+
//#endregion
|
|
1931
|
+
//#region src/workflow/analysis/sliceUnreleased.ts
|
|
1932
|
+
/**
|
|
1933
|
+
* Yield parsed commits from the workspace until a release commit is found.
|
|
1934
|
+
*
|
|
1935
|
+
* Iterates through parsed commit history and yields unreleased commits in
|
|
1936
|
+
* order. Stops processing upon reaching the first release commit.
|
|
1937
|
+
*
|
|
1938
|
+
* @param cwd Path to the working directory
|
|
1939
|
+
* @param options Options for configuring Git history retrieval
|
|
1940
|
+
* @yields Parsed `Commit` objects up to the most recent release commit
|
|
1941
|
+
*/
|
|
1942
|
+
async function* sliceUnreleased(cwd, options) {
|
|
1943
|
+
for await (const commit of showParsedGitHistory(cwd, options)) {
|
|
1944
|
+
if (await isReleaseCommit(cwd, commit, options?.marker)) break;
|
|
1945
|
+
yield commit;
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
//#endregion
|
|
1949
|
+
//#region src/workflow/analysis/streamPackageCommits.ts
|
|
1950
|
+
/**
|
|
1951
|
+
* Yield commits from git history associated with a package.
|
|
1952
|
+
*
|
|
1953
|
+
* Filters git commit history to stream only entries relevant to the target
|
|
1954
|
+
* `pkg` directory.
|
|
1955
|
+
*
|
|
1956
|
+
* @param cwd Path to the working directory for git commands
|
|
1957
|
+
* @param pkg Package configuration containing the target directory
|
|
1958
|
+
* @param options Options for parsing and filtering git history
|
|
1959
|
+
* @yields Commits belonging to the specified package
|
|
1960
|
+
*/
|
|
1961
|
+
async function* streamPackageCommits(cwd, pkg, options = {}) {
|
|
1962
|
+
options = {
|
|
1963
|
+
...options,
|
|
1964
|
+
filePath: options.filePath || pkg.dir
|
|
1965
|
+
};
|
|
1966
|
+
for await (const commit of showParsedGitHistory(cwd, options)) if (isCommitForPackage(pkg, commit)) yield commit;
|
|
1967
|
+
}
|
|
1968
|
+
//#endregion
|
|
1969
|
+
//#region src/workflow/changelog/buildChangelog.ts
|
|
1970
|
+
var preset = await createPreset();
|
|
1971
|
+
var headerPartial = (context) => {
|
|
1972
|
+
return context.version ? preset.writer.headerPartial?.(context) : void 0;
|
|
1973
|
+
};
|
|
1974
|
+
/**
|
|
1975
|
+
* Build the changelog string for a release.
|
|
1976
|
+
*
|
|
1977
|
+
* Compiles commit history and release context into a formatted changelog using
|
|
1978
|
+
* conventional commits and writer configurations. Resolves file paths and handles
|
|
1979
|
+
* tag comparisons when repository URLs are available.
|
|
1980
|
+
*
|
|
1981
|
+
* @param context The release context containing repository and working directory details
|
|
1982
|
+
* @param history The iterable or async iterable collection of commits to process
|
|
1983
|
+
* @param options The build configuration options for the changelog
|
|
1984
|
+
* @returns The formatted changelog content string
|
|
1985
|
+
*/
|
|
1986
|
+
async function buildChangelog(context, history, options = {}) {
|
|
1987
|
+
const repository = context.repository?.url || void 0;
|
|
1988
|
+
const version = options.version || void 0;
|
|
1989
|
+
const previousTag = options.previousTag || void 0;
|
|
1990
|
+
const currentTag = options.currentTag || void 0;
|
|
1991
|
+
return formatFile(path.join(context.cwd, options.packageDir || ".", options.changelogFile || "CHANGELOG.md"), await writeChangelogString(history, {
|
|
1992
|
+
date: context.today,
|
|
1993
|
+
version,
|
|
1994
|
+
previousTag,
|
|
1995
|
+
currentTag,
|
|
1996
|
+
linkCompare: !!repository && !!previousTag && !!currentTag,
|
|
1997
|
+
linkReferences: !!repository,
|
|
1998
|
+
repoUrl: repository,
|
|
1999
|
+
commit: "commit",
|
|
2000
|
+
issue: "issues",
|
|
2001
|
+
headerPartial
|
|
2002
|
+
}, preset.writer));
|
|
2003
|
+
}
|
|
2004
|
+
//#endregion
|
|
2005
|
+
//#region src/workflow/changelog/buildReleaseNotes.ts
|
|
2006
|
+
/**
|
|
2007
|
+
* Build release notes from a release context and commit history.
|
|
2008
|
+
*
|
|
2009
|
+
* Generates changelog content from commits, parses it into an abstract syntax
|
|
2010
|
+
* tree, shifts heading depths, and formats the resulting markdown file.
|
|
2011
|
+
*
|
|
2012
|
+
* @param context The release context containing repository and configuration details
|
|
2013
|
+
* @param history The iterable or async iterable collection of commits to process
|
|
2014
|
+
* @returns The formatted release notes as a markdown string
|
|
2015
|
+
*/
|
|
2016
|
+
async function buildReleaseNotes(context, history) {
|
|
2017
|
+
const changelogContent = await buildChangelog(context, history);
|
|
2018
|
+
const root = fromMarkdown(changelogContent);
|
|
2019
|
+
root.children = root.children.flatMap((node) => {
|
|
2020
|
+
if (node.type === "heading") {
|
|
2021
|
+
/* istanbul ignore next */
|
|
2022
|
+
if (node.depth <= 2) return [];
|
|
2023
|
+
node = {
|
|
2024
|
+
...node,
|
|
2025
|
+
depth: node.depth - 1
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
return node;
|
|
2029
|
+
});
|
|
2030
|
+
return formatFile(path.join(context.cwd, DEFAULT_CHANGELOG_FILE), toMarkdown(root));
|
|
2031
|
+
}
|
|
2032
|
+
//#endregion
|
|
2033
|
+
//#region src/workflow/changelog/extractPreviousVersion.ts
|
|
2034
|
+
/**
|
|
2035
|
+
* Find the latest stable version that precedes the current version.
|
|
2036
|
+
*
|
|
2037
|
+
* Parses markdown from top to bottom to extract semantic versions from headings
|
|
2038
|
+
* and paragraphs. Enforces that discovered versions appear in strict
|
|
2039
|
+
* descending order. Returns the first version that falls outside the current
|
|
2040
|
+
* version's major, minor, and patch boundaries if the current version is
|
|
2041
|
+
* `v1.0.0` or higher, or the first non-matching version for `v0.x.x` releases.
|
|
2042
|
+
*
|
|
2043
|
+
* @param currentVersion The version to evaluate against
|
|
2044
|
+
* @param changelogContent The raw markdown content to scan for versions
|
|
2045
|
+
* @returns The resolved stable version string or null when none is found
|
|
2046
|
+
* @throws If `currentVersion` is invalid or already stable, or if versions in the changelog are not listed in strict descending order
|
|
2047
|
+
*/
|
|
2048
|
+
async function extractPreviousVersion(currentVersion, changelogContent) {
|
|
2049
|
+
const parsedCurrent = semver.parse(currentVersion);
|
|
2050
|
+
if (!currentVersion || !parsedCurrent) throw new Error(`The current version "${colors.bold(String(currentVersion))}" is not a valid semantic version.`);
|
|
2051
|
+
const currentMajor = parsedCurrent.major;
|
|
2052
|
+
if (currentMajor >= 1 && parsedCurrent.prerelease.length === 0) throw new Error(`The current version "${colors.bold(currentVersion)}" is already a stable release.`);
|
|
2053
|
+
const baseVersion = `${parsedCurrent.major}.${parsedCurrent.minor}.${parsedCurrent.patch}`;
|
|
2054
|
+
let priorVersion = null;
|
|
2055
|
+
for (const version of iterateChangelogVersions(changelogContent)) {
|
|
2056
|
+
const parsedVersion = semver.parse(version);
|
|
2057
|
+
/* istanbul ignore else */
|
|
2058
|
+
if (parsedVersion) if (!priorVersion) {
|
|
2059
|
+
if (version !== currentVersion) return version;
|
|
2060
|
+
priorVersion = parsedVersion;
|
|
2061
|
+
} else {
|
|
2062
|
+
if (semver.gte(parsedVersion, priorVersion)) throw new Error(`Each newer release must have a higher version, but "${colors.bold(priorVersion.version)}" appears before "${colors.bold(version)}".`);
|
|
2063
|
+
priorVersion = parsedVersion;
|
|
2064
|
+
const { major, minor, patch } = parsedVersion;
|
|
2065
|
+
if (currentMajor >= 1 && `${major}.${minor}.${patch}` !== baseVersion) return version;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
return null;
|
|
2069
|
+
}
|
|
2070
|
+
//#endregion
|
|
2071
|
+
//#region src/workflow/changelog/prependChangelog.ts
|
|
2072
|
+
var defaultChangelogTitle = "Changelog";
|
|
2073
|
+
/**
|
|
2074
|
+
* Prepend changelog content to a markdown file, creating it if necessary.
|
|
2075
|
+
*
|
|
2076
|
+
* Reads the existing file if present, prepends the new content before the main heading,
|
|
2077
|
+
* formats and writes the result back.
|
|
2078
|
+
*
|
|
2079
|
+
* @param filePath Path to the changelog file
|
|
2080
|
+
* @param revision Changelog content to prepend
|
|
2081
|
+
* @returns Promise that resolves when the changelog is written
|
|
2082
|
+
*/
|
|
2083
|
+
async function prependChangelog(filePath, revision) {
|
|
2084
|
+
let content = "";
|
|
2085
|
+
try {
|
|
2086
|
+
content = await fs.readFile(filePath, "utf-8");
|
|
2087
|
+
} catch (err) {
|
|
2088
|
+
/* istanbul ignore next */
|
|
2089
|
+
if (err.code !== "ENOENT") throw err;
|
|
2090
|
+
}
|
|
2091
|
+
const root = fromMarkdown(content);
|
|
2092
|
+
let index = root.children.findIndex((node) => node.type === "heading" && node.depth === 1);
|
|
2093
|
+
if (index === -1) {
|
|
2094
|
+
index = 0;
|
|
2095
|
+
root.children.unshift({
|
|
2096
|
+
type: "heading",
|
|
2097
|
+
depth: 1,
|
|
2098
|
+
children: [{
|
|
2099
|
+
type: "text",
|
|
2100
|
+
value: defaultChangelogTitle
|
|
2101
|
+
}]
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
root.children.splice(index + 1, 0, ...fromMarkdown(revision).children);
|
|
2105
|
+
content = toMarkdown(root);
|
|
2106
|
+
content = await formatFile(filePath, content);
|
|
2107
|
+
await fs.writeFile(filePath, content);
|
|
2108
|
+
}
|
|
2109
|
+
//#endregion
|
|
2110
|
+
//#region src/workflow/changelog/writeChangelog.ts
|
|
2111
|
+
/**
|
|
2112
|
+
* Write changelog content to the file system based on the release plan.
|
|
2113
|
+
*
|
|
2114
|
+
* @param cwd Repository working directory
|
|
2115
|
+
* @param plan Release plan containing package and changelog content
|
|
2116
|
+
* @returns Promise that resolves when the changelog file has been written
|
|
2117
|
+
*/
|
|
2118
|
+
async function writeChangelog(cwd, plan) {
|
|
2119
|
+
if (plan.release?.changelog) {
|
|
2120
|
+
const changelogFile = plan.package.changelogFile || "CHANGELOG.md";
|
|
2121
|
+
await prependChangelog(path.join(cwd, plan.package.dir, changelogFile), plan.release.changelog);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
//#endregion
|
|
2125
|
+
//#region package.json
|
|
2126
|
+
var version = "0.1.1";
|
|
2127
|
+
//#endregion
|
|
2128
|
+
//#region src/workflow/committing/generateReleaseCommitMessage.ts
|
|
2129
|
+
/**
|
|
2130
|
+
* Generate a formatted release commit message based on release plans and
|
|
2131
|
+
* custom trailers.
|
|
2132
|
+
*
|
|
2133
|
+
* Compiles package version updates and included commit logs into a standardized
|
|
2134
|
+
* conventional commit message body, appending configured release agent and
|
|
2135
|
+
* custom trailers. Returns null if no valid release subject can be derived.
|
|
2136
|
+
*
|
|
2137
|
+
* @param plans Readonly array of release plans containing package changes and commit history
|
|
2138
|
+
* @param trailers Optional record of custom git trailers to append to the commit
|
|
2139
|
+
* @returns The formatted release commit message string or null if the subject is empty
|
|
2140
|
+
*/
|
|
2141
|
+
async function generateReleaseCommitMessage(plans, trailers = {}) {
|
|
2142
|
+
const standalone = plans.length === 1;
|
|
2143
|
+
trailers = {
|
|
2144
|
+
[GIT_TRAILER_RELEASE_AGENT]: `ReelOut/${/* istanbul ignore next */ version}`,
|
|
2145
|
+
...trailers
|
|
2146
|
+
};
|
|
2147
|
+
plans = plans.filter((plan) => plan.release?.tag?.name).toSorted((a, b) => {
|
|
2148
|
+
if (a.package.entry) return -1;
|
|
2149
|
+
if (b.package.entry) return 1;
|
|
2150
|
+
/* istanbul ignore next */
|
|
2151
|
+
return (a.package.name ?? "").localeCompare(b.package.name ?? "");
|
|
2152
|
+
});
|
|
2153
|
+
let subject;
|
|
2154
|
+
if (plans.length === 1) subject = standalone ? plans[0]?.release?.version : plans[0]?.release?.tag?.name;
|
|
2155
|
+
else if (plans.length >= 2 && plans.length <= 3) subject = plans.map((plan) => plan.release?.tag?.name).join(", ");
|
|
2156
|
+
else if (plans.length > 3) subject = `${plans.length} packages (${plans.slice(0, 2).map((plan) => plan.release?.tag?.name).join(", ")}, +${plans.length - 2} more)`;
|
|
2157
|
+
if (!subject) return null;
|
|
2158
|
+
const packages = plans.map((plan) => {
|
|
2159
|
+
return `- ${plan.package.name || plan.package.dir}: ${plan.package.version} → ${plan.release?.version}`;
|
|
2160
|
+
});
|
|
2161
|
+
const commits = Array.from(new Map(plans.flatMap((plan) => plan.history).map((commit) => [commit.hash, commit])).values()).toSorted((a, b) => {
|
|
2162
|
+
return new Date(a.committerDate).valueOf() - new Date(b.committerDate).valueOf();
|
|
2163
|
+
}).map(({ header }) => `- ${header}`);
|
|
2164
|
+
return [
|
|
2165
|
+
`chore: release ${subject}`,
|
|
2166
|
+
packages.length >= 2 ? ["## Version Updates", ...packages].join("\n") : void 0,
|
|
2167
|
+
commits.length > 0 ? ["## Changes Included", ...commits].join("\n") : void 0,
|
|
2168
|
+
Object.entries(trailers).map(([key, value]) => key && value != null ? `${key}: ${value}` : "").filter(Boolean).join("\n")
|
|
2169
|
+
].filter(Boolean).join("\n\n");
|
|
2170
|
+
}
|
|
2171
|
+
//#endregion
|
|
2172
|
+
//#region src/workflow/report/generateReleaseReport.ts
|
|
2173
|
+
/**
|
|
2174
|
+
* Generate a release report from the current workflow state.
|
|
2175
|
+
*
|
|
2176
|
+
* Extracts relevant information from the context and organizes it into a report
|
|
2177
|
+
* containing workflow phase, status, plugin metadata, packages, and release plans.
|
|
2178
|
+
*
|
|
2179
|
+
* @param context Release context with workflow state and plans
|
|
2180
|
+
* @param plugin Name of the current plugin executing
|
|
2181
|
+
* @param hook Current workflow phase
|
|
2182
|
+
* @param status Workflow completion status (`'success'` or `'failure'`)
|
|
2183
|
+
* @param reason Error or failure reason (used only when status is `'failure'`)
|
|
2184
|
+
* @returns Release report object with structured workflow results
|
|
2185
|
+
*/
|
|
2186
|
+
function generateReleaseReport(context, plugin, hook, status, reason) {
|
|
2187
|
+
return {
|
|
2188
|
+
phase: plugin && hook ? {
|
|
2189
|
+
plugin,
|
|
2190
|
+
hook
|
|
2191
|
+
} : void 0,
|
|
2192
|
+
status,
|
|
2193
|
+
reason: Error.isError(reason) ? reason.message : typeof reason === "string" ? reason : null,
|
|
2194
|
+
prereleaseLabel: context.prereleaseLabel,
|
|
2195
|
+
releaseChannel: context.releaseChannel,
|
|
2196
|
+
plugins: context.plugins.map((plugin) => ({
|
|
2197
|
+
name: plugin.name,
|
|
2198
|
+
version: plugin.version,
|
|
2199
|
+
hooks: Object.keys(plugin).filter((key) => key !== "name" && key !== "version")
|
|
2200
|
+
})),
|
|
2201
|
+
packages: context.packages.map((pkg) => ({
|
|
2202
|
+
dir: pkg.dir,
|
|
2203
|
+
name: pkg.name,
|
|
2204
|
+
version: pkg.version
|
|
2205
|
+
})),
|
|
2206
|
+
plans: compact(context.plans.map((plan) => {
|
|
2207
|
+
if (!plan.release?.version) return null;
|
|
2208
|
+
return {
|
|
2209
|
+
package: {
|
|
2210
|
+
entry: plan.package.entry,
|
|
2211
|
+
dir: plan.package.dir,
|
|
2212
|
+
name: plan.package.name,
|
|
2213
|
+
version: plan.package.version,
|
|
2214
|
+
private: !!plan.package.private
|
|
2215
|
+
},
|
|
2216
|
+
release: {
|
|
2217
|
+
type: plan.release.type,
|
|
2218
|
+
version: plan.release.version,
|
|
2219
|
+
tag: plan.release.tag?.name,
|
|
2220
|
+
changelog: plan.release.changelog
|
|
2221
|
+
}
|
|
2222
|
+
};
|
|
2223
|
+
}))
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
//#endregion
|
|
2227
|
+
//#region src/workflow/report/printReleaseReport.ts
|
|
2228
|
+
/**
|
|
2229
|
+
* Print a formatted release report to the logger.
|
|
2230
|
+
*
|
|
2231
|
+
* Logs the workflow status and details about released packages. On success,
|
|
2232
|
+
* displays released package names and version changes. On failure, displays
|
|
2233
|
+
* the error reason.
|
|
2234
|
+
*
|
|
2235
|
+
* @param context Release context for logging
|
|
2236
|
+
* @param report Release report to display
|
|
2237
|
+
* @returns void
|
|
2238
|
+
*/
|
|
2239
|
+
function printReleaseReport(context, report) {
|
|
2240
|
+
const statusLabel = {
|
|
2241
|
+
success: colors.green("SUCCESS"),
|
|
2242
|
+
failure: colors.red("FAILURE")
|
|
2243
|
+
}[report.status];
|
|
2244
|
+
context.logger.info(`Status: ${statusLabel}`);
|
|
2245
|
+
if (report.status === "failure") return context.logger.error(["Release complete:", colors.red(report.reason || "An unexpected error occurred during the release process.")].join(" "));
|
|
2246
|
+
if (report.plans.length === 0) return context.logger.info(["Release complete:", colors.yellow("No packages are scheduled for release.")].join(" "));
|
|
2247
|
+
const header = [
|
|
2248
|
+
"Release complete:",
|
|
2249
|
+
colors.bold(colors.green(`${report.plans.length} released`)),
|
|
2250
|
+
colors.gray(`(${report.packages.length})`)
|
|
2251
|
+
].join(" ");
|
|
2252
|
+
const list = report.plans.toSorted((a, b) => (a.package.name || "").localeCompare(b.package.name || "")).map((plan) => {
|
|
2253
|
+
let dir = path.normalize(path.join(plan.package.dir, "."));
|
|
2254
|
+
dir = dir === "." ? dir : `./${dir}`;
|
|
2255
|
+
const name = plan.package.name ? colors.bgBlue(` ${plan.package.name} `) : !plan.package.entry && colors.gray(` ${dir} `);
|
|
2256
|
+
let version = [plan.release.version, plan.package.version].filter(Boolean).join(" ← ");
|
|
2257
|
+
version = ` ${version} `;
|
|
2258
|
+
const type = plan.release.type && colors.gray(` ${plan.release.type} `);
|
|
2259
|
+
return [[
|
|
2260
|
+
name,
|
|
2261
|
+
plan.release.type === "major" ? colors.bgRed(version) : colors.bgGreen(version),
|
|
2262
|
+
type
|
|
2263
|
+
].filter(Boolean).join(""), plan.release.changelog?.replace(/^(#+ .+)$/gm, (_, header) => colors.cyan(header)).trim()].filter(Boolean).join("\n\n");
|
|
2264
|
+
}).join("\n\n");
|
|
2265
|
+
context.logger.info(`${header}\n\n${list}\n`);
|
|
2266
|
+
}
|
|
2267
|
+
//#endregion
|
|
2268
|
+
//#region src/workflow/runner/runWorkflow.ts
|
|
2269
|
+
/**
|
|
2270
|
+
* Execute the plugin-driven release workflow using the provided `ReleaseContext`.
|
|
2271
|
+
*
|
|
2272
|
+
* The workflow runs plugin stages in order: `configure`, `discover`,
|
|
2273
|
+
* `analyze`, `build`, `write`, `commit`, and `publish`. After a successful
|
|
2274
|
+
* run the function sets `context.status` to `'success'`. The workflow is
|
|
2275
|
+
* finalized in all cases; on success a release report is printed.
|
|
2276
|
+
*
|
|
2277
|
+
* The returned array contains all computed release plans, including plans
|
|
2278
|
+
* without releases. Side effects (file writes, commits, tags, publishing)
|
|
2279
|
+
* are performed by plugins and may be skipped when `context.dryRun` is true.
|
|
2280
|
+
*
|
|
2281
|
+
* @returns Promise that resolves when the workflow is complete.
|
|
2282
|
+
*/
|
|
2283
|
+
async function runWorkflow(createContext) {
|
|
2284
|
+
const context = await createContext();
|
|
2285
|
+
const stack = [];
|
|
2286
|
+
let plugin;
|
|
2287
|
+
let hook;
|
|
2288
|
+
let status = "success";
|
|
2289
|
+
let reason = null;
|
|
2290
|
+
try {
|
|
2291
|
+
const ordered = RELEASE_LIFECYCLE_HOOK_LIST.flatMap((hookName) => {
|
|
2292
|
+
return compact(context.plugins.map((plugin) => {
|
|
2293
|
+
let hook = plugin[hookName];
|
|
2294
|
+
if (!hook) return void 0;
|
|
2295
|
+
hook = typeof hook === "function" ? { handler: hook } : hook;
|
|
2296
|
+
const { enforce, handler } = hook;
|
|
2297
|
+
return {
|
|
2298
|
+
plugin,
|
|
2299
|
+
hook: hookName,
|
|
2300
|
+
handler,
|
|
2301
|
+
weight: enforce === "pre" ? -1 : enforce === "post" ? 1 : 0
|
|
2302
|
+
};
|
|
2303
|
+
})).sort((a, b) => a.weight - b.weight);
|
|
2304
|
+
});
|
|
2305
|
+
let handler;
|
|
2306
|
+
for ({plugin, hook, handler} of ordered) {
|
|
2307
|
+
const label = `${colors.gray(`[${hook}]`)} ${plugin.name}`;
|
|
2308
|
+
let startedLogging = false;
|
|
2309
|
+
let timer;
|
|
2310
|
+
timer = setTimeout(() => {
|
|
2311
|
+
startedLogging = true;
|
|
2312
|
+
context.logger.start(label);
|
|
2313
|
+
}, 10);
|
|
2314
|
+
const startTime = performance.now();
|
|
2315
|
+
try {
|
|
2316
|
+
const rollback = await handler(context);
|
|
2317
|
+
if (typeof rollback === "function") stack.push(rollback);
|
|
2318
|
+
} finally {
|
|
2319
|
+
clearTimeout(timer);
|
|
2320
|
+
}
|
|
2321
|
+
const duration = (performance.now() - startTime) / 1e3;
|
|
2322
|
+
/* istanbul ignore next */
|
|
2323
|
+
if (startedLogging) {
|
|
2324
|
+
const time = (duration >= 5 ? colors.red : colors.cyan)(`${duration.toFixed(2)}s`);
|
|
2325
|
+
context.logger.success(`${label} ${time}`);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
} catch (err) {
|
|
2329
|
+
status = "failure";
|
|
2330
|
+
reason = err;
|
|
2331
|
+
for (const rollback of stack.toReversed()) try {
|
|
2332
|
+
await rollback(reason);
|
|
2333
|
+
} catch {}
|
|
2334
|
+
throw err;
|
|
2335
|
+
} finally {
|
|
2336
|
+
printReleaseReport(context, generateReleaseReport(context, plugin?.name, hook, status, reason));
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
//#endregion
|
|
2340
|
+
export { GIT_PRETTY_FORMAT as $, ensureGitTag as A, deriveReleaseType as At, showParsedGitHistory as B, buildDependencyGraph as C, isSemVerGitTag as Ct, getGitTagMessage as D, RELEASE_LIFECYCLE_HOOK_LIST as Dt, getLatestGitTag as E, GIT_TRAILER_RELEASE_AGENT as Et, resolveGitRepository as F, detectGitRelationship as G, restoreFullGitHistory as H, pushGitChanges as I, withShallowLock as J, expandGitHistory as K, getGitRemote as L, deleteGitTag as M, determineReleaseType as Mt, createGitTag as N, bumpVersion as Nt, getCurrentGitTag as O, SEMVER_REGEX as Ot, readGitFile as P, GIT_PRETTY_FIELD_SEPARATOR as Q, ensureGitUserConfig as R, extractPlainText as S, buildTrailerPattern as St, isGitTagConnected as T, DEFAULT_CHANGELOG_FILE as Tt, mergeGitHistory as U, showGitHistory as V, GIT_REL_FLAGS as W, countGitHistory as X, isGitShallowRepository as Y, getParsedCommit as Z, discoverPackageTag as _, formatFile as _t, version as a, extractMentions as at, iterateChangelogVersions as b, compact as bt, extractPreviousVersion as c, promotePrereleaseVersions as ct, streamPackageCommits as d, resolveReleaseTarget as dt, parseGitPrettyFormat as et, sliceUnreleased as f, sanitizePrereleaseLabel as ft, resolvePackageStableTag as g, interpolate as gt, resolvePackageTag as h, isMatch as ht, generateReleaseCommitMessage as i, resolveGitRevision as it, isGitTagPresent as j, pickPriorityReleaseType as jt, generateGitTagName as k, isStableVersion as kt, buildReleaseNotes as l, analyzePackageChanges as lt, propagateDependencyUpdates as m, readJson as mt, printReleaseReport as n, TTLCache as nt, writeChangelog as o, createGitCommit as ot, isReleaseCommit as p, writeJson as pt, isGitAncestor as q, generateReleaseReport as r, getCurrentGitCommit as rt, prependChangelog as s, getCurrentGitBranch as st, runWorkflow as t, parseCommit as tt, buildChangelog as u, isCommitForPackage as ut, verifyPackageTag as v, execGit as vt, isGitTagPointed as w, ALL_FILES as wt, extractSemVer as x, isDefined as xt, extractLatestVersion as y, execCommand as yt, getGitConfigValue as z };
|
|
2341
|
+
|
|
2342
|
+
//# sourceMappingURL=workflow-BoVmbvH6.js.map
|