@ucdjs/release-scripts 0.1.0-beta.12 → 0.1.0-beta.13

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/index.mjs CHANGED
@@ -1,44 +1,66 @@
1
1
  import { t as Eta } from "./eta-Boh7yPZi.mjs";
2
- import farver from "farver";
3
- import { existsSync } from "node:fs";
4
- import { readFile, writeFile } from "node:fs/promises";
5
- import { join } from "node:path";
6
2
  import process from "node:process";
3
+ import farver from "farver";
4
+ import mri from "mri";
7
5
  import { exec } from "tinyexec";
8
- import { getCommits } from "commit-parser";
9
6
  import { dedent } from "@luxass/utils";
7
+ import { join } from "node:path";
8
+ import { readFile, writeFile } from "node:fs/promises";
9
+ import { getCommits } from "commit-parser";
10
10
  import prompts from "prompts";
11
11
 
12
12
  //#region src/publish.ts
13
13
  function publish(_options) {}
14
14
 
15
15
  //#endregion
16
- //#region src/utils.ts
17
- const globalOptions = {
18
- dryRun: false,
19
- verbose: false
20
- };
16
+ //#region src/shared/utils.ts
17
+ const args = mri(process.argv.slice(2));
18
+ const isDryRun = !!args.dry;
19
+ const isVerbose = !!args.verbose;
20
+ const isForce = !!args.force;
21
21
  const isCI = typeof process.env.CI === "string" && process.env.CI !== "" && process.env.CI.toLowerCase() !== "false";
