cherrypick-interactive 1.0.1 → 1.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.
Files changed (4) hide show
  1. package/biome.json +23 -0
  2. package/cli.backup.js +163 -156
  3. package/cli.js +685 -405
  4. package/package.json +14 -11
package/cli.js CHANGED
@@ -1,163 +1,416 @@
1
1
  #!/usr/bin/env node
2
- import chalk from 'chalk'
3
- import { promises as fsPromises } from 'fs'
4
- import inquirer from 'inquirer'
5
- import { spawn } from 'node:child_process'
6
- import simpleGit from 'simple-git'
7
- import yargs from 'yargs'
8
-
9
- import { hideBin } from 'yargs/helpers'
10
-
11
- const git = simpleGit()
2
+ import chalk from "chalk";
3
+ import { promises as fsPromises, readFileSync } from "node:fs";
4
+ import inquirer from "inquirer";
5
+ import { spawn } from "node:child_process";
6
+ import simpleGit from "simple-git";
7
+ import yargs from "yargs";
8
+ import { hideBin } from "yargs/helpers";
9
+ import updateNotifier from "update-notifier";
10
+ import { fileURLToPath } from "node:url";
11
+ import { dirname, join } from "node:path";
12
+
13
+ const git = simpleGit();
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
18
+
19
+ const notifier = updateNotifier({
20
+ pkg,
21
+ updateCheckInterval: 0, // 12h
22
+ });
23
+
24
+ // Only print if an update is available
25
+ if (notifier.update) {
26
+ const name = pkg.name || "cherrypick-interactive";
27
+ const current = notifier.update.current;
28
+ const latest = notifier.update.latest;
29
+ console.log("");
30
+ console.log(chalk.yellow("⚠️ A new version is available"));
31
+ console.log(chalk.gray(` ${name}: ${chalk.red(current)} → ${chalk.green(latest)}`));
32
+ console.log(chalk.cyan(` Update with: ${chalk.bold(`npm i -g ${name}`)}\n`));
33
+ }
12
34
 
13
35
  const argv = yargs(hideBin(process.argv))
14
- .scriptName('cherrypick-interactive')
15
- .usage('$0 [options]')
16
- .option('dev', {
17
- type: 'string',
18
- default: 'origin/dev',
19
- describe: 'Source branch (contains commits you want).'
20
- })
21
- .option('main', {
22
- type: 'string',
23
- default: 'origin/main',
24
- describe: 'Comparison branch (commits present here will be filtered out).'
25
- })
26
- .option('since', {
27
- type: 'string',
28
- default: '1 week ago',
29
- describe: 'Time window passed to git --since (e.g. "2 weeks ago", "1 month ago").'
30
- })
31
- .option('no-fetch', {
32
- type: 'boolean',
33
- default: false,
34
- describe: "Skip 'git fetch --prune'."
35
- })
36
- .option('all-yes', {
37
- type: 'boolean',
38
- default: false,
39
- describe: 'Non-interactive: cherry-pick ALL missing commits (oldest → newest).'
40
- })
41
- .option('dry-run', {
42
- type: 'boolean',
43
- default: false,
44
- describe: 'Print what would be cherry-picked and exit.'
45
- })
46
- .option('semantic-versioning', {
47
- type: 'boolean',
48
- default: false,
49
- describe: 'Compute next semantic version from selected (or missing) commits.'
50
- })
51
- .option('current-version', {
52
- type: 'string',
53
- describe: 'Current version (X.Y.Z). Required when --semantic-versioning is set.'
54
- })
55
- .option('create-release', {
56
- type: 'boolean',
57
- default: false,
58
- describe: 'Create a release branch from --main named release/<computed-version> before cherry-picking.'
59
- })
60
- .option('push-release', {
61
- type: 'boolean',
62
- default: true,
63
- describe: 'After creating the release branch, push and set upstream (origin).'
64
- })
65
- .option('draft-pr', {
66
- type: 'boolean',
67
- default: false,
68
- describe: 'Create the release PR as a draft.'
69
- })
70
- .option('version-file', {
71
- type: 'string',
72
- describe: 'Path to package.json (read current version; optional replacement for --current-version)'
73
- })
74
- .option('version-commit-message', {
75
- type: 'string',
76
- default: 'chore(release): bump version to {{version}}',
77
- describe: 'Commit message template for version bump. Use {{version}} placeholder.'
78
- })
79
- .help()
80
- .alias('h', 'help')
81
- .alias('v', 'version').argv
82
-
83
- const log = (...a) => console.log(...a)
84
- const err = (...a) => console.error(...a)
36
+ .scriptName("cherrypick-interactive")
37
+ .usage("$0 [options]")
38
+ .option("dev", {
39
+ type: "string",
40
+ default: "origin/dev",
41
+ describe: "Source branch (contains commits you want).",
42
+ })
43
+ .option("main", {
44
+ type: "string",
45
+ default: "origin/main",
46
+ describe: "Comparison branch (commits present here will be filtered out).",
47
+ })
48
+ .option("since", {
49
+ type: "string",
50
+ default: "1 week ago",
51
+ describe: 'Time window passed to git --since (e.g. "2 weeks ago", "1 month ago").',
52
+ })
53
+ .option("no-fetch", {
54
+ type: "boolean",
55
+ default: false,
56
+ describe: "Skip 'git fetch --prune'.",
57
+ })
58
+ .option("all-yes", {
59
+ type: "boolean",
60
+ default: false,
61
+ describe: "Non-interactive: cherry-pick ALL missing commits (oldest → newest).",
62
+ })
63
+ .option("dry-run", {
64
+ type: "boolean",
65
+ default: false,
66
+ describe: "Print what would be cherry-picked and exit.",
67
+ })
68
+ .option("semantic-versioning", {
69
+ type: "boolean",
70
+ default: true,
71
+ describe: "Compute next semantic version from selected (or missing) commits.",
72
+ })
73
+ .option("current-version", {
74
+ type: "string",
75
+ describe: "Current version (X.Y.Z). Required when --semantic-versioning is set.",
76
+ })
77
+ .option("create-release", {
78
+ type: "boolean",
79
+ default: true,
80
+ describe:
81
+ "Create a release branch from --main named release/<computed-version> before cherry-picking.",
82
+ })
83
+ .option("push-release", {
84
+ type: "boolean",
85
+ default: true,
86
+ describe: "After creating the release branch, push and set upstream (origin).",
87
+ })
88
+ .option("draft-pr", {
89
+ type: "boolean",
90
+ default: false,
91
+ describe: "Create the release PR as a draft.",
92
+ })
93
+ .option("version-file", {
94
+ type: "string",
95
+ default: "./package.json",
96
+ describe:
97
+ "Path to package.json (read current version; optional replacement for --current-version)",
98
+ })
99
+ .option("version-commit-message", {
100
+ type: "string",
101
+ default: "chore(release): bump version to {{version}}",
102
+ describe: "Commit message template for version bump. Use {{version}} placeholder.",
103
+ })
104
+ .wrap(200)
105
+ .help()
106
+ .alias("h", "help")
107
+ .alias("v", "version").argv;
108
+
109
+ const log = (...a) => console.log(...a);
110
+ const err = (...a) => console.error(...a);
85
111
 
86
112
  async function gitRaw(args) {
87
- const out = await git.raw(args)
88
- return out.trim()
113
+ const out = await git.raw(args);
114
+ return out.trim();
89
115
  }
90
116
 
91
117
  async function getSubjects(branch) {
92
- const out = await gitRaw(['log', '--no-merges', '--pretty=%s', branch])
93
- if (!out) {
94
- return new Set()
95
- }
96
- return new Set(out.split('\n').filter(Boolean))
118
+ const out = await gitRaw(["log", "--no-merges", "--pretty=%s", branch]);
119
+ if (!out) {
120
+ return new Set();
121
+ }
122
+ return new Set(out.split("\n").filter(Boolean));
97
123
  }
98
124
 
99
125
  async function getDevCommits(branch, since) {
100
- const out = await gitRaw(['log', '--no-merges', '--since=' + since, '--pretty=%H %s', branch])
101
-
102
- if (!out) {
103
- return []
104
- }
105
- return out.split('\n').map((line) => {
106
- const firstSpace = line.indexOf(' ')
107
- const hash = line.slice(0, firstSpace)
108
- const subject = line.slice(firstSpace + 1)
109
- return { hash, subject }
110
- })
126
+ const out = await gitRaw(["log", "--no-merges", "--since=" + since, "--pretty=%H %s", branch]);
127
+
128
+ if (!out) {
129
+ return [];
130
+ }
131
+ return out.split("\n").map((line) => {
132
+ const firstSpace = line.indexOf(" ");
133
+ const hash = line.slice(0, firstSpace);
134
+ const subject = line.slice(firstSpace + 1);
135
+ return { hash, subject };
136
+ });
111
137
  }
112
138
 
113
139
  function filterMissing(devCommits, mainSubjects) {
114
- return devCommits.filter(({ subject }) => !mainSubjects.has(subject))
140
+ return devCommits.filter(({ subject }) => !mainSubjects.has(subject));
115
141
  }
116
142
 