22
22
  const logger = {
23
- info: (...args) => {
24
- console.info(farver.cyan("[info]:"), ...args);
23
+ info: (...args$1) => {
24
+ console.info(...args$1);
25
+ },
26
+ warn: (...args$1) => {
27
+ console.warn(` ${farver.yellow("⚠")}`, ...args$1);
28
+ },
29
+ error: (...args$1) => {
30
+ console.error(` ${farver.red("✖")}`, ...args$1);
31
+ },
32
+ verbose: (...args$1) => {
33
+ if (!isVerbose) return;
34
+ if (args$1.length === 0) {
35
+ console.log();
36
+ return;
37
+ }
38
+ if (args$1.length > 1 && typeof args$1[0] === "string") {
39
+ console.log(farver.dim(args$1[0]), ...args$1.slice(1));
40
+ return;
41
+ }
42
+ console.log(...args$1);
25
43
  },
26
- debug: (...args) => {
27
- console.debug(farver.gray("[debug]:"), ...args);
44
+ section: (title) => {
45
+ console.log();
46
+ console.log(` ${farver.bold(title)}`);
47
+ console.log(` ${farver.gray("─".repeat(title.length + 2))}`);
28
48
  },
29
- warn: (...args) => {
30
- console.warn(farver.yellow("[warn]:"), ...args);
49
+ emptyLine: () => {
50
+ console.log();
31
51
  },
32
- error: (...args) => {
33
- console.error(farver.red("[error]:"), ...args);
52
+ item: (message) => {
53
+ console.log(` ${message}`);
34
54
  },
35
- log: (...args) => {
36
- if (!globalOptions.verbose) return;
37
- console.log(...args);
55
+ step: (message) => {
56
+ console.log(` ${farver.blue("→")} ${message}`);
57
+ },
58
+ success: (message) => {
59
+ console.log(` ${farver.green("✓")} ${message}`);
38
60
  }
39
61
  };
40
- async function run(bin, args, opts = {}) {
41
- return exec(bin, args, {
62
+ async function run(bin, args$1, opts = {}) {
63
+ return exec(bin, args$1, {
42
64
  throwOnError: true,
43
65
  ...opts,
44
66
  nodeOptions: {
@@ -47,21 +69,20 @@ async function run(bin, args, opts = {}) {
47
69
  }
48
70
  });
49
71
  }
50
- async function dryRun(bin, args, opts) {
51
- return logger.log(farver.blue(`[dryrun] ${bin} ${args.join(" ")}`), opts || "");
72
+ async function dryRun(bin, args$1, opts) {
73
+ return logger.verbose(farver.blue(`[dryrun] ${bin} ${args$1.join(" ")}`), opts || "");
52
74
  }
53
- const runIfNotDry = globalOptions.dryRun ? dryRun : run;
75
+ const runIfNotDry = isDryRun ? dryRun : run;
54
76
  function exitWithError(message, hint) {
55
77
  logger.error(farver.bold(message));
56
78
  if (hint) console.error(farver.gray(` ${hint}`));
57
79
  process.exit(1);
58
80
  }
59
81
  function normalizeSharedOptions(options) {
60
- const { workspaceRoot = process.cwd(), githubToken = "", verbose = false, repo: fullRepo, packages = true, prompts: prompts$1 = {
82
+ const { workspaceRoot = process.cwd(), githubToken = "", repo: fullRepo, packages = true, prompts: prompts$1 = {
61
83
  packages: true,
62
84
  versions: true
63
85
  },...rest } = options;
64
- globalOptions.verbose = verbose;
65
86
  if (!githubToken.trim()) exitWithError("GitHub token is required", "Set GITHUB_TOKEN environment variable or pass it in options");
66
87
  if (!fullRepo || !fullRepo.trim() || !fullRepo.includes("/")) exitWithError("Repository (repo) is required", "Specify the repository in 'owner/repo' format (e.g., 'octocat/hello-world')");
67
88
  const [owner, repo] = fullRepo.split("/");
@@ -69,259 +90,28 @@ function normalizeSharedOptions(options) {
69
90
  return {
70
91
  ...rest,
71
92
  packages,
72
- prompts: prompts$1,
93
+ prompts: {
94
+ packages: prompts$1?.packages ?? true,
95
+ versions: prompts$1?.versions ?? true
96
+ },
73
97
  workspaceRoot,
74
98
  githubToken,
75
99
  owner,
76
- repo,
77
- verbose
100
+ repo
78
101
  };
79
102
  }
80
-
81
- //#endregion
82
- //#region src/changelog.ts
83
- /**
84
- * Get section label for commit type
85
- */
86
- function getSectionLabel(type) {
87
- return {
88
- feat: "Features",
89
- fix: "Bug Fixes",
90
- docs: "Documentation",
91
- style: "Styles",
92
- refactor: "Code Refactoring",
93
- perf: "Performance Improvements",
94
- test: "Tests",
95
- build: "Build System",
96
- ci: "Continuous Integration",
97
- chore: "Miscellaneous Chores",
98
- revert: "Reverts"
99
- }[type] || "Other Changes";
100
- }
101
- /**
102
- * Generate changelog content from commits
103
- */
104
- function generateChangelog(pkg, newVersion, commits, previousVersion, repository) {
105
- const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
106
- let versionHeader = `## `;
107
- if (repository && previousVersion) {
108
- const compareUrl = `https://github.com/${repository.owner}/${repository.repo}/compare/${pkg.name}@${previousVersion}...${pkg.name}@${newVersion}`;
109
- versionHeader += `[${newVersion}](${compareUrl})`;
110
- } else versionHeader += newVersion;
111
- versionHeader += ` (${date})\n\n`;
112
- let changelog = versionHeader;
113
- const grouped = /* @__PURE__ */ new Map();
114
- for (const commit of commits) {
115
- if (!commit.isConventional || !commit.type) continue;
116
- const type = commit.type;
117
- if (!grouped.has(type)) grouped.set(type, []);
118
- grouped.get(type).push(commit);
119
- }
120
- for (const type of [
121
- "feat",
122
- "fix",
123
- "perf",
124
- "refactor",
125
- "docs",
126
- "test",
127
- "build",
128
- "ci",
129
- "chore",
130
- "revert",
131
- "style"
132
- ]) {
133
- const commits$1 = grouped.get(type);
134
- if (!commits$1 || commits$1.length === 0) continue;
135
- const label = getSectionLabel(type);
136
- changelog += `### ${label}\n\n`;
137
- for (const commit of commits$1) {
138
- const scope = commit.scope ? `**${commit.scope}:** ` : "";
139
- const breaking = commit.isBreaking ? " **BREAKING CHANGE**" : "";
140
- let entry = `* ${scope}${commit.description}${breaking}`;
141
- if (repository) {
142
- const commitUrl = `https://github.com/${repository.owner}/${repository.repo}/commit/${commit.shortHash}`;
143
- entry += ` ([${commit.shortHash}](${commitUrl}))`;
144
- } else entry += ` (${commit.shortHash})`;
145
- changelog += `${entry}\n`;
146
- }
147
- changelog += "\n";
148
- }
149
- return changelog.trim();
150
- }
151
- /**
152
- * Write changelog to package's CHANGELOG.md file
153
- */
154
- async function writeChangelog(pkg, newContent, version) {
155
- const changelogPath = join(pkg.path, "CHANGELOG.md");
156
- let existingContent = "";
157
- if (existsSync(changelogPath)) existingContent = await readFile(changelogPath, "utf-8");
158
- let updatedContent;
159
- if (existingContent) {
160
- const withoutTitle = existingContent.replace(/^# Changelog\n\n/, "");
161
- if (new RegExp(`^## ${version.replace(/\./g, "\\.")}(\\s|$)`, "m").test(withoutTitle)) {
162
- const versionSectionRegex = new RegExp(`^## ${version.replace(/\./g, "\\.")}[\\s\\S]*?(?=^## |$)`, "m");
163
- updatedContent = `# Changelog\n\n${withoutTitle.replace(versionSectionRegex, `${newContent}\n\n`)}`;
164
- } else updatedContent = `# Changelog\n\n${newContent}\n\n${withoutTitle}`;
165
- } else updatedContent = `# Changelog\n\n${newContent}\n`;
166
- await writeFile(changelogPath, updatedContent, "utf-8");
167
- logger.log(`Updated changelog: ${changelogPath}`);
168
- }
169
- /**
170
- * Generate and write changelogs for all updated packages
171
- */
172
- async function updateChangelogs(updates, packageCommits, options) {
173
- if (!options?.enabled) {
174
- logger.log("Changelog generation is disabled");
175
- return;
176
- }
177
- logger.log("Generating changelogs...");
178
- for (const update of updates) {
179
- if (!update.hasDirectChanges) continue;
180
- const commits = packageCommits.get(update.package.name) || [];
181
- if (commits.length === 0) continue;
182
- let changelog = generateChangelog(update.package, update.newVersion, commits, update.currentVersion, options.repository);
183
- if (options.transform) changelog = await options.transform(changelog, update.package);
184
- logger.info(`Generating changelog for package ${update.package.name}`);
185
- await writeChangelog(update.package, changelog, update.newVersion);
186
- }
187
- }
188
-
189
- //#endregion
190
- //#region src/commits.ts
191
- async function getLastPackageTag(packageName, workspaceRoot) {
192
- try {
193
- const { stdout } = await run("git", ["tag", "--list"], { nodeOptions: {
194
- cwd: workspaceRoot,
195
- stdio: "pipe"
196
- } });
197
- return stdout.split("\n").map((tag) => tag.trim()).filter(Boolean).reverse().find((tag) => tag.startsWith(`${packageName}@`));
198
- } catch (err) {
199
- logger.warn(`Failed to get tags for package ${packageName}: ${err.message}`);
200
- return;
201
- }
202
- }
203
- function determineHighestBump(commits) {
204
- if (commits.length === 0) return "none";
205
- let highestBump = "none";
206
- for (const commit of commits) {
207
- const bump = determineBumpType(commit);
208
- if (bump === "major") return "major";
209
- if (bump === "minor") highestBump = "minor";
210
- else if (bump === "patch" && highestBump === "none") highestBump = "patch";
211
- }
212
- return highestBump;
213
- }
214
- /**
215
- * Retrieves commits that affect a specific workspace package since its last tag.
216
- *
217
- * @param {string} workspaceRoot - The root directory of the workspace.
218
- * @param {WorkspacePackage} pkg - The workspace package to analyze.
219
- * @returns {Promise<GitCommit[]>} A promise that resolves to an array of GitCommit objects affecting the package.
220
- */
221
- async function getCommitsForWorkspacePackage(workspaceRoot, pkg) {
222
- const lastTag = await getLastPackageTag(pkg.name, workspaceRoot);
223
- const allCommits = getCommits({
224
- from: lastTag,
225
- to: "HEAD",
226
- cwd: workspaceRoot
227
- });
228
- logger.log(`Found ${allCommits.length} commits for ${pkg.name} since ${lastTag || "beginning"}`);
229
- const touchedCommitHashes = getCommits({
230
- from: lastTag,
231
- to: "HEAD",
232
- cwd: workspaceRoot,
233
- folder: pkg.path
234
- });
235
- const touchedSet = new Set(touchedCommitHashes);
236
- const packageCommits = allCommits.filter((commit) => touchedSet.has(commit));
237
- logger.log(`${packageCommits.length} commits affect ${pkg.name}`);
238
- return packageCommits;
239
- }
240
- async function getWorkspacePackageCommits(workspaceRoot, packages) {
241
- const changedPackages = /* @__PURE__ */ new Map();
242
- const promises = packages.map(async (pkg) => {
243
- return {
244
- pkgName: pkg.name,
245
- commits: await getCommitsForWorkspacePackage(workspaceRoot, pkg)
246
- };
247
- });
248
- const results = await Promise.all(promises);
249
- for (const { pkgName, commits } of results) changedPackages.set(pkgName, commits);
250
- return changedPackages;
251
- }
252
- /**
253
- * Get all commits for the workspace (not filtered by package)
254
- */
255
- async function getAllWorkspaceCommits(workspaceRoot, lastTag) {
256
- return getCommits({
257
- from: lastTag,
258
- to: "HEAD",
259
- cwd: workspaceRoot
103
+ if (isDryRun || isVerbose || isForce) {
104
+ logger.verbose(farver.inverse(farver.yellow(" Running with special flags ")));
105
+ logger.verbose({
106
+ isDryRun,
107
+ isVerbose,
108
+ isForce
260
109
  });
261
- }
262
- /**
263
- * Get files changed in a specific commit
264
- */
265
- async function getFilesChangedInCommit(commitHash, workspaceRoot) {
266
- try {
267
- const { stdout } = await run("git", [
268
- "diff-tree",
269
- "--no-commit-id",
270
- "--name-only",
271
- "-r",
272
- commitHash
273
- ], { nodeOptions: {
274
- cwd: workspaceRoot,
275
- stdio: "pipe"
276
- } });
277
- return stdout.split("\n").map((file) => file.trim()).filter(Boolean);
278
- } catch {
279
- return null;
280
- }
281
- }
282
- /**
283
- * Filter and combine package commits with global commits
284
- */
285
- function combineWithGlobalCommits(workspaceRoot, packageCommits, allCommits, mode) {
286
- if (!mode) return packageCommits;
287
- const packageCommitShas = new Set(packageCommits.map((c) => c.shortHash));
288
- const globalCommits = allCommits.filter((c) => !packageCommitShas.has(c.shortHash));
289
- if (mode === "all") return [...packageCommits, ...globalCommits];
290
- if (mode === "dependencies") {
291
- const dependencyCommits = globalCommits.filter(async (c) => {
292
- const affectedFiles = await getFilesChangedInCommit(c.shortHash, workspaceRoot);
293
- if (affectedFiles == null) return false;
294
- return affectedFiles.some((file) => [
295
- "package.json",
296
- "pnpm-lock.yaml",
297
- "pnpm-workspace.yaml"
298
- ].includes(file));
299
- });
300
- return [...packageCommits, ...dependencyCommits];
301
- }
302
- return packageCommits;
303
- }
304
- function determineBumpType(commit) {
305
- if (commit.isBreaking) return "major";
306
- if (!commit.isConventional || !commit.type) return "none";
307
- switch (commit.type) {
308
- case "feat": return "minor";
309
- case "fix":
310
- case "perf": return "patch";
311
- case "docs":
312
- case "style":
313
- case "refactor":
314
- case "test":
315
- case "build":
316
- case "ci":
317
- case "chore":
318
- case "revert": return "none";
319
- default: return "none";
320
- }
110
+ logger.verbose();
321
111
  }
322
112
 
323
113
  //#endregion
324
- //#region src/git.ts
114
+ //#region src/core/git.ts
325
115
  /**
326
116
  * Check if the working directory is clean (no uncommitted changes)
327
117
  * @param {string} workspaceRoot - The root directory of the workspace
@@ -361,31 +151,64 @@ async function doesBranchExist(branch, workspaceRoot) {
361
151
  }
362
152
  }
363
153
  /**
364
- * Pull latest changes from remote branch
365
- * @param branch - The branch name to pull from
366
- * @param workspaceRoot - The root directory of the workspace
367
- * @returns Promise resolving to true if pull succeeded, false otherwise
154
+ * Retrieves the default branch name from the remote repository.
155
+ * Falls back to "main" if the default branch cannot be determined.
156
+ * @returns {Promise<string>} A Promise resolving to the default branch name as a string.
368
157
  */
369
- async function pullLatestChanges(branch, workspaceRoot) {
158
+ async function getDefaultBranch(workspaceRoot) {
370
159
  try {
371
- await run("git", [
372
- "pull",
373
- "origin",
374
- branch
375
- ], { nodeOptions: {
160
+ const match = (await run("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { nodeOptions: {
376
161
  cwd: workspaceRoot,
377
162
  stdio: "pipe"
378
- } });
379
- return true;
163
+ } })).stdout.trim().match(/^refs\/remotes\/origin\/(.+)$/);
164
+ if (match && match[1]) return match[1];
165
+ return "main";
380
166
  } catch {
381
- return false;
167
+ return "main";
382
168
  }
383
169
  }
384
170
  /**
385
- * Create a new git branch
386
- * @param branch - The new branch name
387
- * @param base - The base branch to create from
388
- * @param workspaceRoot - The root directory of the workspace
171
+ * Retrieves the name of the current branch in the repository.
172
+ * @param {string} workspaceRoot - The root directory of the workspace
173
+ * @returns {Promise<string>} A Promise resolving to the current branch name as a string
174
+ */
175
+ async function getCurrentBranch(workspaceRoot) {
176
+ try {
177
+ return (await run("git", [
178
+ "rev-parse",
179
+ "--abbrev-ref",
180
+ "HEAD"
181
+ ], { nodeOptions: {
182
+ cwd: workspaceRoot,
183
+ stdio: "pipe"
184
+ } })).stdout.trim();
185
+ } catch (err) {
186
+ logger.error("Error getting current branch:", err);
187
+ throw err;
188
+ }
189
+ }
190
+ /**
191
+ * Retrieves the list of available branches in the repository.
192
+ * @param {string} workspaceRoot - The root directory of the workspace
193
+ * @returns {Promise<string[]>} A Promise resolving to an array of branch names
194
+ */
195
+ async function getAvailableBranches(workspaceRoot) {
196
+ try {
197
+ return (await run("git", ["branch", "--list"], { nodeOptions: {
198
+ cwd: workspaceRoot,
199
+ stdio: "pipe"
200
+ } })).stdout.split("\n").map((line) => line.replace("*", "").trim()).filter((line) => line.length > 0);
201
+ } catch (err) {
202
+ logger.error("Error getting available branches:", err);
203
+ throw err;
204
+ }
205
+ }
206
+ /**
207
+ * Creates a new branch from the specified base branch.
208
+ * @param {string} branch - The name of the new branch to create
209
+ * @param {string} base - The base branch to create the new branch from
210
+ * @param {string} workspaceRoot - The root directory of the workspace
211
+ * @returns {Promise<void>} A Promise that resolves when the branch is created
389
212
  */
390
213
  async function createBranch(branch, base, workspaceRoot) {
391
214
  try {
@@ -403,16 +226,29 @@ async function createBranch(branch, base, workspaceRoot) {
403
226
  exitWithError(`Failed to create branch: ${branch}`, `Make sure the branch doesn't already exist and you have a clean working directory`);
404
227
  }
405
228
  }
406
- /**
407
- * Checkout a git branch
408
- * @param branch - The branch name to checkout
409
- * @param workspaceRoot - The root directory of the workspace
410
- * @returns Promise resolving to true if checkout succeeded, false otherwise
411
- */
412
229
  async function checkoutBranch(branch, workspaceRoot) {
413
230
  try {
414
231
  logger.info(`Switching to branch: ${farver.green(branch)}`);
415
- await run("git", ["checkout", branch], { nodeOptions: {
232
+ const match = (await run("git", ["checkout", branch], { nodeOptions: {
233
+ cwd: workspaceRoot,
234
+ stdio: "pipe"
235
+ } })).stderr.trim().match(/Switched to branch '(.+)'/);
236
+ if (match && match[1] === branch) {
237
+ logger.info(`Successfully switched to branch: ${farver.green(branch)}`);
238
+ return true;
239
+ }
240
+ return false;
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+ async function pullLatestChanges(branch, workspaceRoot) {
246
+ try {
247
+ await run("git", [
248
+ "pull",
249
+ "origin",
250
+ branch
251
+ ], { nodeOptions: {
416
252
  cwd: workspaceRoot,
417
253
  stdio: "pipe"
418
254
  } });
@@ -421,43 +257,18 @@ async function checkoutBranch(branch, workspaceRoot) {
421
257
  return false;
422
258
  }
423
259
  }
424
- /**
425
- * Get the current branch name
426
- * @param workspaceRoot - The root directory of the workspace
427
- * @returns Promise resolving to the current branch name
428
- */
429
- async function getCurrentBranch(workspaceRoot) {
430
- return (await run("git", [
431
- "rev-parse",
432
- "--abbrev-ref",
433
- "HEAD"
434
- ], { nodeOptions: {
435
- cwd: workspaceRoot,
436
- stdio: "pipe"
437
- } })).stdout.trim();
438
- }
439
- /**
440
- * Rebase current branch onto another branch
441
- * @param ontoBranch - The target branch to rebase onto
442
- * @param workspaceRoot - The root directory of the workspace
443
- */
444
260
  async function rebaseBranch(ontoBranch, workspaceRoot) {
445
261
  try {
446
262
  logger.info(`Rebasing onto: ${farver.cyan(ontoBranch)}`);
447
- await run("git", ["rebase", ontoBranch], { nodeOptions: {
263
+ await runIfNotDry("git", ["rebase", ontoBranch], { nodeOptions: {
448
264
  cwd: workspaceRoot,
449
265
  stdio: "pipe"
450
266
  } });
267
+ return true;
451
268
  } catch {
452
269
  exitWithError(`Failed to rebase onto: ${ontoBranch}`, `You may have merge conflicts. Run 'git rebase --abort' to undo the rebase`);
453
270
  }
454
271
  }
455
- /**
456
- * Check if local branch is ahead of remote (has commits to push)
457
- * @param branch - The branch name to check
458
- * @param workspaceRoot - The root directory of the workspace
459
- * @returns Promise resolving to true if local is ahead, false otherwise
460
- */
461
272
  async function isBranchAheadOfRemote(branch, workspaceRoot) {
462
273
  try {
463
274
  const result = await run("git", [
@@ -473,23 +284,12 @@ async function isBranchAheadOfRemote(branch, workspaceRoot) {
473
284
  return true;
474
285
  }
475
286
  }
476
- /**
477
- * Check if there are any changes to commit (staged or unstaged)
478
- * @param workspaceRoot - The root directory of the workspace
479
- * @returns Promise resolving to true if there are changes, false otherwise
480
- */
481
287
  async function hasChangesToCommit(workspaceRoot) {
482
288
  return (await run("git", ["status", "--porcelain"], { nodeOptions: {
483
289
  cwd: workspaceRoot,
484
290
  stdio: "pipe"
485
291
  } })).stdout.trim() !== "";
486
292
  }
487
- /**
488
- * Commit changes with a message
489
- * @param message - The commit message
490
- * @param workspaceRoot - The root directory of the workspace
491
- * @returns Promise resolving to true if commit was made, false if there were no changes
492
- */
493
293
  async function commitChanges(message, workspaceRoot) {
494
294
  try {
495
295
  await run("git", ["add", "."], { nodeOptions: {
@@ -511,51 +311,35 @@ async function commitChanges(message, workspaceRoot) {
511
311
  exitWithError(`Failed to commit changes`, `Make sure you have git configured properly with user.name and user.email`);
512
312
  }
513
313
  }
514
- /**
515
- * Push branch to remote
516
- * @param branch - The branch name to push
517
- * @param workspaceRoot - The root directory of the workspace
518
- * @param options - Push options
519
- * @param options.force - Force push (overwrite remote)
520
- * @param options.forceWithLease - Force push with safety check (won't overwrite unexpected changes)
521
- */
522
314
  async function pushBranch(branch, workspaceRoot, options) {
523
315
  try {
524
- const args = [
316
+ const args$1 = [
525
317
  "push",
526
318
  "origin",
527
319
  branch
528
320
  ];
529
321
  if (options?.forceWithLease) {
530
- args.push("--force-with-lease");
322
+ args$1.push("--force-with-lease");
531
323
  logger.info(`Pushing branch: ${farver.green(branch)} ${farver.dim("(with lease)")}`);
532
324
  } else if (options?.force) {
533
- args.push("--force");
325
+ args$1.push("--force");
534
326
  logger.info(`Force pushing branch: ${farver.green(branch)}`);
535
327
  } else logger.info(`Pushing branch: ${farver.green(branch)}`);
536
- await run("git", args, { nodeOptions: {
328
+ await runIfNotDry("git", args$1, { nodeOptions: {
537
329
  cwd: workspaceRoot,
538
330
  stdio: "pipe"
539
331
  } });
332
+ return true;
540
333
  } catch {
541
334
  exitWithError(`Failed to push branch: ${branch}`, `Make sure you have permission to push to the remote repository`);
542
335
  }
543
336
  }
544
- async function getDefaultBranch() {
545
- try {
546
- const match = (await run("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { nodeOptions: { stdio: "pipe" } })).stdout.trim().match(/^refs\/remotes\/origin\/(.+)$/);
547
- if (match && match[1]) return match[1];
548
- return "main";
549
- } catch {
550
- return "main";
551
- }
552
- }
553
337
 
554
338
  //#endregion
555
- //#region src/github.ts
339
+ //#region src/core/github.ts
556
340
  async function getExistingPullRequest({ owner, repo, branch, githubToken }) {
557
341
  try {
558
- logger.debug(`Requesting pull request for branch: ${branch} (url: https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${branch})`);
342
+ logger.verbose(`Requesting pull request for branch: ${branch} (url: https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${branch})`);
559
343
  const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${branch}`, { headers: {
560
344
  Accept: "application/vnd.github.v3+json",
561
345
  Authorization: `token ${githubToken}`
@@ -591,9 +375,10 @@ async function upsertPullRequest({ owner, repo, title, body, head, base, pullNum
591
375
  title,
592
376
  body,
593
377
  head,
594
- base
378
+ base,
379
+ draft: true
595
380
  };
596
- logger.debug(`${isUpdate ? "Updating" : "Creating"} pull request (url: ${url})`);
381
+ logger.verbose(`${isUpdate ? "Updating" : "Creating"} pull request (url: ${url})`);
597
382
  const res = await fetch(url, {
598
383
  method,
599
384
  headers: {
@@ -653,174 +438,226 @@ function generatePullRequestBody(updates, body) {
653
438
  }
654
439
 
655
440
  //#endregion
656
- //#region src/prompts.ts
657
- async function selectPackagePrompt(packages) {
658
- const response = await prompts({
659
- type: "multiselect",
660
- name: "selectedPackages",
661
- message: "Select packages to release",
662
- choices: packages.map((pkg) => ({
663
- title: `${pkg.name} (${farver.bold(pkg.version)})`,
664
- value: pkg.name,
665
- selected: true
666
- })),
667
- min: 1,
668
- hint: "Space to select/deselect. Return to submit.",
669
- instructions: false
670
- });
671
- if (!response.selectedPackages || response.selectedPackages.length === 0) return [];
672
- return response.selectedPackages;
673
- }
674
- async function promptVersionOverride(pkg, workspaceRoot, currentVersion, suggestedVersion, suggestedBumpType) {
675
- const choices = [{
676
- title: `Use suggested: ${suggestedVersion} (${suggestedBumpType})`,
677
- value: "suggested"
678
- }];
679
- for (const bumpType of [
680
- "patch",
681
- "minor",
682
- "major"
683
- ]) if (bumpType !== suggestedBumpType) {
684
- const version = getNextVersion(currentVersion, bumpType);
685
- choices.push({
686
- title: `${bumpType}: ${version}`,
687
- value: bumpType
688
- });
441
+ //#region src/versioning/commits.ts
442
+ async function getLastPackageTag(packageName, workspaceRoot) {
443
+ try {
444
+ const { stdout } = await run("git", [
445
+ "tag",
446
+ "--list",
447
+ `${packageName}@*`
448
+ ], { nodeOptions: {
449
+ cwd: workspaceRoot,
450
+ stdio: "pipe"
451
+ } });
452
+ return stdout.split("\n").map((tag) => tag.trim()).filter(Boolean).reverse()[0];
453
+ } catch (err) {
454
+ logger.warn(`Failed to get tags for package ${packageName}: ${err.message}`);
455
+ return;
689
456
  }
690
- choices.push({
691
- title: "Custom version",
692
- value: "custom"
693
- });
694
- const response = await prompts([{
695
- type: "select",
696
- name: "choice",
697
- message: `${pkg.name} (${currentVersion}):`,
698
- choices,
699
- initial: 0
700
- }, {
701
- type: (prev) => prev === "custom" ? "text" : null,
702
- name: "customVersion",
703
- message: "Enter custom version:",
704
- initial: suggestedVersion,
705
- validate: (value) => {
706
- return /^\d+\.\d+\.\d+(?:[-+].+)?$/.test(value) || "Invalid semver version (e.g., 1.0.0)";
707
- }
708
- }]);
709
- if (response.choice === "suggested") return suggestedVersion;
710
- else if (response.choice === "custom") return response.customVersion;
711
- else return getNextVersion(currentVersion, response.choice);
712
457
  }
713
-
714
- //#endregion
715
- //#region src/version.ts
716
- function isValidSemver(version) {
717
- return /^\d+\.\d+\.\d+(?:[-+].+)?$/.test(version);
458
+ function determineHighestBump(commits) {
459
+ if (commits.length === 0) return "none";
460
+ let highestBump = "none";
461
+ for (const commit of commits) {
462
+ const bump = determineBumpType(commit);
463
+ if (bump === "major") return "major";
464
+ if (bump === "minor") highestBump = "minor";
465
+ else if (bump === "patch" && highestBump === "none") highestBump = "patch";
466
+ }
467
+ return highestBump;
718
468
  }
719
- function validateSemver(version) {
720
- if (!isValidSemver(version)) throw new Error(`Invalid semver version: ${version}`);
469
+ /**
470
+ * Retrieves commits that affect a specific workspace package since its last tag.
471
+ *
472
+ * @param {string} workspaceRoot - The root directory of the workspace.
473
+ * @param {WorkspacePackage} pkg - The workspace package to analyze.
474
+ * @returns {Promise<GitCommit[]>} A promise that resolves to an array of GitCommit objects affecting the package.
475
+ */
476
+ async function getCommitsForWorkspacePackage(workspaceRoot, pkg) {
477
+ const lastTag = await getLastPackageTag(pkg.name, workspaceRoot);
478
+ const allCommits = getCommits({
479
+ from: lastTag,
480
+ to: "HEAD",
481
+ cwd: workspaceRoot
482
+ });
483
+ logger.verbose("Found commits for package", `${farver.cyan(allCommits.length)} for ${farver.bold(pkg.name)} since ${lastTag || "beginning"}`);
484
+ const commitsAffectingPackage = getCommits({
485
+ from: lastTag,
486
+ to: "HEAD",
487
+ cwd: workspaceRoot,
488
+ folder: pkg.path
489
+ });
490
+ const affectingCommitShas = /* @__PURE__ */ new Set();
491
+ for (const commit of commitsAffectingPackage) affectingCommitShas.add(commit.shortHash);
492
+ const packageCommits = allCommits.filter((commit) => {
493
+ return affectingCommitShas.has(commit.shortHash);
494
+ });
495
+ logger.verbose("Commits affect package", `${farver.cyan(packageCommits.length)} affect ${farver.bold(pkg.name)}`);
496
+ return packageCommits;
721
497
  }
722
- function getNextVersion(currentVersion, bump) {
723
- if (bump === "none") return currentVersion;
724
- validateSemver(currentVersion);
725
- const match = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/);
726
- if (!match) throw new Error(`Invalid semver version: ${currentVersion}`);
727
- const [, major, minor, patch] = match;
728
- let newMajor = Number.parseInt(major, 10);
729
- let newMinor = Number.parseInt(minor, 10);
730
- let newPatch = Number.parseInt(patch, 10);
731
- switch (bump) {
732
- case "major":
733
- newMajor += 1;
734
- newMinor = 0;
735
- newPatch = 0;
736
- break;
737
- case "minor":
738
- newMinor += 1;
739
- newPatch = 0;
740
- break;
741
- case "patch":
742
- newPatch += 1;
743
- break;
498
+ async function getWorkspacePackageCommits(workspaceRoot, packages) {
499
+ const changedPackages = /* @__PURE__ */ new Map();
500
+ const promises = packages.map(async (pkg) => {
501
+ return {
502
+ pkgName: pkg.name,
503
+ commits: await getCommitsForWorkspacePackage(workspaceRoot, pkg)
504
+ };
505
+ });
506
+ const results = await Promise.all(promises);
507
+ for (const { pkgName, commits } of results) changedPackages.set(pkgName, commits);
508
+ return changedPackages;
509
+ }
510
+ async function getCommitFileList(workspaceRoot, from, to) {
511
+ const map = /* @__PURE__ */ new Map();
512
+ try {
513
+ const { stdout } = await run("git", [
514
+ "log",
515
+ "--name-only",
516
+ "--format=%H",
517
+ `${from}^..${to}`
518
+ ], { nodeOptions: {
519
+ cwd: workspaceRoot,
520
+ stdio: "pipe"
521
+ } });
522
+ const lines = stdout.trim().split("\n");
523
+ let currentSha = null;
524
+ for (const line of lines) {
525
+ const trimmedLine = line.trim();
526
+ if (trimmedLine === "") {
527
+ currentSha = null;
528
+ continue;
529
+ }
530
+ if (currentSha === null) {
531
+ currentSha = trimmedLine;
532
+ map.set(currentSha, []);
533
+ continue;
534
+ }
535
+ map.get(currentSha).push(trimmedLine);
536
+ }
537
+ return map;
538
+ } catch {
539
+ return null;
744
540
  }
745
- return `${newMajor}.${newMinor}.${newPatch}`;
746
541
  }
747
542
  /**
748
- * Create a version update object
543
+ * Check if a file path touches any package folder.
544
+ * @param file - The file path to check
545
+ * @param packagePaths - Set of normalized package paths
546
+ * @param workspaceRoot - The workspace root for path normalization
547
+ * @returns true if the file is inside a package folder
749
548
  */
750
- function createVersionUpdate(pkg, bump, hasDirectChanges) {
751
- const newVersion = getNextVersion(pkg.version, bump);
752
- return {
753
- package: pkg,
754
- currentVersion: pkg.version,
755
- newVersion,
756
- bumpType: bump,
757
- hasDirectChanges
758
- };
549
+ function fileMatchesPackageFolder(file, packagePaths, workspaceRoot) {
550
+ const normalizedFile = file.startsWith("./") ? file.slice(2) : file;
551
+ for (const pkgPath of packagePaths) {
552
+ const normalizedPkgPath = pkgPath.startsWith(workspaceRoot) ? pkgPath.slice(workspaceRoot.length + 1) : pkgPath;
553
+ if (normalizedFile.startsWith(`${normalizedPkgPath}/`) || normalizedFile === normalizedPkgPath) return true;
554
+ }
555
+ return false;
759
556
  }
760
557
  /**
761
- * Infer version updates from package commits with optional interactive overrides
558
+ * Check if a commit is a "global" commit (doesn't touch any package folder).
559
+ * @param files - Array of files changed in the commit
560
+ * @param packagePaths - Set of normalized package paths
561
+ * @param workspaceRoot - The workspace root
562
+ * @returns true if this is a global commit
563
+ */
564
+ function isGlobalCommit(files, packagePaths, workspaceRoot) {
565
+ if (!files || files.length === 0) return false;
566
+ return !files.some((file) => fileMatchesPackageFolder(file, packagePaths, workspaceRoot));
567
+ }
568
+ /**
569
+ * Get global commits for each package based on their individual commit timelines.
570
+ * This solves the problem where packages with different release histories need different global commits.
571
+ *
572
+ * A "global commit" is a commit that doesn't touch any package folder but may affect all packages
573
+ * (e.g., root package.json, CI config, README).
574
+ *
575
+ * Performance: Makes ONE batched git call to get files for all commits across all packages.
762
576
  *
763
- * @returns Version updates for packages with changes
577
+ * @param workspaceRoot - The root directory of the workspace
578
+ * @param packageCommits - Map of package name to their commits (from getWorkspacePackageCommits)
579
+ * @param allPackages - All workspace packages (used to identify package folders)
580
+ * @param mode - Filter mode: false (disabled), "all" (all global commits), or "dependencies" (only dependency-related)
581
+ * @returns Map of package name to their global commits
764
582
  */
765
- async function inferVersionUpdates({ workspacePackages, packageCommits, allCommits, workspaceRoot, showPrompt, globalCommitMode }) {
766
- const versionUpdates = [];
767
- for (const [pkgName, pkgCommits] of packageCommits) {
768
- if (pkgCommits.length === 0) continue;
769
- const pkg = workspacePackages.find((p) => p.name === pkgName);
770
- if (!pkg) continue;
771
- const bump = determineHighestBump(combineWithGlobalCommits(workspaceRoot, pkgCommits, allCommits, globalCommitMode));
772
- if (bump === "none") {
773
- logger.info(`No version bump needed for package ${pkg.name}`);
774
- continue;
775
- }
776
- let newVersion = getNextVersion(pkg.version, bump);
777
- if (!isCI && showPrompt) newVersion = await promptVersionOverride(pkg, workspaceRoot, pkg.version, newVersion, bump);
778
- versionUpdates.push({
779
- package: pkg,
780
- currentVersion: pkg.version,
781
- newVersion,
782
- bumpType: bump,
783
- hasDirectChanges: true
784
- });
583
+ async function getGlobalCommitsPerPackage(workspaceRoot, packageCommits, allPackages, mode) {
584
+ const result = /* @__PURE__ */ new Map();
585
+ if (!mode) {
586
+ logger.verbose("Global commits mode disabled");
587
+ return result;
785
588
  }
786
- return versionUpdates;
787
- }
788
- async function updatePackageJson(pkg, newVersion, dependencyUpdates) {
789
- const packageJsonPath = join(pkg.path, "package.json");
790
- const content = await readFile(packageJsonPath, "utf-8");
791
- const packageJson = JSON.parse(content);
792
- packageJson.version = newVersion;
793
- for (const [depName, depVersion] of dependencyUpdates) {
794
- if (packageJson.dependencies?.[depName]) {
795
- if (packageJson.dependencies[depName] === "workspace:*") continue;
796
- packageJson.dependencies[depName] = `^${depVersion}`;
797
- }
798
- if (packageJson.devDependencies?.[depName]) {
799
- if (packageJson.devDependencies[depName] === "workspace:*") continue;
800
- packageJson.devDependencies[depName] = `^${depVersion}`;
801
- }
802
- if (packageJson.peerDependencies?.[depName]) {
803
- if (packageJson.peerDependencies[depName] === "workspace:*") continue;
804
- packageJson.peerDependencies[depName] = `^${depVersion}`;
589
+ logger.verbose(`Computing global commits per-package (mode: ${farver.cyan(mode)})`);
590
+ let oldestCommit = null;
591
+ let newestCommit = null;
592
+ for (const commits of packageCommits.values()) if (commits.length > 0) {
593
+ if (!newestCommit) newestCommit = commits[0].shortHash;
594
+ oldestCommit = commits[commits.length - 1].shortHash;
595
+ }
596
+ if (!oldestCommit || !newestCommit) {
597
+ logger.verbose("No commits found across packages");
598
+ return result;
599
+ }
600
+ logger.verbose("Fetching files for commits range", `${farver.cyan(oldestCommit)}..${farver.cyan(newestCommit)}`);
601
+ const commitFilesMap = await getCommitFileList(workspaceRoot, oldestCommit, newestCommit);
602
+ if (!commitFilesMap) {
603
+ logger.warn("Failed to get commit file list, returning empty global commits");
604
+ return result;
605
+ }
606
+ logger.verbose("Got file lists for commits", `${farver.cyan(commitFilesMap.size)} commits in ONE git call`);
607
+ const packagePaths = new Set(allPackages.map((p) => p.path));
608
+ for (const [pkgName, commits] of packageCommits) {
609
+ const globalCommitsForPackage = [];
610
+ logger.verbose("Filtering global commits for package", `${farver.bold(pkgName)} from ${farver.cyan(commits.length)} commits`);
611
+ for (const commit of commits) if (isGlobalCommit(commitFilesMap.get(commit.shortHash), packagePaths, workspaceRoot)) globalCommitsForPackage.push(commit);
612
+ logger.verbose("Package global commits found", `${farver.bold(pkgName)}: ${farver.cyan(globalCommitsForPackage.length)} global commits`);
613
+ if (mode === "all") result.set(pkgName, globalCommitsForPackage);
614
+ else if (mode === "dependencies") {
615
+ const dependencyCommits = [];
616
+ const dependencyFiles = [
617
+ "package.json",
618
+ "pnpm-lock.yaml",
619
+ "pnpm-workspace.yaml",
620
+ "yarn.lock",
621
+ "package-lock.json"
622
+ ];
623
+ for (const commit of globalCommitsForPackage) {
624
+ const files = commitFilesMap.get(commit.shortHash);
625
+ if (!files) continue;
626
+ if (files.some((file) => {
627
+ const normalizedFile = file.startsWith("./") ? file.slice(2) : file;
628
+ return dependencyFiles.includes(normalizedFile);
629
+ })) {
630
+ logger.verbose("Global commit affects dependencies", `${farver.bold(pkgName)}: commit ${farver.cyan(commit.shortHash)} affects dependencies`);
631
+ dependencyCommits.push(commit);
632
+ }
633
+ }
634
+ logger.verbose("Global commits affect dependencies", `${farver.bold(pkgName)}: ${farver.cyan(dependencyCommits.length)} global commits affect dependencies`);
635
+ result.set(pkgName, dependencyCommits);
805
636
  }
806
637
  }
807
- await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf-8");
638
+ return result;
808
639
  }
809
- /**
810
- * Get all dependency updates needed for a package
811
- */
812
- function getDependencyUpdates(pkg, allUpdates) {
813
- const updates = /* @__PURE__ */ new Map();
814
- const allDeps = [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies];
815
- for (const dep of allDeps) {
816
- const update = allUpdates.find((u) => u.package.name === dep);
817
- if (update) updates.set(dep, update.newVersion);
640
+ function determineBumpType(commit) {
641
+ if (commit.isBreaking) return "major";
642
+ if (!commit.isConventional || !commit.type) return "none";
643
+ switch (commit.type) {
644
+ case "feat": return "minor";
645
+ case "fix":
646
+ case "perf": return "patch";
647
+ case "docs":
648
+ case "style":
649
+ case "refactor":
650
+ case "test":
651
+ case "build":
652
+ case "ci":
653
+ case "chore":
654
+ case "revert": return "none";
655
+ default: return "none";
818
656
  }
819
- return updates;
820
657
  }
821
658
 
822
659
  //#endregion
823
- //#region src/package.ts
660
+ //#region src/versioning/package.ts
824
661
  /**
825
662
  * Build a dependency graph from workspace packages
826
663
  *
@@ -888,29 +725,326 @@ function createDependentUpdates(graph, workspacePackages, directUpdates) {
888
725
  const directUpdateMap = new Map(directUpdates.map((u) => [u.package.name, u]));
889
726
  const affectedPackages = getAllAffectedPackages(graph, new Set(directUpdates.map((u) => u.package.name)));
890
727
  for (const pkgName of affectedPackages) {
891
- if (directUpdateMap.has(pkgName)) continue;
728
+ logger.verbose(`Processing affected package: ${pkgName}`);
729
+ if (directUpdateMap.has(pkgName)) {
730
+ logger.verbose(`Skipping ${pkgName}, already has a direct update`);
731
+ continue;
732
+ }
892
733
  const pkg = workspacePackages.find((p) => p.name === pkgName);
893
734
  if (!pkg) continue;
894
735
  allUpdates.push(createVersionUpdate(pkg, "patch", false));
895
736
  }
896
737
  return allUpdates;
897
738
  }
739
+
740
+ //#endregion
741
+ //#region src/versioning/version.ts
742
+ function isValidSemver(version) {
743
+ return /^\d+\.\d+\.\d+(?:[-+].+)?$/.test(version);
744
+ }
745
+ function getNextVersion(currentVersion, bump) {
746
+ if (bump === "none") {
747
+ logger.verbose(`No version bump needed, keeping version ${currentVersion}`);
748
+ return currentVersion;
749
+ }
750
+ if (!isValidSemver(currentVersion)) throw new Error(`Cannot bump version for invalid semver: ${currentVersion}`);
751
+ const match = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/);
752
+ if (!match) throw new Error(`Invalid semver version: ${currentVersion}`);
753
+ const [, major, minor, patch] = match;
754
+ let newMajor = Number.parseInt(major, 10);
755
+ let newMinor = Number.parseInt(minor, 10);
756
+ let newPatch = Number.parseInt(patch, 10);
757
+ switch (bump) {
758
+ case "major":
759
+ newMajor += 1;
760
+ newMinor = 0;
761
+ newPatch = 0;
762
+ break;
763
+ case "minor":
764
+ newMinor += 1;
765
+ newPatch = 0;
766
+ break;
767
+ case "patch":
768
+ newPatch += 1;
769
+ break;
770
+ }
771
+ return `${newMajor}.${newMinor}.${newPatch}`;
772
+ }
898
773
  /**
899
- * Update all package.json files with new versions and dependency updates
900
- *
901
- * Updates are performed in parallel for better performance.
902
- *
903
- * @param updates - Version updates to apply
774
+ * Create a version update object
904
775
  */
905
- async function updateAllPackageJsonFiles(updates) {
906
- await Promise.all(updates.map(async (update) => {
907
- const depUpdates = getDependencyUpdates(update.package, updates);
908
- await updatePackageJson(update.package, update.newVersion, depUpdates);
909
- }));
776
+ function createVersionUpdate(pkg, bump, hasDirectChanges) {
777
+ const newVersion = getNextVersion(pkg.version, bump);
778
+ return {
779
+ package: pkg,
780
+ currentVersion: pkg.version,
781
+ newVersion,
782
+ bumpType: bump,
783
+ hasDirectChanges
784
+ };
785
+ }
786
+ function _calculateBumpType(oldVersion, newVersion) {
787
+ if (!isValidSemver(oldVersion) || !isValidSemver(newVersion)) throw new Error(`Cannot calculate bump type for invalid semver: ${oldVersion} or ${newVersion}`);
788
+ const oldParts = oldVersion.split(".").map(Number);
789
+ const newParts = newVersion.split(".").map(Number);
790
+ if (newParts[0] > oldParts[0]) return "major";
791
+ if (newParts[1] > oldParts[1]) return "minor";
792
+ if (newParts[2] > oldParts[2]) return "patch";
793
+ return "none";
794
+ }
795
+ const messageColorMap = {
796
+ feat: farver.green,
797
+ feature: farver.green,
798
+ refactor: farver.cyan,
799
+ style: farver.cyan,
800
+ docs: farver.blue,
801
+ doc: farver.blue,
802
+ types: farver.blue,
803
+ type: farver.blue,
804
+ chore: farver.gray,
805
+ ci: farver.gray,
806
+ build: farver.gray,
807
+ deps: farver.gray,
808
+ dev: farver.gray,
809
+ fix: farver.yellow,
810
+ test: farver.yellow,
811
+ perf: farver.magenta,
812
+ revert: farver.red,
813
+ breaking: farver.red
814
+ };
815
+ function formatCommitsForDisplay(commits) {
816
+ if (commits.length === 0) return farver.dim("No commits found");
817
+ const maxCommitsToShow = 10;
818
+ const commitsToShow = commits.slice(0, maxCommitsToShow);
819
+ const hasMore = commits.length > maxCommitsToShow;
820
+ const typeLength = commits.map(({ type }) => type.length).reduce((a, b) => Math.max(a, b), 0);
821
+ const scopeLength = commits.map(({ scope }) => scope.length).reduce((a, b) => Math.max(a, b), 0);
822
+ const formattedCommits = commitsToShow.map((commit) => {
823
+ let color = messageColorMap[commit.type] || ((c) => c);
824
+ if (commit.isBreaking) color = (s) => farver.inverse.red(s);
825
+ const paddedType = commit.type.padStart(typeLength + 1, " ");
826
+ const paddedScope = !commit.scope ? " ".repeat(scopeLength ? scopeLength + 2 : 0) : farver.dim("(") + commit.scope + farver.dim(")") + " ".repeat(scopeLength - commit.scope.length);
827
+ return [
828
+ farver.dim(commit.shortHash),
829
+ " ",
830
+ color === farver.gray ? color(paddedType) : farver.bold(color(paddedType)),
831
+ " ",
832
+ paddedScope,
833
+ farver.dim(":"),
834
+ " ",
835
+ color === farver.gray ? color(commit.description) : commit.description
836
+ ].join("");
837
+ }).join("\n");
838
+ if (hasMore) return `${formattedCommits}\n ${farver.dim(`... and ${commits.length - maxCommitsToShow} more commits`)}`;
839
+ return formattedCommits;
840
+ }
841
+ /**
842
+ * Calculate version updates for packages based on their commits
843
+ */
844
+ async function calculateVersionUpdates({ workspacePackages, packageCommits, workspaceRoot, showPrompt, globalCommitsPerPackage }) {
845
+ const versionUpdates = [];
846
+ const processedPackages = /* @__PURE__ */ new Set();
847
+ logger.verbose(`Starting version inference for ${packageCommits.size} packages with commits`);
848
+ for (const [pkgName, pkgCommits] of packageCommits) {
849
+ const pkg = workspacePackages.find((p) => p.name === pkgName);
850
+ if (!pkg) {
851
+ logger.error(`Package ${pkgName} not found in workspace packages, skipping`);
852
+ continue;
853
+ }
854
+ processedPackages.add(pkgName);
855
+ const globalCommits = globalCommitsPerPackage.get(pkgName) || [];
856
+ if (globalCommits.length > 0) logger.verbose(` - Global commits for this package: ${globalCommits.length}`);
857
+ const allCommitsForPackage = [...pkgCommits, ...globalCommits];
858
+ const bump = determineHighestBump(allCommitsForPackage);
859
+ if (bump === "none") continue;
860
+ let newVersion = getNextVersion(pkg.version, bump);
861
+ if (!isCI && showPrompt) {
862
+ logger.section("📝 Commits affecting this package");
863
+ formatCommitsForDisplay(allCommitsForPackage).split("\n").forEach((line) => logger.item(line));
864
+ logger.emptyLine();
865
+ const selectedVersion = await selectVersionPrompt(workspaceRoot, pkg, pkg.version, newVersion);
866
+ if (selectedVersion === null) continue;
867
+ newVersion = selectedVersion;
868
+ }
869
+ logger.item(`Version update: ${pkg.version} → ${newVersion}`);
870
+ versionUpdates.push({
871
+ package: pkg,
872
+ currentVersion: pkg.version,
873
+ newVersion,
874
+ bumpType: bump,
875
+ hasDirectChanges: true
876
+ });
877
+ }
878
+ if (!isCI && showPrompt) for (const pkg of workspacePackages) {
879
+ if (processedPackages.has(pkg.name)) continue;
880
+ logger.section(`📦 Package: ${pkg.name}`);
881
+ logger.item("No direct commits found");
882
+ const newVersion = await selectVersionPrompt(workspaceRoot, pkg, pkg.version, pkg.version);
883
+ if (newVersion === null) break;
884
+ if (newVersion !== pkg.version) {
885
+ const bumpType = _calculateBumpType(pkg.version, newVersion);
886
+ versionUpdates.push({
887
+ package: pkg,
888
+ currentVersion: pkg.version,
889
+ newVersion,
890
+ bumpType,
891
+ hasDirectChanges: false
892
+ });
893
+ }
894
+ }
895
+ return versionUpdates;
896
+ }
897
+ /**
898
+ * Calculate version updates and prepare dependent updates
899
+ * Returns both the updates and a function to apply them
900
+ */
901
+ async function calculateAndPrepareVersionUpdates({ workspacePackages, packageCommits, workspaceRoot, showPrompt, globalCommitsPerPackage }) {
902
+ const directUpdates = await calculateVersionUpdates({
903
+ workspacePackages,
904
+ packageCommits,
905
+ workspaceRoot,
906
+ showPrompt,
907
+ globalCommitsPerPackage
908
+ });
909
+ const allUpdates = createDependentUpdates(buildPackageDependencyGraph(workspacePackages), workspacePackages, directUpdates);
910
+ const applyUpdates = async () => {
911
+ await Promise.all(allUpdates.map(async (update) => {
912
+ const depUpdates = getDependencyUpdates(update.package, allUpdates);
913
+ await updatePackageJson(update.package, update.newVersion, depUpdates);
914
+ }));
915
+ };
916
+ return {
917
+ allUpdates,
918
+ applyUpdates
919
+ };
920
+ }
921
+ async function updatePackageJson(pkg, newVersion, dependencyUpdates) {
922
+ const packageJsonPath = join(pkg.path, "package.json");
923
+ const content = await readFile(packageJsonPath, "utf-8");
924
+ const packageJson = JSON.parse(content);
925
+ packageJson.version = newVersion;
926
+ for (const [depName, depVersion] of dependencyUpdates) {
927
+ if (packageJson.dependencies?.[depName]) {
928
+ const oldVersion = packageJson.dependencies[depName];
929
+ if (oldVersion === "workspace:*") {
930
+ logger.verbose(` - Skipping workspace:* dependency: ${depName}`);
931
+ continue;
932
+ }
933
+ packageJson.dependencies[depName] = `^${depVersion}`;
934
+ logger.verbose(` - Updated dependency ${depName}: ${oldVersion} → ^${depVersion}`);
935
+ }
936
+ if (packageJson.devDependencies?.[depName]) {
937
+ const oldVersion = packageJson.devDependencies[depName];
938
+ if (oldVersion === "workspace:*") {
939
+ logger.verbose(` - Skipping workspace:* devDependency: ${depName}`);
940
+ continue;
941
+ }
942
+ packageJson.devDependencies[depName] = `^${depVersion}`;
943
+ logger.verbose(` - Updated devDependency ${depName}: ${oldVersion} → ^${depVersion}`);
944
+ }
945
+ if (packageJson.peerDependencies?.[depName]) {
946
+ const oldVersion = packageJson.peerDependencies[depName];
947
+ if (oldVersion === "workspace:*") {
948
+ logger.verbose(` - Skipping workspace:* peerDependency: ${depName}`);
949
+ continue;
950
+ }
951
+ const majorVersion = depVersion.split(".")[0];
952
+ packageJson.peerDependencies[depName] = `>=${depVersion} <${Number(majorVersion) + 1}.0.0`;
953
+ logger.verbose(` - Updated peerDependency ${depName}: ${oldVersion} → ^${depVersion}`);
954
+ }
955
+ }
956
+ await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf-8");
957
+ logger.verbose(` - Successfully wrote updated package.json`);
958
+ }
959
+ /**
960
+ * Get all dependency updates needed for a package
961
+ */
962
+ function getDependencyUpdates(pkg, allUpdates) {
963
+ const updates = /* @__PURE__ */ new Map();
964
+ const allDeps = [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies];
965
+ for (const dep of allDeps) {
966
+ const update = allUpdates.find((u) => u.package.name === dep);
967
+ if (update) {
968
+ logger.verbose(` - Dependency ${dep} will be updated: ${update.currentVersion} → ${update.newVersion} (${update.bumpType})`);
969
+ updates.set(dep, update.newVersion);
970
+ }
971
+ }
972
+ if (updates.size === 0) logger.verbose(` - No dependency updates needed`);
973
+ return updates;
974
+ }
975
+
976
+ //#endregion
977
+ //#region src/core/prompts.ts
978
+ async function selectPackagePrompt(packages) {
979
+ const response = await prompts({
980
+ type: "multiselect",
981
+ name: "selectedPackages",
982
+ message: "Select packages to release",
983
+ choices: packages.map((pkg) => ({
984
+ title: `${pkg.name} (${farver.bold(pkg.version)})`,
985
+ value: pkg.name,
986
+ selected: true
987
+ })),
988
+ min: 1,
989
+ hint: "Space to select/deselect. Return to submit.",
990
+ instructions: false
991
+ });
992
+ if (!response.selectedPackages || response.selectedPackages.length === 0) return [];
993
+ return response.selectedPackages;
994
+ }
995
+ async function selectVersionPrompt(workspaceRoot, pkg, currentVersion, suggestedVersion) {
996
+ const answers = await prompts([{
997
+ type: "autocomplete",
998
+ name: "version",
999
+ message: `${pkg.name}: ${farver.green(pkg.version)}`,
1000
+ choices: [
1001
+ {
1002
+ value: "skip",
1003
+ title: `skip ${farver.dim("(no change)")}`
1004
+ },
1005
+ {
1006
+ value: "major",
1007
+ title: `major ${farver.bold(getNextVersion(pkg.version, "major"))}`
1008
+ },
1009
+ {
1010
+ value: "minor",
1011
+ title: `minor ${farver.bold(getNextVersion(pkg.version, "minor"))}`
1012
+ },
1013
+ {
1014
+ value: "patch",
1015
+ title: `patch ${farver.bold(getNextVersion(pkg.version, "patch"))}`
1016
+ },
1017
+ {
1018
+ value: "suggested",
1019
+ title: `suggested ${farver.bold(suggestedVersion)}`
1020
+ },
1021
+ {
1022
+ value: "custom",
1023
+ title: "custom"
1024
+ }
1025
+ ],
1026
+ initial: suggestedVersion === currentVersion ? 0 : 4
1027
+ }, {
1028
+ type: (prev) => prev === "custom" ? "text" : null,
1029
+ name: "custom",
1030
+ message: "Enter the new version number:",
1031
+ initial: suggestedVersion,
1032
+ validate: (custom) => {
1033
+ if (isValidSemver(custom)) return true;
1034
+ return "That's not a valid version number";
1035
+ }
1036
+ }]);
1037
+ if (!answers.version) return null;
1038
+ if (answers.version === "skip") return null;
1039
+ else if (answers.version === "suggested") return suggestedVersion;
1040
+ else if (answers.version === "custom") {
1041
+ if (!answers.custom) return null;
1042
+ return answers.custom;
1043
+ } else return getNextVersion(pkg.version, answers.version);
910
1044
  }
911
1045
 
912
1046
  //#endregion
913
- //#region src/workspace.ts
1047
+ //#region src/core/workspace.ts
914
1048
  async function discoverWorkspacePackages(workspaceRoot, options) {
915
1049
  let workspaceOptions;
916
1050
  let explicitPackages;
@@ -992,105 +1126,153 @@ function shouldIncludePackage(pkg, options) {
992
1126
  //#endregion
993
1127
  //#region src/release.ts
994
1128
  async function release(options) {
995
- const normalizedOptions = normalizeSharedOptions(options);
996
- normalizedOptions.dryRun ??= false;
997
- normalizedOptions.branch ??= {};
998
- normalizedOptions.branch.release ??= "release/next";
999
- normalizedOptions.branch.default = await getDefaultBranch();
1000
- normalizedOptions.safeguards ??= true;
1001
- normalizedOptions.changelog ??= { enabled: true };
1002
- normalizedOptions.globalCommitMode ??= "dependencies";
1003
- globalOptions.dryRun = normalizedOptions.dryRun;
1004
- const workspaceRoot = normalizedOptions.workspaceRoot;
1129
+ const { workspaceRoot,...normalizedOptions } = await normalizeReleaseOptions(options);
1005
1130
  if (normalizedOptions.safeguards && !await isWorkingDirectoryClean(workspaceRoot)) exitWithError("Working directory is not clean. Please commit or stash your changes before proceeding.");
1006
1131
  const workspacePackages = await discoverWorkspacePackages(workspaceRoot, options);
1007
1132
  if (workspacePackages.length === 0) {
1008
- logger.log("No packages found to release.");
1133
+ logger.warn("No packages found to release");
1009
1134
  return null;
1010
1135
  }
1136
+ logger.section("📦 Workspace Packages");
1137
+ logger.item(`Found ${workspacePackages.length} packages`);
1138
+ for (const pkg of workspacePackages) {
1139
+ logger.item(`${farver.cyan(pkg.name)} (${farver.bold(pkg.version)})`);
1140
+ logger.item(` ${farver.gray("→")} ${farver.gray(pkg.path)}`);
1141
+ }
1142
+ logger.emptyLine();
1011
1143
  const packageCommits = await getWorkspacePackageCommits(workspaceRoot, workspacePackages);
1012
- const versionUpdates = await inferVersionUpdates({
1144
+ const globalCommitsPerPackage = await getGlobalCommitsPerPackage(workspaceRoot, packageCommits, workspacePackages, normalizedOptions.globalCommitMode);
1145
+ const { allUpdates, applyUpdates } = await calculateAndPrepareVersionUpdates({
1013
1146
  workspacePackages,
1014
1147
  packageCommits,
1015
1148
  workspaceRoot,
1016
1149
  showPrompt: options.prompts?.versions !== false,
1017
- allCommits: await getAllWorkspaceCommits(workspaceRoot),
1018
- globalCommitMode: options.globalCommitMode
1150
+ globalCommitsPerPackage
1019
1151
  });
1020
- if (versionUpdates.length === 0) logger.warn("No packages have changes requiring a release");
1021
- const allUpdates = createDependentUpdates(buildPackageDependencyGraph(workspacePackages), workspacePackages, versionUpdates);
1022
- const currentBranch = await getCurrentBranch(workspaceRoot);
1023
- if (currentBranch !== normalizedOptions.branch.default) exitWithError(`Current branch is '${currentBranch}'. Please switch to the default branch '${normalizedOptions.branch.default}' before proceeding.`, `git checkout ${normalizedOptions.branch.default}`);
1024
- const existingPullRequest = await getExistingPullRequest({
1152
+ if (allUpdates.filter((u) => u.hasDirectChanges).length === 0) logger.warn("No packages have changes requiring a release");
1153
+ logger.section("🔄 Version Updates");
1154
+ logger.item(`Updating ${allUpdates.length} packages (including dependents)`);
1155
+ for (const update of allUpdates) logger.item(`${update.package.name}: ${update.currentVersion} ${update.newVersion}`);
1156
+ const prOps = await orchestrateReleasePullRequest({
1157
+ workspaceRoot,
1025
1158
  owner: normalizedOptions.owner,
1026
1159
  repo: normalizedOptions.repo,
1027
- branch: normalizedOptions.branch.release,
1028
- githubToken: normalizedOptions.githubToken
1160
+ githubToken: normalizedOptions.githubToken,
1161
+ releaseBranch: normalizedOptions.branch.release,
1162
+ defaultBranch: normalizedOptions.branch.default,
1163
+ pullRequestTitle: options.pullRequest?.title,
1164
+ pullRequestBody: options.pullRequest?.body
1029
1165
  });
1030
- const prExists = !!existingPullRequest;
1031
- if (prExists) logger.log("Existing pull request found:", existingPullRequest.html_url);
1032
- else logger.log("No existing pull request found, will create new one");
1033
- const branchExists = await doesBranchExist(normalizedOptions.branch.release, workspaceRoot);
1034
- if (!branchExists) {
1035
- logger.log("Creating release branch:", normalizedOptions.branch.release);
1036
- await createBranch(normalizedOptions.branch.release, normalizedOptions.branch.default, workspaceRoot);
1037
- }
1038
- if (!await checkoutBranch(normalizedOptions.branch.release, workspaceRoot)) throw new Error(`Failed to checkout branch: ${normalizedOptions.branch.release}`);
1039
- if (branchExists) {
1040
- logger.log("Pulling latest changes from remote");
1041
- if (!await pullLatestChanges(normalizedOptions.branch.release, workspaceRoot)) logger.log("Warning: Failed to pull latest changes, continuing anyway");
1042
- }
1043
- logger.log("Rebasing release branch onto", normalizedOptions.branch.default);
1044
- await rebaseBranch(normalizedOptions.branch.default, workspaceRoot);
1045
- await updateAllPackageJsonFiles(allUpdates);
1046
- await updateChangelogs(versionUpdates, packageCommits, {
1047
- ...options.changelog,
1048
- repository: options.changelog?.repository || {
1049
- owner: normalizedOptions.owner,
1050
- repo: normalizedOptions.repo
1051
- }
1052
- });
1053
- const hasCommitted = await commitChanges("chore: update release versions", workspaceRoot);
1054
- const isBranchAhead = await isBranchAheadOfRemote(normalizedOptions.branch.release, workspaceRoot);
1055
- if (!hasCommitted && !isBranchAhead) {
1056
- logger.log("No changes to commit and branch is in sync with remote");
1057
- await checkoutBranch(normalizedOptions.branch.default, workspaceRoot);
1058
- if (prExists) {
1059
- logger.log("No updates needed, PR is already up to date");
1060
- return {
1061
- updates: allUpdates,
1062
- prUrl: existingPullRequest.html_url,
1063
- created: false
1064
- };
1065
- } else {
1066
- logger.error("No changes to commit, and no existing PR. Nothing to do.");
1067
- return null;
1068
- }
1166
+ await prOps.prepareBranch();
1167
+ await applyUpdates();
1168
+ if (!await prOps.commitAndPush(true)) if (prOps.doesReleasePRExist && prOps.existingPullRequest) {
1169
+ logger.item("No updates needed, PR is already up to date");
1170
+ return {
1171
+ updates: allUpdates,
1172
+ prUrl: prOps.existingPullRequest.html_url,
1173
+ created: false
1174
+ };
1175
+ } else {
1176
+ logger.error("No changes to commit, and no existing PR. Nothing to do.");
1177
+ return null;
1069
1178
  }
1070
- logger.log("Pushing changes to remote");
1071
- await pushBranch(normalizedOptions.branch.release, workspaceRoot, { forceWithLease: true });
1072
- const prTitle = existingPullRequest?.title || options.pullRequest?.title || "chore: update package versions";
1073
- const prBody = generatePullRequestBody(allUpdates, options.pullRequest?.body);
1074
- const pullRequest = await upsertPullRequest({
1075
- owner: normalizedOptions.owner,
1076
- repo: normalizedOptions.repo,
1077
- pullNumber: existingPullRequest?.number,
1078
- title: prTitle,
1079
- body: prBody,
1080
- head: normalizedOptions.branch.release,
1081
- base: normalizedOptions.branch.default,
1082
- githubToken: normalizedOptions.githubToken
1083
- });
1084
- logger.log(prExists ? "Updated pull request:" : "Created pull request:", pullRequest?.html_url);
1085
- await checkoutBranch(normalizedOptions.branch.default, workspaceRoot);
1179
+ const { pullRequest, created } = await prOps.createOrUpdatePullRequest(allUpdates);
1180
+ await prOps.checkoutDefaultBranch();
1086
1181
  if (pullRequest?.html_url) {
1087
- logger.info();
1088
- logger.info(`${farver.green("✓")} Pull request ${prExists ? "updated" : "created"}: ${farver.cyan(pullRequest.html_url)}`);
1182
+ logger.section("🚀 Pull Request");
1183
+ logger.success(`Pull request ${created ? "created" : "updated"}: ${pullRequest.html_url}`);
1089
1184
  }
1090
1185
  return {
1091
1186
  updates: allUpdates,
1092
1187
  prUrl: pullRequest?.html_url,
1093
- created: !prExists
1188
+ created
1189
+ };
1190
+ }
1191
+ async function normalizeReleaseOptions(options) {
1192
+ const normalized = normalizeSharedOptions(options);
1193
+ let defaultBranch = options.branch?.default?.trim();
1194
+ const releaseBranch = options.branch?.release?.trim() ?? "release/next";
1195
+ if (defaultBranch == null || defaultBranch === "") {
1196
+ defaultBranch = await getDefaultBranch(normalized.workspaceRoot);
1197
+ if (!defaultBranch) exitWithError("Could not determine default branch", "Please specify the default branch in options");
1198
+ }
1199
+ if (defaultBranch === releaseBranch) exitWithError(`Default branch and release branch cannot be the same: "${defaultBranch}"`, "Specify different branches for default and release");
1200
+ const availableBranches = await getAvailableBranches(normalized.workspaceRoot);
1201
+ if (!availableBranches.includes(defaultBranch)) exitWithError(`Default branch "${defaultBranch}" does not exist in the repository`, `Available branches: ${availableBranches.join(", ")}`);
1202
+ logger.verbose(`Using default branch: ${farver.green(defaultBranch)}`);
1203
+ return {
1204
+ ...normalized,
1205
+ branch: {
1206
+ release: releaseBranch,
1207
+ default: defaultBranch
1208
+ },
1209
+ safeguards: options.safeguards ?? true,
1210
+ globalCommitMode: options.globalCommitMode ?? "dependencies",
1211
+ pullRequest: options.pullRequest,
1212
+ changelog: { enabled: options.changelog?.enabled ?? true }
1213
+ };
1214
+ }
1215
+ async function orchestrateReleasePullRequest({ workspaceRoot, owner, repo, githubToken, releaseBranch, defaultBranch, pullRequestTitle, pullRequestBody }) {
1216
+ const currentBranch = await getCurrentBranch(workspaceRoot);
1217
+ if (currentBranch !== defaultBranch) exitWithError(`Current branch is '${currentBranch}'. Please switch to the default branch '${defaultBranch}' before proceeding.`, `git checkout ${defaultBranch}`);
1218
+ const existingPullRequest = await getExistingPullRequest({
1219
+ owner,
1220
+ repo,
1221
+ branch: releaseBranch,
1222
+ githubToken
1223
+ });
1224
+ const doesReleasePRExist = !!existingPullRequest;
1225
+ if (doesReleasePRExist) logger.item("Found existing release pull request");
1226
+ else logger.item("Will create new pull request");
1227
+ const branchExists = await doesBranchExist(releaseBranch, workspaceRoot);
1228
+ return {
1229
+ existingPullRequest,
1230
+ doesReleasePRExist,
1231
+ prepareBranch: async () => {
1232
+ if (!branchExists) await createBranch(releaseBranch, defaultBranch, workspaceRoot);
1233
+ logger.step(`Checking out release branch: ${releaseBranch}`);
1234
+ if (!await checkoutBranch(releaseBranch, workspaceRoot)) throw new Error(`Failed to checkout branch: ${releaseBranch}`);
1235
+ if (branchExists) {
1236
+ logger.step("Pulling latest changes from remote");
1237
+ if (!await pullLatestChanges(releaseBranch, workspaceRoot)) logger.warn("Failed to pull latest changes, continuing anyway");
1238
+ }
1239
+ logger.step(`Rebasing onto ${defaultBranch}`);
1240
+ if (!await rebaseBranch(defaultBranch, workspaceRoot)) throw new Error(`Failed to rebase onto ${defaultBranch}. Please resolve conflicts manually.`);
1241
+ },
1242
+ commitAndPush: async (hasChanges) => {
1243
+ const hasCommitted = hasChanges ? await commitChanges("chore: update release versions", workspaceRoot) : false;
1244
+ const isBranchAhead = await isBranchAheadOfRemote(releaseBranch, workspaceRoot);
1245
+ if (!hasCommitted && !isBranchAhead) {
1246
+ logger.item("No changes to commit and branch is in sync with remote");
1247
+ await checkoutBranch(defaultBranch, workspaceRoot);
1248
+ return false;
1249
+ }
1250
+ logger.step("Pushing changes to remote");
1251
+ if (!await pushBranch(releaseBranch, workspaceRoot, { forceWithLease: true })) throw new Error(`Failed to push changes to ${releaseBranch}. Remote may have been updated.`);
1252
+ return true;
1253
+ },
1254
+ createOrUpdatePullRequest: async (updates) => {
1255
+ const prTitle = existingPullRequest?.title || pullRequestTitle || "chore: update package versions";
1256
+ const prBody = pullRequestBody || generatePullRequestBody(updates);
1257
+ const pullRequest = await upsertPullRequest({
1258
+ owner,
1259
+ repo,
1260
+ pullNumber: existingPullRequest?.number,
1261
+ title: prTitle,
1262
+ body: prBody,
1263
+ head: releaseBranch,
1264
+ base: defaultBranch,
1265
+ githubToken
1266
+ });
1267
+ logger.success(`${doesReleasePRExist ? "Updated" : "Created"} pull request: ${pullRequest?.html_url}`);
1268
+ return {
1269
+ pullRequest,
1270
+ created: !doesReleasePRExist
1271
+ };
1272
+ },
1273
+ checkoutDefaultBranch: async () => {
1274
+ await checkoutBranch(defaultBranch, workspaceRoot);
1275
+ }
1094
1276
  };
1095
1277
  }
1096
1278