117
143
  async function selectCommitsInteractive(missing) {
118
- const choices = [
119
- new inquirer.Separator(chalk.gray('── Newest commits ──')),
120
- ...missing.map(({ hash, subject }, idx) => {
121
- // display-only trim to avoid accidental leading spaces
122
- const displaySubject = subject.replace(/^[\s\u00A0]+/, '')
123
- return {
124
- name: `${chalk.dim(`(${hash.slice(0, 7)})`)} ${displaySubject}`,
125
- value: hash,
126
- short: displaySubject,
127
- idx // we keep index for oldest→newest ordering later
128
- }
129
- }),
130
- new inquirer.Separator(chalk.gray('── Oldest commits ──'))
131
- ]
132
-
133
- const { selected } = await inquirer.prompt([
144
+ const choices = [
145
+ new inquirer.Separator(chalk.gray("── Newest commits ──")),
146
+ ...missing.map(({ hash, subject }, idx) => {
147
+ // display-only trim to avoid accidental leading spaces
148
+ const displaySubject = subject.replace(/^[\s\u00A0]+/, "");
149
+ return {
150
+ name: `${chalk.dim(`(${hash.slice(0, 7)})`)} ${displaySubject}`,
151
+ value: hash,
152
+ short: displaySubject,
153
+ idx, // we keep index for oldest→newest ordering later
154
+ };
155
+ }),
156
+ new inquirer.Separator(chalk.gray("── Oldest commits ──")),
157
+ ];
158
+ const termHeight = process.stdout.rows || 24; // fallback for non-TTY environments
159
+
160
+ const { selected } = await inquirer.prompt([
161
+ {
162
+ type: "checkbox",
163
+ name: "selected",
164
+ message: `Select commits to cherry-pick (${missing.length} missing):`,
165
+ choices,
166
+ pageSize: Math.max(10, Math.min(termHeight - 5, missing.length)),
167
+ },
168
+ ]);
169
+
170
+ return selected;
171
+ }
172
+
173
+ async function handleCherryPickConflict(hash) {
174
+ while (true) {
175
+ err(chalk.red(`\n✖ Cherry-pick has conflicts on ${hash} (${hash.slice(0, 7)}).`));
176
+ await showConflictsList(); // prints conflicted files (if any)
177
+
178
+ const { action } = await inquirer.prompt([
179
+ {
180
+ type: "list",
181
+ name: "action",
182
+ message: "Choose how to proceed:",
183
+ choices: [
184
+ { name: "Skip this commit", value: "skip" },
185
+ { name: "Resolve conflicts now", value: "resolve" },
186
+ { name: "Revoke and cancel (abort entire sequence)", value: "abort" },
187
+ ],
188
+ },
189
+ ]);
190
+
191
+ if (action === "skip") {
192
+ await gitRaw(["cherry-pick", "--skip"]);
193
+ log(chalk.yellow(`↷ Skipped commit ${chalk.dim(`(${hash.slice(0, 7)})`)}`));
194
+ return "skipped";
195
+ }
196
+
197
+ if (action === "abort") {
198
+ await gitRaw(["cherry-pick", "--abort"]);
199
+ throw new Error("Cherry-pick aborted by user.");
200
+ }
201
+
202
+ const res = await conflictsResolutionWizard(hash);
203
+ if (res === "continued") {
204
+ // Successfully continued; this commit is now applied
205
+ return "continued";
206
+ }
207
+ }
208
+ }
209
+
210
+ async function getConflictedFiles() {
211
+ const out = await gitRaw(["diff", "--name-only", "--diff-filter=U"]);
212
+ return out ? out.split("\n").filter(Boolean) : [];
213
+ }
214
+
215
+ async function assertNoUnmerged() {
216
+ const files = await getConflictedFiles();
217
+ return files.length === 0;
218
+ }
219
+
220
+ async function runBin(bin, args) {
221
+ return new Promise((resolve, reject) => {
222
+ const p = spawn(bin, args, { stdio: "inherit" });
223
+ p.on("error", reject);
224
+ p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`${bin} exited ${code}`))));
225
+ });
226
+ }
227
+
228
+ async function showConflictsList() {
229
+ const files = await getConflictedFiles();
230
+
231
+ if (!files.length) {
232
+ log(chalk.green("No conflicted files reported by git."));
233
+ return [];
234
+ }
235
+ err(chalk.yellow("Conflicted files:"));
236
+ for (const f of files) {
237
+ err(" - " + f);
238
+ }
239
+ return files;
240
+ }
241
+
242
+ async function resolveSingleFileWizard(file) {
243
+ const { action } = await inquirer.prompt([
244
+ {
245
+ type: "list",
246
+ name: "action",
247
+ message: `How to resolve "${file}"?`,
248
+ choices: [
249
+ { name: "Use ours (current branch)", value: "ours" },
250
+ { name: "Use theirs (picked commit)", value: "theirs" },
251
+ { name: "Open in editor", value: "edit" },
252
+ { name: "Show diff", value: "diff" },
253
+ { name: "Mark resolved (stage file)", value: "stage" },
254
+ { name: "Back", value: "back" },
255
+ ],
256
+ },
257
+ ]);
258
+
259
+ try {
260
+ if (action === "ours") {
261
+ await gitRaw(["checkout", "--ours", file]);
262
+ await git.add([file]);
263
+ log(chalk.green(`✓ Applied "ours" and staged: ${file}`));
264
+ } else if (action === "theirs") {
265
+ await gitRaw(["checkout", "--theirs", file]);
266
+ await git.add([file]);
267
+ log(chalk.green(`✓ Applied "theirs" and staged: ${file}`));
268
+ } else if (action === "edit") {
269
+ const editor = process.env.EDITOR || "vi";
270
+ log(chalk.cyan(`Opening ${file} in ${editor}...`));
271
+ await runBin(editor, [file]);
272
+ // user edits and saves, so now they can stage
273
+ const { stageNow } = await inquirer.prompt([
134
274
  {
135
- type: 'checkbox',
136
- name: 'selected',
137
- message: `Select commits to cherry-pick (${missing.length} missing):`,
138
- choices,
139
- pageSize: Math.min(20, Math.max(8, missing.length))
140
- }
141
- ])
275
+ type: "confirm",
276
+ name: "stageNow",
277
+ message: "File edited. Stage it now?",
278
+ default: true,
279
+ },
280
+ ]);
281
+ if (stageNow) {
282
+ await git.add([file]);
283
+ log(chalk.green(`✓ Staged: ${file}`));
284
+ }
285
+ } else if (action === "diff") {
286
+ const d = await gitRaw(["diff", file]);
287
+ err(chalk.gray(`\n--- diff: ${file} ---\n${d}\n--- end diff ---\n`));
288
+ } else if (action === "stage") {
289
+ await git.add([file]);
290
+ log(chalk.green(`✓ Staged: ${file}`));
291
+ }
292
+ } catch (e) {
293
+ err(chalk.red(`Action failed on ${file}: ${e.message || e}`));
294
+ }
142
295
 
143
- return selected
296
+ return action;
144
297
  }
145
298
 
146
- async function cherryPickSequential(hashes) {
147
- for (const hash of hashes) {
299
+ async function conflictsResolutionWizard(hash) {
300
+ // Loop until no conflicts remain and continue succeeds
301
+ while (true) {
302
+ const files = await showConflictsList();
303
+ if (files.length === 0) {
304
+ try {
305
+ await gitRaw(["cherry-pick", "--continue"]);
306
+ const subject = await gitRaw(["show", "--format=%s", "-s", hash]);
307
+ log(`${chalk.green("✓")} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
308
+ return "continued";
309
+ } catch (e) {
310
+ err(chalk.red("`git cherry-pick --continue` failed:"));
311
+ err(String(e.message || e));
312
+ // fall back to loop
313
+ }
314
+ }
315
+
316
+ const { choice } = await inquirer.prompt([
317
+ {
318
+ type: "list",
319
+ name: "choice",
320
+ message: "Select a file to resolve or a global action:",
321
+ pageSize: Math.min(20, Math.max(8, files.length + 5)),
322
+ choices: [
323
+ ...files.map((f) => ({ name: f, value: { type: "file", file: f } })),
324
+ new inquirer.Separator(chalk.gray("─ Actions ─")),
325
+ { name: "Use ours for ALL", value: { type: "all", action: "ours-all" } },
326
+ { name: "Use theirs for ALL", value: { type: "all", action: "theirs-all" } },
327
+ { name: "Stage ALL", value: { type: "all", action: "stage-all" } },
328
+ { name: "Launch mergetool (all)", value: { type: "all", action: "mergetool-all" } },
329
+ {
330
+ name: "Try to continue (run --continue)",
331
+ value: { type: "global", action: "continue" },
332
+ },
333
+ { name: "Back to main conflict menu", value: { type: "global", action: "back" } },
334
+ ],
335
+ },
336
+ ]);
337
+
338
+ if (!choice) {
339
+ continue;
340
+ }
341
+ if (choice.type === "file") {
342
+ await resolveSingleFileWizard(choice.file);
343
+ continue;
344
+ }
345
+
346
+ if (choice.type === "all") {
347
+ for (const f of files) {
348
+ if (choice.action === "ours-all") {
349
+ await gitRaw(["checkout", "--ours", f]);
350
+ await git.add([f]);
351
+ } else if (choice.action === "theirs-all") {
352
+ await gitRaw(["checkout", "--theirs", f]);
353
+ await git.add([f]);
354
+ } else if (choice.action === "stage-all") {
355
+ await git.add([f]);
356
+ } else if (choice.action === "mergetool-all") {
357
+ await runBin("git", ["mergetool"]);
358
+ break; // mergetool all opens sequentially; re-loop to re-check state
359
+ }
360
+ }
361
+ continue;
362
+ }
363
+
364
+ if (choice.type === "global" && choice.action === "continue") {
365
+ if (await assertNoUnmerged()) {
148
366
  try {
149
- await gitRaw(['cherry-pick', hash])
150
- const subject = await gitRaw(['show', '--format=%s', '-s', hash])
151
- log(`${chalk.green('')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`)
367
+ await gitRaw(["cherry-pick", "--continue"]);
368
+ const subject = await gitRaw(["show", "--format=%s", "-s", hash]);
369
+ log(`${chalk.green("")} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
370
+ return "continued";
152
371
  } catch (e) {
153
- err(chalk.red(`✖ Cherry-pick failed on ${hash}`))
154
- err(chalk.yellow('Resolve conflicts, then run:'))
155
- err(chalk.yellow(' git add -A && git cherry-pick --continue'))
156
- err(chalk.yellow('Or abort:'))
157
- err(chalk.yellow(' git cherry-pick --abort'))
158
- throw e
372
+ err(chalk.red("`--continue` failed. Resolve remaining issues and try again."));
159
373
  }
374
+ } else {
375
+ err(chalk.yellow("There are still unmerged files."));
376
+ }
377
+ }
378
+
379
+ if (choice.type === "global" && choice.action === "back") {
380
+ return "back";
160
381
  }
382
+ }
383
+ }
384
+
385
+ async function cherryPickSequential(hashes) {
386
+ const result = { applied: 0, skipped: 0 };
387
+
388
+ for (const hash of hashes) {
389
+ try {
390
+ await gitRaw(["cherry-pick", hash]);
391
+ const subject = await gitRaw(["show", "--format=%s", "-s", hash]);
392
+ log(`${chalk.green("✓")} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
393
+ result.applied += 1;
394
+ } catch (e) {
395
+ try {
396
+ const action = await handleCherryPickConflict(hash);
397
+ if (action === "skipped") {
398
+ result.skipped += 1;
399
+ continue;
400
+ }
401
+ if (action === "continued") {
402
+ // --continue başarıyla commit oluşturdu
403
+ result.applied += 1;
404
+ continue;
405
+ }
406
+ } catch (abortErr) {
407
+ err(chalk.red(`✖ Cherry-pick aborted on ${hash}`));
408
+ throw abortErr;
409
+ }
410
+ }
411
+ }
412
+
413
+ return result;
161
414
  }
162
415
 
163
416
  /**
@@ -165,347 +418,374 @@ async function cherryPickSequential(hashes) {
165
418
  * @returns {Promise<void>}
166
419
  */
167
420
  function parseVersion(v) {
168
- const m = String(v || '')
169
- .trim()
170
- .match(/^(\d+)\.(\d+)\.(\d+)$/)
171
- if (!m) {
172
- throw new Error(`Invalid --current-version "${v}". Expected X.Y.Z`)
173
- }
174
- return { major: +m[1], minor: +m[2], patch: +m[3] }
421
+ const m = String(v || "")
422
+ .trim()
423
+ .match(/^(\d+)\.(\d+)\.(\d+)$/);
424
+ if (!m) {
425
+ throw new Error(`Invalid --current-version "${v}". Expected X.Y.Z`);
426
+ }
427
+ return { major: +m[1], minor: +m[2], patch: +m[3] };
175
428
  }
176
429
 
177
430
  function incrementVersion(version, bump) {
178
- const cur = parseVersion(version)
179
- if (bump === 'major') {
180
- return `${cur.major + 1}.0.0`
181
- }
182
- if (bump === 'minor') {
183
- return `${cur.major}.${cur.minor + 1}.0`
184
- }
185
- if (bump === 'patch') {
186
- return `${cur.major}.${cur.minor}.${cur.patch + 1}`
187
- }
188
- return `${cur.major}.${cur.minor}.${cur.patch}`
431
+ const cur = parseVersion(version);
432
+ if (bump === "major") {
433
+ return `${cur.major + 1}.0.0`;
434
+ }
435
+ if (bump === "minor") {
436
+ return `${cur.major}.${cur.minor + 1}.0`;
437
+ }
438
+ if (bump === "patch") {
439
+ return `${cur.major}.${cur.minor}.${cur.patch + 1}`;
440
+ }
441
+ return `${cur.major}.${cur.minor}.${cur.patch}`;
189
442
  }
190
443
 
191
444
  function normalizeMessage(msg) {
192
- // normalize whitespace; keep case-insensitive matching
193
- return (msg || '').replace(/\r\n/g, '\n')
445
+ // normalize whitespace; keep case-insensitive matching
446
+ return (msg || "").replace(/\r\n/g, "\n");
194
447
  }
195
448
 
196
449
  // Returns "major" | "minor" | "patch" | null for a single commit message
197
450
  function classifySingleCommit(messageBody) {
198
- const body = normalizeMessage(messageBody)
451
+ const body = normalizeMessage(messageBody);
199
452
 
200
- // Major
201
- if (/\bBREAKING[- _]CHANGE(?:\([^)]+\))?\s*:?/i.test(body)) {
202
- return 'major'
203
- }
453
+ // Major
454
+ if (/\bBREAKING[- _]CHANGE(?:\([^)]+\))?\s*:?/i.test(body)) {
455
+ return "major";
456
+ }
204
457
 
205
- // Minor
206
- if (/(^|\n)\s*(\*?\s*)?feat(?:\([^)]+\))?\s*:?/i.test(body)) {
207
- return 'minor'
208
- }
458
+ // Minor
459
+ if (/(^|\n)\s*(\*?\s*)?feat(?:\([^)]+\))?\s*:?/i.test(body)) {
460
+ return "minor";
461
+ }
209
462
 
210
- // Patch
211
- if (/(^|\n)\s*(\*?\s*)?(fix|perf)(?:\([^)]+\))?\s*:?/i.test(body)) {
212
- return 'patch'
213
- }
463
+ // Patch
464
+ if (/(^|\n)\s*(\*?\s*)?(fix|perf)(?:\([^)]+\))?\s*:?/i.test(body)) {
465
+ return "patch";
466
+ }
214
467
 
215
- return null
468
+ return null;
216
469
  }
217
470
 
218
471
  // Given many commits, collapse to a single bump level
219
472
  function collapseBumps(levels) {
220
- if (levels.includes('major')) {
221
- return 'major'
222
- }
223
- if (levels.includes('minor')) {
224
- return 'minor'
225
- }
226
- if (levels.includes('patch')) {
227
- return 'patch'
228
- }
229
- return null
473
+ if (levels.includes("major")) {
474
+ return "major";
475
+ }
476
+ if (levels.includes("minor")) {
477
+ return "minor";
478
+ }
479
+ if (levels.includes("patch")) {
480
+ return "patch";
481
+ }
482
+ return null;
230
483
  }
231
484
 
232
485
  // Fetch full commit messages (%B) for SHAs and compute bump
233
486
  async function computeSemanticBumpForCommits(hashes, gitRawFn) {
234
- if (!hashes.length) {
235
- return null
487
+ if (!hashes.length) {
488
+ return null;
489
+ }
490
+
491
+ const levels = [];
492
+ for (const h of hashes) {
493
+ const msg = await gitRawFn(["show", "--format=%B", "-s", h]);
494
+ const level = classifySingleCommit(msg);
495
+ if (level) {
496
+ levels.push(level);
236
497
  }
237
-
238
- const levels = []
239
- for (const h of hashes) {
240
- const msg = await gitRawFn(['show', '--format=%B', '-s', h])
241
- const level = classifySingleCommit(msg)
242
- if (level) {
243
- levels.push(level)
244
- }
245
- if (level === 'major') {
246
- break
247
- } // early exit if major is found
248
- }
249
- return collapseBumps(levels)
498
+ if (level === "major") {
499
+ break;
500
+ } // early exit if major is found
501
+ }
502
+ return collapseBumps(levels);
250
503
  }
251
504
  async function main() {
252
- try {
253
- if (!argv['no-fetch']) {
254
- log(chalk.gray('Fetching remotes (git fetch --prune)...'))
255
- await git.fetch(['--prune'])
256
- }
257
-
258
- const currentBranch = (await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD'])) || 'HEAD'
259
-
260
- log(chalk.gray(`Comparing subjects since ${argv.since}`))
261
- log(chalk.gray(`Dev: ${argv.dev}`))
262
- log(chalk.gray(`Main: ${argv.main}`))
263
-
264
- const [devCommits, mainSubjects] = await Promise.all([getDevCommits(argv.dev, argv.since), getSubjects(argv.main)])
265
-
266
- const missing = filterMissing(devCommits, mainSubjects)
267
-
268
- if (missing.length === 0) {
269
- log(chalk.green('✅ No missing commits found in the selected window.'))
270
- return
271
- }
272
-
273
- const indexByHash = new Map(missing.map((c, i) => [c.hash, i])) // 0=newest, larger=older
274
-
275
- let selected
276
- if (argv['all-yes']) {
277
- selected = missing.map((m) => m.hash)
278
- } else {
279
- selected = await selectCommitsInteractive(missing)
280
- if (!selected.length) {
281
- log(chalk.yellow('No commits selected. Exiting.'))
282
- return
283
- }
284
- }
285
-
286
- const bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a))
287
-
288
- if (argv.dry_run || argv['dry-run']) {
289
- log(chalk.cyan('\n--dry-run: would cherry-pick (oldest → newest):'))
290
- for (const h of bottomToTop) {
291
- const subj = await gitRaw(['show', '--format=%s', '-s', h])
292
- log(`- ${chalk.dim(`(${h.slice(0, 7)})`)} ${subj}`)
293
- }
294
- return
295
- }
505
+ try {
506
+ if (!argv["no-fetch"]) {
507
+ log(chalk.gray("Fetching remotes (git fetch --prune)..."));
508
+ await git.fetch(["--prune"]);
509
+ }
296
510
 
297
- if (argv['version-file'] && !argv['current-version']) {
298
- const currentVersionFromPkg = await getPkgVersion(argv['version-file'])
299
- argv['current-version'] = currentVersionFromPkg
300
- }
511
+ const currentBranch = (await gitRaw(["rev-parse", "--abbrev-ref", "HEAD"])) || "HEAD";
301
512
 
302
- let computedNextVersion = argv['current-version']
303
- if (argv['semantic-versioning']) {
304
- if (!argv['current-version']) {
305
- throw new Error(' --semantic-versioning requires --current-version X.Y.Z (or pass --version-file)')
306
- }
513
+ log(chalk.gray(`Comparing subjects since ${argv.since}`));
514
+ log(chalk.gray(`Dev: ${argv.dev}`));
515
+ log(chalk.gray(`Main: ${argv.main}`));
307
516
 
308
- // Bump is based on the commits you are about to apply (selected).
309
- const bump = await computeSemanticBumpForCommits(bottomToTop, gitRaw)
517
+ const [devCommits, mainSubjects] = await Promise.all([
518
+ getDevCommits(argv.dev, argv.since),
519
+ getSubjects(argv.main),
520
+ ]);
310
521
 
311
- computedNextVersion = bump ? incrementVersion(argv['current-version'], bump) : argv['current-version']
522
+ const missing = filterMissing(devCommits, mainSubjects);
312
523
 
313
- log('')
314
- log(chalk.magenta('Semantic Versioning'))
315
- log(
316
- ` Current: ${chalk.bold(argv['current-version'])} ` +
317
- `Detected bump: ${chalk.bold(bump || 'none')} ` +
318
- `Next: ${chalk.bold(computedNextVersion)}`
319
- )
320
- }
524
+ if (missing.length === 0) {
525
+ log(chalk.green("✅ No missing commits found in the selected window."));
526
+ return;
527
+ }
321
528
 
322
- if (argv['create-release']) {
323
- if (!argv['semantic-versioning'] || !argv['current-version']) {
324
- throw new Error(' --create-release requires --semantic-versioning and --current-version X.Y.Z')
325
- }
326
- if (!computedNextVersion) {
327
- throw new Error('Unable to determine release version. Check semantic-versioning inputs.')
328
- }
529
+ const indexByHash = new Map(missing.map((c, i) => [c.hash, i])); // 0=newest, larger=older
530
+
531
+ let selected;
532
+ if (argv["all-yes"]) {
533
+ selected = missing.map((m) => m.hash);
534
+ } else {
535
+ selected = await selectCommitsInteractive(missing);
536
+ if (!selected.length) {
537
+ log(chalk.yellow("No commits selected. Exiting."));
538
+ return;
539
+ }
540
+ }
329
541
 
330
- const releaseBranch = `release/${computedNextVersion}`
331
- await ensureBranchDoesNotExistLocally(releaseBranch)
332
- const startPoint = argv.main // e.g., 'origin/main' or a local ref
542
+ const bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a));
333
543
 
334
- const changelogBody = await buildChangelogBody({
335
- version: computedNextVersion,
336
- hashes: bottomToTop,
337
- gitRawFn: gitRaw
338
- })
544
+ if (argv.dry_run || argv["dry-run"]) {
545
+ log(chalk.cyan("\n--dry-run: would cherry-pick (oldest → newest):"));
546
+ for (const h of bottomToTop) {
547
+ const subj = await gitRaw(["show", "--format=%s", "-s", h]);
548
+ log(`- ${chalk.dim(`(${h.slice(0, 7)})`)} ${subj}`);
549
+ }
550
+ return;
551
+ }
339
552
 
340
- await fsPromises.writeFile('RELEASE_CHANGELOG.md', changelogBody, 'utf8')
341
- log(chalk.gray(`✅ Generated changelog for ${releaseBranch} → RELEASE_CHANGELOG.md`))
553
+ if (argv["version-file"] && !argv["current-version"]) {
554
+ const currentVersionFromPkg = await getPkgVersion(argv["version-file"]);
555
+ argv["current-version"] = currentVersionFromPkg;
556
+ }
342
557
 
343
- log(chalk.cyan(`\nCreating ${chalk.bold(releaseBranch)} from ${chalk.bold(startPoint)}...`))
558
+ let computedNextVersion = argv["current-version"];
559
+ if (argv["semantic-versioning"]) {
560
+ if (!argv["current-version"]) {
561
+ throw new Error(
562
+ " --semantic-versioning requires --current-version X.Y.Z (or pass --version-file)",
563
+ );
564
+ }
565
+
566
+ // Bump is based on the commits you are about to apply (selected).
567
+ const bump = await computeSemanticBumpForCommits(bottomToTop, gitRaw);
568
+
569
+ computedNextVersion = bump
570
+ ? incrementVersion(argv["current-version"], bump)
571
+ : argv["current-version"];
572
+
573
+ log("");
574
+ log(chalk.magenta("Semantic Versioning"));
575
+ log(
576
+ ` Current: ${chalk.bold(argv["current-version"])} ` +
577
+ `Detected bump: ${chalk.bold(bump || "none")} ` +
578
+ `Next: ${chalk.bold(computedNextVersion)}`,
579
+ );
580
+ }
344
581
 
345
- await git.checkoutBranch(releaseBranch, startPoint)
582
+ if (argv["create-release"]) {
583
+ if (!argv["semantic-versioning"] || !argv["current-version"]) {
584
+ throw new Error(
585
+ " --create-release requires --semantic-versioning and --current-version X.Y.Z",
586
+ );
587
+ }
588
+ if (!computedNextVersion) {
589
+ throw new Error("Unable to determine release version. Check semantic-versioning inputs.");
590
+ }
591
+
592
+ const releaseBranch = `release/${computedNextVersion}`;
593
+ await ensureBranchDoesNotExistLocally(releaseBranch);
594
+ const startPoint = argv.main; // e.g., 'origin/main' or a local ref
595
+
596
+ const changelogBody = await buildChangelogBody({
597
+ version: computedNextVersion,
598
+ hashes: bottomToTop,
599
+ gitRawFn: gitRaw,
600
+ });
601
+
602
+ await fsPromises.writeFile("RELEASE_CHANGELOG.md", changelogBody, "utf8");
603
+ log(chalk.gray(`✅ Generated changelog for ${releaseBranch} → RELEASE_CHANGELOG.md`));
604
+
605
+ log(chalk.cyan(`\nCreating ${chalk.bold(releaseBranch)} from ${chalk.bold(startPoint)}...`));
606
+
607
+ await git.checkoutBranch(releaseBranch, startPoint);
608
+
609
+ log(chalk.green(`✓ Ready on ${chalk.bold(releaseBranch)}. Cherry-picking will apply here.`));
610
+ } else {
611
+ // otherwise we stay on the current branch
612
+ log(chalk.bold(`Base branch: ${currentBranch}`));
613
+ }
346
614
 
347
- log(chalk.green(`✓ Ready on ${chalk.bold(releaseBranch)}. Cherry-picking will apply here.`))
348
- } else {
349
- // otherwise we stay on the current branch
350
- log(chalk.bold(`Base branch: ${currentBranch}`))
351
- }
615
+ log(
616
+ chalk.cyan(
617
+ `\nCherry-picking ${bottomToTop.length} commit(s) onto ${currentBranch} (oldest newest)...\n`,
618
+ ),
619
+ );
620
+
621
+ const stats = await cherryPickSequential(bottomToTop);
622
+
623
+ log(chalk.gray(`\nSummary → applied: ${stats.applied}, skipped: ${stats.skipped}`));
624
+
625
+ if (stats.applied === 0) {
626
+ err(
627
+ chalk.yellow("\nNo commits were cherry-picked (all were skipped or unresolved). Aborting."),
628
+ );
629
+ // Abort any leftover state just in case
630
+ try {
631
+ await gitRaw(["cherry-pick", "--abort"]);
632
+ } catch {}
633
+ throw new Error("Nothing cherry-picked");
634
+ }
352
635
 
353
- log(chalk.cyan(`\nCherry-picking ${bottomToTop.length} commit(s) onto ${currentBranch} (oldest → newest)...\n`))
354
-
355
- await cherryPickSequential(bottomToTop)
356
-
357
- if (argv['push-release']) {
358
- const baseBranchForGh = stripOrigin(argv.main) // 'origin/main' -> 'main'
359
- const prTitle = `Release ${computedNextVersion}`
360
- const releaseBranch = `release/${computedNextVersion}`
361
-
362
- const onBranch = await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD'])
363
- if (!onBranch.startsWith(releaseBranch)) {
364
- throw new Error(`Version update should happen on a release branch. Current: ${onBranch}`)
365
- }
366
-
367
- log(chalk.cyan(`\nUpdating ${argv['version-file']} version → ${computedNextVersion} ...`))
368
- await setPkgVersion(argv['version-file'], computedNextVersion)
369
- await git.add([argv['version-file']])
370
- const msg = argv['version-commit-message'].replace('{{version}}', computedNextVersion)
371
- await git.raw(['commit', '--no-verify', '-m', msg]);
372
-
373
- log(chalk.green(`✓ package.json updated and committed: ${msg}`))
374
-
375
- await git.push(['-u', 'origin', releaseBranch, '--no-verify'])
376
-
377
- const ghArgs = [
378
- 'pr',
379
- 'create',
380
- '--base',
381
- baseBranchForGh,
382
- '--head',
383
- releaseBranch,
384
- '--title',
385
- prTitle,
386
- '--body-file',
387
- 'RELEASE_CHANGELOG.md'
388
- ]
389
- if (argv['draft-pr']) {
390
- ghArgs.push('--draft')
391
- }
392
-
393
- await runGh(ghArgs)
394
- log(chalk.gray(`Pushed ${onBranch} with version bump.`))
395
- }
636
+ if (argv["push-release"]) {
637
+ const baseBranchForGh = stripOrigin(argv.main); // 'origin/main' -> 'main'
638
+ const prTitle = `Release ${computedNextVersion}`;
639
+ const releaseBranch = `release/${computedNextVersion}`;
640
+
641
+ const onBranch = await gitRaw(["rev-parse", "--abbrev-ref", "HEAD"]);
642
+ if (!onBranch.startsWith(releaseBranch)) {
643
+ throw new Error(`Version update should happen on a release branch. Current: ${onBranch}`);
644
+ }
645
+
646
+ log(chalk.cyan(`\nUpdating ${argv["version-file"]} version → ${computedNextVersion} ...`));
647
+ await setPkgVersion(argv["version-file"], computedNextVersion);
648
+ await git.add([argv["version-file"]]);
649
+ const msg = argv["version-commit-message"].replace("{{version}}", computedNextVersion);
650
+ await git.raw(["commit", "--no-verify", "-m", msg]);
651
+
652
+ log(chalk.green(`✓ package.json updated and committed: ${msg}`));
653
+
654
+ await git.push(["-u", "origin", releaseBranch, "--no-verify"]);
655
+
656
+ const ghArgs = [
657
+ "pr",
658
+ "create",
659
+ "--base",
660
+ baseBranchForGh,
661
+ "--head",
662
+ releaseBranch,
663
+ "--title",
664
+ prTitle,
665
+ "--body-file",
666
+ "RELEASE_CHANGELOG.md",
667
+ ];
668
+ if (argv["draft-pr"]) {
669
+ ghArgs.push("--draft");
670
+ }
671
+
672
+ await runGh(ghArgs);
673
+ log(chalk.gray(`Pushed ${onBranch} with version bump.`));
674
+ }
396
675
 
397
- const finalBranch = argv['create-release']
398
- ? await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD']) // should be release/*
399
- : currentBranch
676
+ const finalBranch = argv["create-release"]
677
+ ? await gitRaw(["rev-parse", "--abbrev-ref", "HEAD"]) // should be release/*
678
+ : currentBranch;
400
679
 
401
- log(chalk.green(`\n✅ Done on ${finalBranch}`))
402
- } catch (e) {
403
- err(chalk.red(`\n❌ Error: ${e.message || e}`))
404
- process.exit(1)
405
- }
680
+ log(chalk.green(`\n✅ Done on ${finalBranch}`));
681
+ } catch (e) {
682
+ err(chalk.red(`\n❌ Error: ${e.message || e}`));
683
+ process.exit(1);
684
+ }
406
685
  }
407
686
 
408
- main()
687
+ main();
409
688
 
410
689
  /**
411
690
  * Utils
412
691
  */
413
692
 
414
693
  async function ensureBranchDoesNotExistLocally(branchName) {
415
- const branches = await git.branchLocal()
416
- if (branches.all.includes(branchName)) {
417
- throw new Error(
418
- `Release branch "${branchName}" already exists locally. ` + `Please delete it or choose a different version.`
419
- )
420
- }
694
+ const branches = await git.branchLocal();
695
+ if (branches.all.includes(branchName)) {
696
+ throw new Error(
697
+ `Release branch "${branchName}" already exists locally. ` +
698
+ `Please delete it or choose a different version.`,
699
+ );
700
+ }
421
701
  }
422
702
 
423
703
  async function buildChangelogBody({ version, hashes, gitRawFn }) {
424
- const today = new Date().toISOString().slice(0, 10)
425
- const header = version ? `## Release ${version} — ${today}` : `## Release — ${today}`
426
-
427
- const breakings = []
428
- const features = []
429
- const fixes = []
430
- const others = []
431
-
432
- for (const h of hashes) {
433
- const msg = await gitRawFn(['show', '--format=%B', '-s', h])
434
- const level = classifySingleCommit(msg)
435
-
436
- const subject = msg.split(/\r?\n/)[0].trim() // first line of commit message
437
- const shaDisplay = shortSha(h)
438
-
439
- switch (level) {
440
- case 'major':
441
- breakings.push(`${shaDisplay} ${subject}`)
442
- break
443
- case 'minor':
444
- features.push(`${shaDisplay} ${subject}`)
445
- break
446
- case 'patch':
447
- fixes.push(`${shaDisplay} ${subject}`)
448
- break
449
- default:
450
- others.push(`${shaDisplay} ${subject}`)
451
- break
452
- }
453
- }
454
-
455
- const sections = []
456
- if (breakings.length) {
457
- sections.push(`### ✨ Breaking Changes\n${breakings.join('\n')}`)
458
- }
459
- if (features.length) {
460
- sections.push(`### ✨ Features\n${features.join('\n')}`)
461
- }
462
- if (fixes.length) {
463
- sections.push(`### 🐛 Fixes\n${fixes.join('\n')}`)
704
+ const today = new Date().toISOString().slice(0, 10);
705
+ const header = version ? `## Release ${version} — ${today}` : `## Release — ${today}`;
706
+
707
+ const breakings = [];
708
+ const features = [];
709
+ const fixes = [];
710
+ const others = [];
711
+
712
+ for (const h of hashes) {
713
+ const msg = await gitRawFn(["show", "--format=%B", "-s", h]);
714
+ const level = classifySingleCommit(msg);
715
+
716
+ const subject = msg.split(/\r?\n/)[0].trim(); // first line of commit message
717
+ const shaDisplay = shortSha(h);
718
+
719
+ switch (level) {
720
+ case "major":
721
+ breakings.push(`${shaDisplay} ${subject}`);
722
+ break;
723
+ case "minor":
724
+ features.push(`${shaDisplay} ${subject}`);
725
+ break;
726
+ case "patch":
727
+ fixes.push(`${shaDisplay} ${subject}`);
728
+ break;
729
+ default:
730
+ others.push(`${shaDisplay} ${subject}`);
731
+ break;
464
732
  }
465
- if (others.length) {
466
- sections.push(`### 🧹 Others\n${others.join('\n')}`)
467
- }
468
-
469
- return `${header}\n\n${sections.join('\n\n')}\n`
733
+ }
734
+
735
+ const sections = [];
736
+ if (breakings.length) {
737
+ sections.push(`### ✨ Breaking Changes\n${breakings.join("\n")}`);
738
+ }
739
+ if (features.length) {
740
+ sections.push(`### ✨ Features\n${features.join("\n")}`);
741
+ }
742
+ if (fixes.length) {
743
+ sections.push(`### 🐛 Fixes\n${fixes.join("\n")}`);
744
+ }
745
+ if (others.length) {
746
+ sections.push(`### 🧹 Others\n${others.join("\n")}`);
747
+ }
748
+
749
+ return `${header}\n\n${sections.join("\n\n")}\n`;
470
750
  }
471
751
  function shortSha(sha) {
472
- return String(sha).slice(0, 7)
752
+ return String(sha).slice(0, 7);
473
753
  }
474
754
 
475
755
  function stripOrigin(ref) {
476
- return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref
756
+ return ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
477
757
  }
478
758
 
479
759
  async function runGh(args) {
480
- return new Promise((resolve, reject) => {
481
- const p = spawn('gh', args, { stdio: 'inherit' })
482
- p.on('error', reject)
483
- p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`gh exited ${code}`))))
484
- })
760
+ return new Promise((resolve, reject) => {
761
+ const p = spawn("gh", args, { stdio: "inherit" });
762
+ p.on("error", reject);
763
+ p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`gh exited ${code}`))));
764
+ });
485
765
  }
486
766
  async function readJson(filePath) {
487
- const raw = await fsPromises.readFile(filePath, 'utf8')
488
- return JSON.parse(raw)
767
+ const raw = await fsPromises.readFile(filePath, "utf8");
768
+ return JSON.parse(raw);
489
769
  }
490
770
 
491
771
  async function writeJson(filePath, data) {
492
- const text = JSON.stringify(data, null, 2) + '\n'
493
- await fsPromises.writeFile(filePath, text, 'utf8')
772
+ const text = JSON.stringify(data, null, 2) + "\n";
773
+ await fsPromises.writeFile(filePath, text, "utf8");
494
774
  }
495
775
 
496
776
  /** Read package.json version; throw if missing */
497
777
  async function getPkgVersion(pkgPath) {
498
- const pkg = await readJson(pkgPath)
499
- const v = pkg && pkg.version
500
- if (!v) {
501
- throw new Error(`No "version" field found in ${pkgPath}`)
502
- }
503
- return v
778
+ const pkg = await readJson(pkgPath);
779
+ const v = pkg && pkg.version;
780
+ if (!v) {
781
+ throw new Error(`No "version" field found in ${pkgPath}`);
782
+ }
783
+ return v;
504
784
  }
505
785
 
506
786
  /** Update package.json version in-place */
507
787
  async function setPkgVersion(pkgPath, nextVersion) {
508
- const pkg = await readJson(pkgPath)
509
- pkg.version = nextVersion
510
- await writeJson(pkgPath, pkg)
788
+ const pkg = await readJson(pkgPath);
789
+ pkg.version = nextVersion;
790
+ await writeJson(pkgPath, pkg);
511
791
  }