cherrypick-interactive 1.5.0 → 1.6.1

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 (3) hide show
  1. package/README.md +144 -28
  2. package/cli.js +856 -802
  3. package/package.json +11 -7
package/cli.js CHANGED
@@ -1,523 +1,522 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
3
- import { promises as fsPromises, readFileSync } from "node:fs";
4
- import { dirname, join } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import chalk from "chalk";
7
- import inquirer from "inquirer";
8
- import semver from "semver";
9
- import simpleGit from "simple-git";
10
- import updateNotifier from "update-notifier";
11
- import yargs from "yargs";
12
- import { hideBin } from "yargs/helpers";
2
+ import { spawn } from 'node:child_process';
3
+ import { promises as fsPromises, readFileSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import chalk from 'chalk';
7
+ import inquirer from 'inquirer';
8
+ import semver from 'semver';
9
+ import simpleGit from 'simple-git';
10
+ import updateNotifier from 'update-notifier';
11
+ import yargs from 'yargs';
12
+ import { hideBin } from 'yargs/helpers';
13
13
 
14
14
  const git = simpleGit();
15
15
 
16
16
  const __filename = fileURLToPath(import.meta.url);
17
17
  const __dirname = dirname(__filename);
18
18
 
19
- const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
19
+ const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
20
20
 
21
21
  const notifier = updateNotifier({
22
- pkg,
22
+ pkg,
23
23
  });
24
24
 
25
25
  // Only print if a *real* newer version exists
26
26
  const upd = notifier.update;
27
- if (
28
- upd &&
29
- semver.valid(upd.latest) &&
30
- semver.valid(pkg.version) &&
31
- semver.gt(upd.latest, pkg.version)
32
- ) {
33
- const name = pkg.name || "cherrypick-interactive";
34
- console.log("");
35
- console.log(chalk.yellow("⚠️ A new version is available"));
36
- console.log(chalk.gray(` ${name}: ${chalk.red(pkg.version)} → ${chalk.green(upd.latest)}`));
37
- console.log(chalk.cyan(` Update with: ${chalk.bold(`npm i -g ${name}`)}\n`));
27
+ if (upd && semver.valid(upd.latest) && semver.valid(pkg.version) && semver.gt(upd.latest, pkg.version)) {
28
+ const name = pkg.name || 'cherrypick-interactive';
29
+ console.log('');
30
+ console.log(chalk.yellow('⚠️ A new version is available'));
31
+ console.log(chalk.gray(` ${name}: ${chalk.red(pkg.version)} → ${chalk.green(upd.latest)}`));
32
+ console.log(chalk.cyan(` Update with: ${chalk.bold(`npm i -g ${name}`)}\n`));
38
33
  }
39
34
 
40
35
  const argv = yargs(hideBin(process.argv))
41
- .scriptName("cherrypick-interactive")
42
- .usage("$0 [options]")
43
- .option("dev", {
44
- type: "string",
45
- default: "origin/dev",
46
- describe: "Source branch (contains commits you want).",
47
- })
48
- .option("main", {
49
- type: "string",
50
- default: "origin/main",
51
- describe: "Comparison branch (commits present here will be filtered out).",
52
- })
53
- .option("since", {
54
- type: "string",
55
- default: "1 week ago",
56
- describe: 'Time window passed to git --since (e.g. "2 weeks ago", "1 month ago").',
57
- })
58
- .option("no-fetch", {
59
- type: "boolean",
60
- default: false,
61
- describe: "Skip 'git fetch --prune'.",
62
- })
63
- .option("all-yes", {
64
- type: "boolean",
65
- default: false,
66
- describe: "Non-interactive: cherry-pick ALL missing commits (oldest → newest).",
67
- })
68
- .option("dry-run", {
69
- type: "boolean",
70
- default: false,
71
- describe: "Print what would be cherry-picked and exit.",
72
- })
73
- .option("semantic-versioning", {
74
- type: "boolean",
75
- default: true,
76
- describe: "Compute next semantic version from selected (or missing) commits.",
77
- })
78
- .option("current-version", {
79
- type: "string",
80
- describe: "Current version (X.Y.Z). Required when --semantic-versioning is set.",
81
- })
82
- .option("create-release", {
83
- type: "boolean",
84
- default: true,
85
- describe:
86
- "Create a release branch from --main named release/<computed-version> before cherry-picking.",
87
- })
88
- .option("push-release", {
89
- type: "boolean",
90
- default: true,
91
- describe: "After creating the release branch, push and set upstream (origin).",
92
- })
93
- .option("draft-pr", {
94
- type: "boolean",
95
- default: false,
96
- describe: "Create the release PR as a draft.",
97
- })
98
- .option("version-file", {
99
- type: "string",
100
- default: "./package.json",
101
- describe:
102
- "Path to package.json (read current version; optional replacement for --current-version)",
103
- })
104
- .option("version-commit-message", {
105
- type: "string",
106
- default: "chore(release): bump version to {{version}}",
107
- describe: "Commit message template for version bump. Use {{version}} placeholder.",
108
- })
109
- .option("semver-ignore", {
110
- type: "string",
111
- describe:
112
- "Comma-separated regex patterns. If a commit message matches any, it will be treated as a chore for semantic versioning.",
113
- })
114
- .wrap(200)
115
- .help()
116
- .alias("h", "help")
117
- .alias("v", "version").argv;
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: 'Create a release branch from --main named release/<computed-version> before cherry-picking.',
81
+ })
82
+ .option('push-release', {
83
+ type: 'boolean',
84
+ default: true,
85
+ describe: 'After creating the release branch, push and set upstream (origin).',
86
+ })
87
+ .option('draft-pr', {
88
+ type: 'boolean',
89
+ default: false,
90
+ describe: 'Create the release PR as a draft.',
91
+ })
92
+ .option('version-file', {
93
+ type: 'string',
94
+ default: './package.json',
95
+ describe: 'Path to package.json (read current version; optional replacement for --current-version)',
96
+ })
97
+ .option('version-commit-message', {
98
+ type: 'string',
99
+ default: 'chore(release): bump version to {{version}}',
100
+ describe: 'Commit message template for version bump. Use {{version}} placeholder.',
101
+ })
102
+ .option('ignore-semver', {
103
+ type: 'string',
104
+ describe:
105
+ 'Comma-separated regex patterns. If a commit message matches any, it will be treated as a chore for semantic versioning.',
106
+ })
107
+ .option('ignore-commits', {
108
+ type: 'string',
109
+ describe:
110
+ 'Comma-separated regex patterns. If a commit message matches any, it will be omitted from the commit list.',
111
+ })
112
+ .wrap(200)
113
+ .help()
114
+ .alias('h', 'help')
115
+ .alias('v', 'version').argv;
118
116
 
119
117
  const log = (...a) => console.log(...a);
120
118
  const err = (...a) => console.error(...a);
121
119
 
122
120
  async function gitRaw(args) {
123
- const out = await git.raw(args);
124
- return out.trim();
121
+ const out = await git.raw(args);
122
+ return out.trim();
125
123
  }
126
124
 
127
125
  async function getSubjects(branch) {
128
- const out = await gitRaw(["log", "--no-merges", "--pretty=%s", branch]);
129
- if (!out) {
130
- return new Set();
131
- }
132
- return new Set(out.split("\n").filter(Boolean));
126
+ const out = await gitRaw(['log', '--no-merges', '--pretty=%s', branch]);
127
+ if (!out) {
128
+ return new Set();
129
+ }
130
+ return new Set(out.split('\n').filter(Boolean));
133
131
  }
134
132
 
135
133
  async function getDevCommits(branch, since) {
136
- const out = await gitRaw(["log", "--no-merges", `--since=${since}`, "--pretty=%H %s", branch]);
134
+ const out = await gitRaw(['log', '--no-merges', `--since=${since}`, '--pretty=%H %s', branch]);
137
135
 
138
- if (!out) {
139
- return [];
140
- }
141
- return out.split("\n").map((line) => {
142
- const firstSpace = line.indexOf(" ");
143
- const hash = line.slice(0, firstSpace);
144
- const subject = line.slice(firstSpace + 1);
145
- return { hash, subject };
146
- });
136
+ if (!out) {
137
+ return [];
138
+ }
139
+ return out.split('\n').map((line) => {
140
+ const firstSpace = line.indexOf(' ');
141
+ const hash = line.slice(0, firstSpace);
142
+ const subject = line.slice(firstSpace + 1);
143
+ return { hash, subject };
144
+ });
147
145
  }
148
146
 
149
147
  function filterMissing(devCommits, mainSubjects) {
150
- return devCommits.filter(({ subject }) => !mainSubjects.has(subject));
148
+ return devCommits.filter(({ subject }) => !mainSubjects.has(subject));
151
149
  }
152
150
 
153
151
  async function selectCommitsInteractive(missing) {
154
- const choices = [
155
- new inquirer.Separator(chalk.gray("── Newest commits ──")),
156
- ...missing.map(({ hash, subject }, idx) => {
157
- // display-only trim to avoid accidental leading spaces
158
- const displaySubject = subject.replace(/^[\s\u00A0]+/, "");
159
- return {
160
- name: `${chalk.dim(`(${hash.slice(0, 7)})`)} ${displaySubject}`,
161
- value: hash,
162
- short: displaySubject,
163
- idx, // we keep index for oldest→newest ordering later
164
- };
165
- }),
166
- new inquirer.Separator(chalk.gray("── Oldest commits ──")),
167
- ];
168
- const termHeight = process.stdout.rows || 24; // fallback for non-TTY environments
169
-
170
- const { selected } = await inquirer.prompt([
171
- {
172
- type: "checkbox",
173
- name: "selected",
174
- message: `Select commits to cherry-pick (${missing.length} missing):`,
175
- choices,
176
- pageSize: Math.max(10, Math.min(termHeight - 5, missing.length)),
177
- },
178
- ]);
179
-
180
- return selected;
152
+ const choices = [
153
+ new inquirer.Separator(chalk.gray('── Newest commits ──')),
154
+ ...missing.map(({ hash, subject }, idx) => {
155
+ // display-only trim to avoid accidental leading spaces
156
+ const displaySubject = subject.replace(/^[\s\u00A0]+/, '');
157
+ return {
158
+ name: `${chalk.dim(`(${hash.slice(0, 7)})`)} ${displaySubject}`,
159
+ value: hash,
160
+ short: displaySubject,
161
+ idx, // we keep index for oldest→newest ordering later
162
+ };
163
+ }),
164
+ new inquirer.Separator(chalk.gray('── Oldest commits ──')),
165
+ ];
166
+ const termHeight = process.stdout.rows || 24; // fallback for non-TTY environments
167
+
168
+ const { selected } = await inquirer.prompt([
169
+ {
170
+ type: 'checkbox',
171
+ name: 'selected',
172
+ message: `Select commits to cherry-pick (${missing.length} missing):`,
173
+ choices,
174
+ pageSize: Math.max(10, Math.min(termHeight - 5, missing.length)),
175
+ },
176
+ ]);
177
+
178
+ return selected;
181
179
  }
182
180
 
183
181
  async function handleCherryPickConflict(hash) {
184
- if (!(await isCherryPickInProgress())) {
185
- return "skipped";
186
- }
182
+ if (!(await isCherryPickInProgress())) {
183
+ return 'skipped';
184
+ }
187
185
 
188
- while (true) {
189
- err(chalk.red(`\n✖ Cherry-pick has conflicts on ${hash} (${hash.slice(0, 7)}).`));
190
- await showConflictsList(); // prints conflicted files (if any)
186
+ while (true) {
187
+ err(chalk.red(`\n✖ Cherry-pick has conflicts on ${hash} (${hash.slice(0, 7)}).`));
188
+ await showConflictsList(); // prints conflicted files (if any)
191
189
 
192
- const { action } = await inquirer.prompt([
193
- {
194
- type: "list",
195
- name: "action",
196
- message: "Choose how to proceed:",
197
- choices: [
198
- { name: "Skip this commit", value: "skip" },
199
- { name: "Resolve conflicts now", value: "resolve" },
200
- { name: "Revoke and cancel (abort entire sequence)", value: "abort" },
201
- ],
202
- },
203
- ]);
190
+ const { action } = await inquirer.prompt([
191
+ {
192
+ type: 'select',
193
+ name: 'action',
194
+ message: 'Choose how to proceed:',
195
+ choices: [
196
+ { name: 'Skip this commit', value: 'skip' },
197
+ { name: 'Resolve conflicts now', value: 'resolve' },
198
+ { name: 'Revoke and cancel (abort entire sequence)', value: 'abort' },
199
+ ],
200
+ },
201
+ ]);
204
202
 
205
- if (action === "skip") {
206
- await gitRaw(["cherry-pick", "--skip"]);
207
- log(chalk.yellow(`↷ Skipped commit ${chalk.dim(`(${hash.slice(0, 7)})`)}`));
208
- return "skipped";
209
- }
203
+ if (action === 'skip') {
204
+ await gitRaw(['cherry-pick', '--skip']);
205
+ log(chalk.yellow(`↷ Skipped commit ${chalk.dim(`(${hash.slice(0, 7)})`)}`));
206
+ return 'skipped';
207
+ }
210
208
 
211
- if (action === "abort") {
212
- await gitRaw(["cherry-pick", "--abort"]);
213
- throw new Error("Cherry-pick aborted by user.");
214
- }
209
+ if (action === 'abort') {
210
+ await gitRaw(['cherry-pick', '--abort']);
211
+ throw new Error('Cherry-pick aborted by user.');
212
+ }
215
213
 
216
- const res = await conflictsResolutionWizard(hash);
217
- if (res === "continued" || res === "skipped") {
218
- return res;
214
+ const res = await conflictsResolutionWizard(hash);
215
+ if (res === 'continued' || res === 'skipped') {
216
+ return res;
217
+ }
219
218
  }
220
- }
221
219
  }
222
220
 
223
221
  async function getConflictedFiles() {
224
- const out = await gitRaw(["diff", "--name-only", "--diff-filter=U"]);
225
- return out ? out.split("\n").filter(Boolean) : [];
222
+ const out = await gitRaw(['diff', '--name-only', '--diff-filter=U']);
223
+ return out ? out.split('\n').filter(Boolean) : [];
226
224
  }
227
225
 
228
226
  async function assertNoUnmerged() {
229
- const files = await getConflictedFiles();
230
- return files.length === 0;
227
+ const files = await getConflictedFiles();
228
+ return files.length === 0;
231
229
  }
232
230
 
233
231
  async function isCherryPickInProgress() {
234
- try {
235
- const head = await gitRaw(["rev-parse", "-q", "--verify", "CHERRY_PICK_HEAD"]);
236
- return !!head;
237
- } catch {
238
- return false;
239
- }
232
+ try {
233
+ const head = await gitRaw(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD']);
234
+ return !!head;
235
+ } catch {
236
+ return false;
237
+ }
240
238
  }
241
239
 
242
240
  async function hasStagedChanges() {
243
- const out = await gitRaw(["diff", "--cached", "--name-only"]);
244
- return !!out;
241
+ const out = await gitRaw(['diff', '--cached', '--name-only']);
242
+ return !!out;
245
243
  }
246
244
 
247
245
  async function isEmptyCherryPick() {
248
- if (!(await isCherryPickInProgress())) return false;
249
- const noUnmerged = await assertNoUnmerged();
250
- if (!noUnmerged) return false;
251
- const anyStaged = await hasStagedChanges();
252
- return !anyStaged;
246
+ if (!(await isCherryPickInProgress())) return false;
247
+ const noUnmerged = await assertNoUnmerged();
248
+ if (!noUnmerged) return false;
249
+ const anyStaged = await hasStagedChanges();
250
+ return !anyStaged;
253
251
  }
254
252
 
255
253
  async function runBin(bin, args) {
256
- return new Promise((resolve, reject) => {
257
- const p = spawn(bin, args, { stdio: "inherit" });
258
- p.on("error", reject);
259
- p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`${bin} exited ${code}`))));
260
- });
254
+ return new Promise((resolve, reject) => {
255
+ const p = spawn(bin, args, { stdio: 'inherit' });
256
+ p.on('error', reject);
257
+ p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${bin} exited ${code}`))));
258
+ });
261
259
  }
262
260
 
263
261
  async function showConflictsList() {
264
- const files = await getConflictedFiles();
262
+ const files = await getConflictedFiles();
265
263
 
266
- if (!files.length) {
267
- log(chalk.green("No conflicted files reported by git."));
268
- return [];
269
- }
270
- err(chalk.yellow("Conflicted files:"));
271
- for (const f of files) {
272
- err(` - ${f}`);
273
- }
274
- return files;
264
+ if (!files.length) {
265
+ log(chalk.green('No conflicted files reported by git.'));
266
+ return [];
267
+ }
268
+ err(chalk.yellow('Conflicted files:'));
269
+ for (const f of files) {
270
+ err(` - ${f}`);
271
+ }
272
+ return files;
275
273
  }
276
274
 
277
275
  async function resolveSingleFileWizard(file) {
278
- const { action } = await inquirer.prompt([
279
- {
280
- type: "list",
281
- name: "action",
282
- message: `How to resolve "${file}"?`,
283
- choices: [
284
- { name: "Use ours (current branch)", value: "ours" },
285
- { name: "Use theirs (picked commit)", value: "theirs" },
286
- { name: "Open in editor", value: "edit" },
287
- { name: "Show diff", value: "diff" },
288
- { name: "Mark resolved (stage file)", value: "stage" },
289
- { name: "Back", value: "back" },
290
- ],
291
- },
292
- ]);
293
-
294
- try {
295
- if (action === "ours") {
296
- await gitRaw(["checkout", "--ours", file]);
297
- await git.add([file]);
298
- log(chalk.green(`✓ Applied "ours" and staged: ${file}`));
299
- } else if (action === "theirs") {
300
- await gitRaw(["checkout", "--theirs", file]);
301
- await git.add([file]);
302
- log(chalk.green(`✓ Applied "theirs" and staged: ${file}`));
303
- } else if (action === "edit") {
304
- const editor = process.env.EDITOR || "vi";
305
- log(chalk.cyan(`Opening ${file} in ${editor}...`));
306
- await runBin(editor, [file]);
307
- // user edits and saves, so now they can stage
308
- const { stageNow } = await inquirer.prompt([
276
+ const { action } = await inquirer.prompt([
309
277
  {
310
- type: "confirm",
311
- name: "stageNow",
312
- message: "File edited. Stage it now?",
313
- default: true,
278
+ type: 'select',
279
+ name: 'action',
280
+ message: `How to resolve "${file}"?`,
281
+ choices: [
282
+ { name: 'Use ours (current branch)', value: 'ours' },
283
+ { name: 'Use theirs (picked commit)', value: 'theirs' },
284
+ { name: 'Open in editor', value: 'edit' },
285
+ { name: 'Show diff', value: 'diff' },
286
+ { name: 'Mark resolved (stage file)', value: 'stage' },
287
+ { name: 'Back', value: 'back' },
288
+ ],
314
289
  },
315
- ]);
316
- if (stageNow) {
317
- await git.add([file]);
318
- log(chalk.green(`✓ Staged: ${file}`));
319
- }
320
- } else if (action === "diff") {
321
- const d = await gitRaw(["diff", file]);
322
- err(chalk.gray(`\n--- diff: ${file} ---\n${d}\n--- end diff ---\n`));
323
- } else if (action === "stage") {
324
- await git.add([file]);
325
- log(chalk.green(`✓ Staged: ${file}`));
290
+ ]);
291
+
292
+ try {
293
+ if (action === 'ours') {
294
+ await gitRaw(['checkout', '--ours', file]);
295
+ await git.add([file]);
296
+ log(chalk.green(`✓ Applied "ours" and staged: ${file}`));
297
+ } else if (action === 'theirs') {
298
+ await gitRaw(['checkout', '--theirs', file]);
299
+ await git.add([file]);
300
+ log(chalk.green(`✓ Applied "theirs" and staged: ${file}`));
301
+ } else if (action === 'edit') {
302
+ const editor = process.env.EDITOR || 'vi';
303
+ log(chalk.cyan(`Opening ${file} in ${editor}...`));
304
+ await runBin(editor, [file]);
305
+ // user edits and saves, so now they can stage
306
+ const { stageNow } = await inquirer.prompt([
307
+ {
308
+ type: 'confirm',
309
+ name: 'stageNow',
310
+ message: 'File edited. Stage it now?',
311
+ default: true,
312
+ },
313
+ ]);
314
+ if (stageNow) {
315
+ await git.add([file]);
316
+ log(chalk.green(`✓ Staged: ${file}`));
317
+ }
318
+ } else if (action === 'diff') {
319
+ const d = await gitRaw(['diff', file]);
320
+ err(chalk.gray(`\n--- diff: ${file} ---\n${d}\n--- end diff ---\n`));
321
+ } else if (action === 'stage') {
322
+ await git.add([file]);
323
+ log(chalk.green(`✓ Staged: ${file}`));
324
+ }
325
+ } catch (e) {
326
+ err(chalk.red(`Action failed on ${file}: ${e.message || e}`));
326
327
  }
327
- } catch (e) {
328
- err(chalk.red(`Action failed on ${file}: ${e.message || e}`));
329
- }
330
328
 
331
- return action;
329
+ return action;
332
330
  }
333
331
 
334
332
  async function conflictsResolutionWizard(hash) {
335
- // Loop until no conflicts remain and continue succeeds
336
- while (true) {
337
- const files = await showConflictsList();
338
-
339
- if (files.length === 0) {
340
- // If there are no conflicted files, either continue or detect empty pick
341
- if (await isEmptyCherryPick()) {
342
- err(chalk.yellow("The previous cherry-pick is now empty."));
343
- const { emptyAction } = await inquirer.prompt([
344
- {
345
- type: "list",
346
- name: "emptyAction",
347
- message: "No staged changes for this pick. Choose next step:",
348
- choices: [
349
- { name: "Skip this commit (recommended)", value: "skip" },
350
- { name: "Create an empty commit", value: "empty-commit" },
351
- { name: "Back to conflict menu", value: "back" },
352
- ],
353
- },
354
- ]);
355
-
356
- if (emptyAction === "skip") {
357
- await gitRaw(["cherry-pick", "--skip"]);
358
- log(chalk.yellow(`↷ Skipped empty pick ${chalk.dim(`(${hash.slice(0, 7)})`)}`));
359
- return "skipped";
360
- }
361
- if (emptyAction === "empty-commit") {
362
- const subject = await gitRaw(["show", "--format=%s", "-s", hash]);
363
- await gitRaw(["commit", "--allow-empty", "-m", subject]);
364
- log(
365
- `${chalk.green("")} (empty) cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`,
366
- );
367
- return "continued";
368
- }
369
- if (emptyAction === "back") {
370
- // ← FIX #1: really go back to the conflict menu (do NOT try --continue)
371
- continue;
333
+ // Loop until no conflicts remain and continue succeeds
334
+ while (true) {
335
+ const files = await showConflictsList();
336
+
337
+ if (files.length === 0) {
338
+ // If there are no conflicted files, either continue or detect empty pick
339
+ if (await isEmptyCherryPick()) {
340
+ err(chalk.yellow('The previous cherry-pick is now empty.'));
341
+ const { emptyAction } = await inquirer.prompt([
342
+ {
343
+ type: 'select',
344
+ name: 'emptyAction',
345
+ message: 'No staged changes for this pick. Choose next step:',
346
+ choices: [
347
+ { name: 'Skip this commit (recommended)', value: 'skip' },
348
+ { name: 'Create an empty commit', value: 'empty-commit' },
349
+ { name: 'Back to conflict menu', value: 'back' },
350
+ ],
351
+ },
352
+ ]);
353
+
354
+ if (emptyAction === 'skip') {
355
+ await gitRaw(['cherry-pick', '--skip']);
356
+ log(chalk.yellow(`↷ Skipped empty pick ${chalk.dim(`(${hash.slice(0, 7)})`)}`));
357
+ return 'skipped';
358
+ }
359
+ if (emptyAction === 'empty-commit') {
360
+ const fullMessage = await gitRaw(['show', '--format=%B', '-s', hash]);
361
+ await gitRaw(['commit', '--allow-empty', '-m', fullMessage]);
362
+ const subject = await gitRaw(['show', '--format=%s', '-s', 'HEAD']);
363
+ log(`${chalk.green('')} (empty) cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
364
+ return 'continued';
365
+ }
366
+ if (emptyAction === 'back') {
367
+ // ← FIX #1: really go back to the conflict menu (do NOT try --continue)
368
+ continue;
369
+ }
370
+ // (re-loop)
371
+ } else {
372
+ try {
373
+ // Use -C to copy the original commit message
374
+ await gitRaw(['commit', '-C', hash]);
375
+ const subject = await gitRaw(['show', '--format=%s', '-s', 'HEAD']);
376
+ log(`${chalk.green('✓')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
377
+ return 'continued';
378
+ } catch (e) {
379
+ err(chalk.red('`git cherry-pick --continue` failed:'));
380
+ err(String(e.message || e));
381
+ // fall back to loop
382
+ }
383
+ }
372
384
  }
373
- // (re-loop)
374
- } else {
375
- try {
376
- await gitRaw(["cherry-pick", "--continue"]);
377
- const subject = await gitRaw(["show", "--format=%s", "-s", hash]);
378
- log(`${chalk.green("✓")} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
379
- return "continued";
380
- } catch (e) {
381
- err(chalk.red("`git cherry-pick --continue` failed:"));
382
- err(String(e.message || e));
383
- // fall back to loop
384
- }
385
- }
386
- }
387
-
388
- const { choice } = await inquirer.prompt([
389
- {
390
- type: "list",
391
- name: "choice",
392
- message: "Select a file to resolve or a global action:",
393
- pageSize: Math.min(20, Math.max(8, files.length + 5)),
394
- choices: [
395
- ...files.map((f) => ({ name: f, value: { type: "file", file: f } })),
396
- new inquirer.Separator(chalk.gray("─ Actions ─")),
397
- { name: "Use ours for ALL", value: { type: "all", action: "ours-all" } },
398
- { name: "Use theirs for ALL", value: { type: "all", action: "theirs-all" } },
399
- { name: "Stage ALL", value: { type: "all", action: "stage-all" } },
400
- { name: "Launch mergetool (all)", value: { type: "all", action: "mergetool-all" } },
401
- {
402
- name: "Try to continue (run --continue)",
403
- value: { type: "global", action: "continue" },
404
- },
405
- { name: "Back to main conflict menu", value: { type: "global", action: "back" } },
406
- ],
407
- },
408
- ]);
409
385
 
410
- if (!choice) {
411
- continue;
412
- }
386
+ const { choice } = await inquirer.prompt([
387
+ {
388
+ type: 'select',
389
+ name: 'choice',
390
+ message: 'Select a file to resolve or a global action:',
391
+ pageSize: Math.min(20, Math.max(8, files.length + 5)),
392
+ choices: [
393
+ ...files.map((f) => ({ name: f, value: { type: 'file', file: f } })),
394
+ new inquirer.Separator(chalk.gray('─ Actions ─')),
395
+ { name: 'Use ours for ALL', value: { type: 'all', action: 'ours-all' } },
396
+ { name: 'Use theirs for ALL', value: { type: 'all', action: 'theirs-all' } },
397
+ { name: 'Stage ALL', value: { type: 'all', action: 'stage-all' } },
398
+ { name: 'Launch mergetool (all)', value: { type: 'all', action: 'mergetool-all' } },
399
+ {
400
+ name: 'Try to continue (run --continue)',
401
+ value: { type: 'global', action: 'continue' },
402
+ },
403
+ { name: 'Back to main conflict menu', value: { type: 'global', action: 'back' } },
404
+ ],
405
+ },
406
+ ]);
413
407
 
414
- if (choice.type === "file") {
415
- await resolveSingleFileWizard(choice.file);
416
- continue;
417
- }
408
+ if (!choice) {
409
+ continue;
410
+ }
418
411
 
419
- if (choice.type === "all") {
420
- for (const f of files) {
421
- if (choice.action === "ours-all") {
422
- await gitRaw(["checkout", "--ours", f]);
423
- await git.add([f]);
424
- } else if (choice.action === "theirs-all") {
425
- await gitRaw(["checkout", "--theirs", f]);
426
- await git.add([f]);
427
- } else if (choice.action === "stage-all") {
428
- await git.add([f]);
429
- } else if (choice.action === "mergetool-all") {
430
- await runBin("git", ["mergetool"]);
431
- break; // mergetool all opens sequentially; re-loop to re-check state
412
+ if (choice.type === 'file') {
413
+ await resolveSingleFileWizard(choice.file);
414
+ continue;
432
415
  }
433
- }
434
- continue;
435
- }
436
416
 
437
- if (choice.type === "global" && choice.action === "continue") {
438
- if (await assertNoUnmerged()) {
439
- // If nothing is staged, treat as empty pick and prompt
440
- if (!(await hasStagedChanges())) {
441
- err(chalk.yellow("No staged changes found for this cherry-pick."));
442
- const { emptyAction } = await inquirer.prompt([
443
- {
444
- type: "list",
445
- name: "emptyAction",
446
- message: "This pick seems empty. Choose next step:",
447
- choices: [
448
- { name: "Skip this commit", value: "skip" },
449
- { name: "Create empty commit", value: "empty-commit" },
450
- { name: "Back", value: "back" },
451
- ],
452
- },
453
- ]);
454
-
455
- if (emptyAction === "skip") {
456
- await gitRaw(["cherry-pick", "--skip"]);
457
- log(chalk.yellow(`↷ Skipped empty pick ${chalk.dim(`(${hash.slice(0, 7)})`)}`));
458
- return "skipped";
459
- }
460
- if (emptyAction === "empty-commit") {
461
- const subject = await gitRaw(["show", "--format=%s", "-s", hash]);
462
- await gitRaw(["commit", "--allow-empty", "-m", subject]);
463
- log(
464
- `${chalk.green("✓")} (empty) cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`,
465
- );
466
- return "continued";
467
- }
468
- if (emptyAction === "back") {
469
- // ← FIX #2: actually go back to the conflict menu; do NOT try --continue
417
+ if (choice.type === 'all') {
418
+ for (const f of files) {
419
+ if (choice.action === 'ours-all') {
420
+ await gitRaw(['checkout', '--ours', f]);
421
+ await git.add([f]);
422
+ } else if (choice.action === 'theirs-all') {
423
+ await gitRaw(['checkout', '--theirs', f]);
424
+ await git.add([f]);
425
+ } else if (choice.action === 'stage-all') {
426
+ await git.add([f]);
427
+ } else if (choice.action === 'mergetool-all') {
428
+ await runBin('git', ['mergetool']);
429
+ break; // mergetool all opens sequentially; re-loop to re-check state
430
+ }
431
+ }
470
432
  continue;
471
- }
472
433
  }
473
434
 
474
- try {
475
- await gitRaw(["cherry-pick", "--continue"]);
476
- const subject = await gitRaw(["show", "--format=%s", "-s", hash]);
477
- log(`${chalk.green("✓")} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
478
- return "continued";
479
- } catch (e) {
480
- err(chalk.red("`--continue` failed. Resolve remaining issues and try again."));
435
+ if (choice.type === 'global' && choice.action === 'continue') {
436
+ if (await assertNoUnmerged()) {
437
+ // If nothing is staged, treat as empty pick and prompt
438
+ if (!(await hasStagedChanges())) {
439
+ err(chalk.yellow('No staged changes found for this cherry-pick.'));
440
+ const { emptyAction } = await inquirer.prompt([
441
+ {
442
+ type: 'select',
443
+ name: 'emptyAction',
444
+ message: 'This pick seems empty. Choose next step:',
445
+ choices: [
446
+ { name: 'Skip this commit', value: 'skip' },
447
+ { name: 'Create empty commit', value: 'empty-commit' },
448
+ { name: 'Back', value: 'back' },
449
+ ],
450
+ },
451
+ ]);
452
+
453
+ if (emptyAction === 'skip') {
454
+ await gitRaw(['cherry-pick', '--skip']);
455
+ log(chalk.yellow(`↷ Skipped empty pick ${chalk.dim(`(${hash.slice(0, 7)})`)}`));
456
+ return 'skipped';
457
+ }
458
+
459
+ if (emptyAction === 'empty-commit') {
460
+ const fullMessage = await gitRaw(['show', '--format=%B', '-s', hash]);
461
+ await gitRaw(['commit', '--allow-empty', '-m', fullMessage]);
462
+ const subject = await gitRaw(['show', '--format=%s', '-s', 'HEAD']);
463
+ log(`${chalk.green('✓')} (empty) cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
464
+ return 'continued';
465
+ }
466
+ if (emptyAction === 'back') {
467
+ // ← FIX #2: actually go back to the conflict menu; do NOT try --continue
468
+ continue;
469
+ }
470
+ }
471
+
472
+ try {
473
+ // Use -C to copy the original commit message
474
+ await gitRaw(['commit', '-C', hash]);
475
+ const subject = await gitRaw(['show', '--format=%s', '-s', 'HEAD']);
476
+ log(`${chalk.green('✓')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
477
+ return 'continued';
478
+ } catch (e) {
479
+ err(chalk.red('`--continue` failed. Resolve remaining issues and try again.'));
480
+ }
481
+ } else {
482
+ err(chalk.yellow('There are still unmerged files.'));
483
+ }
481
484
  }
482
- } else {
483
- err(chalk.yellow("There are still unmerged files."));
484
- }
485
- }
486
485
 
487
- if (choice.type === "global" && choice.action === "back") {
488
- return "back";
486
+ if (choice.type === 'global' && choice.action === 'back') {
487
+ return 'back';
488
+ }
489
489
  }
490
- }
491
490
  }
492
491
 
493
492
  async function cherryPickSequential(hashes) {
494
- const result = { applied: 0, skipped: 0 };
493
+ const result = { applied: 0, skipped: 0 };
495
494
 
496
- for (const hash of hashes) {
497
- try {
498
- await gitRaw(["cherry-pick", hash]);
499
- const subject = await gitRaw(["show", "--format=%s", "-s", hash]);
500
- log(`${chalk.green("")} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
501
- result.applied += 1;
502
- } catch (e) {
503
- try {
504
- const action = await handleCherryPickConflict(hash);
505
- if (action === "skipped") {
506
- result.skipped += 1;
507
- continue;
508
- }
509
- if (action === "continued") {
510
- // --continue başarıyla commit oluşturdu
511
- result.applied += 1;
495
+ for (const hash of hashes) {
496
+ try {
497
+ await gitRaw(['cherry-pick', hash]);
498
+ const subject = await gitRaw(['show', '--format=%s', '-s', hash]);
499
+ log(`${chalk.green('')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
500
+ result.applied += 1;
501
+ } catch (e) {
502
+ try {
503
+ const action = await handleCherryPickConflict(hash);
504
+ if (action === 'skipped') {
505
+ result.skipped += 1;
506
+ continue;
507
+ }
508
+ if (action === 'continued') {
509
+ // --continue başarıyla commit oluşturdu
510
+ result.applied += 1;
511
+ }
512
+ } catch (abortErr) {
513
+ err(chalk.red(`✖ Cherry-pick aborted on ${hash}`));
514
+ throw abortErr;
515
+ }
512
516
  }
513
- } catch (abortErr) {
514
- err(chalk.red(`✖ Cherry-pick aborted on ${hash}`));
515
- throw abortErr;
516
- }
517
517
  }
518
- }
519
518
 
520
- return result;
519
+ return result;
521
520
  }
522
521
 
523
522
  /**
@@ -525,282 +524,302 @@ async function cherryPickSequential(hashes) {
525
524
  * @returns {Promise<void>}
526
525
  */
527
526
  function parseVersion(v) {
528
- const m = String(v || "")
529
- .trim()
530
- .match(/^(\d+)\.(\d+)\.(\d+)$/);
531
- if (!m) {
532
- throw new Error(`Invalid --current-version "${v}". Expected X.Y.Z`);
533
- }
534
- return { major: +m[1], minor: +m[2], patch: +m[3] };
527
+ const m = String(v || '')
528
+ .trim()
529
+ .match(/^(\d+)\.(\d+)\.(\d+)$/);
530
+ if (!m) {
531
+ throw new Error(`Invalid --current-version "${v}". Expected X.Y.Z`);
532
+ }
533
+ return { major: +m[1], minor: +m[2], patch: +m[3] };
535
534
  }
536
535
 
537
536
  function incrementVersion(version, bump) {
538
- const cur = parseVersion(version);
539
- if (bump === "major") {
540
- return `${cur.major + 1}.0.0`;
541
- }
542
- if (bump === "minor") {
543
- return `${cur.major}.${cur.minor + 1}.0`;
544
- }
545
- if (bump === "patch") {
546
- return `${cur.major}.${cur.minor}.${cur.patch + 1}`;
547
- }
548
- return `${cur.major}.${cur.minor}.${cur.patch}`;
537
+ const cur = parseVersion(version);
538
+ if (bump === 'major') {
539
+ return `${cur.major + 1}.0.0`;
540
+ }
541
+ if (bump === 'minor') {
542
+ return `${cur.major}.${cur.minor + 1}.0`;
543
+ }
544
+ if (bump === 'patch') {
545
+ return `${cur.major}.${cur.minor}.${cur.patch + 1}`;
546
+ }
547
+ return `${cur.major}.${cur.minor}.${cur.patch}`;
549
548
  }
550
549
 
551
550
  function normalizeMessage(msg) {
552
- // normalize whitespace; keep case-insensitive matching
553
- return (msg || "").replace(/\r\n/g, "\n");
551
+ // normalize whitespace; keep case-insensitive matching
552
+ return (msg || '').replace(/\r\n/g, '\n');
554
553
  }
555
554
 
556
555
  // Returns "major" | "minor" | "patch" | null for a single commit message
557
556
  function classifySingleCommit(messageBody) {
558
- const body = normalizeMessage(messageBody);
557
+ const body = normalizeMessage(messageBody);
559
558
 
560
- // Major
561
- if (/\bBREAKING[- _]CHANGE(?:\([^)]+\))?\s*:?/i.test(body)) {
562
- return "major";
563
- }
559
+ // Major
560
+ if (/\bBREAKING[- _]CHANGE(?:\([^)]+\))?\s*:?/i.test(body)) {
561
+ return 'major';
562
+ }
564
563
 
565
- // Minor
566
- if (/(^|\n)\s*(\*?\s*)?feat(?:\([^)]+\))?\s*:?/i.test(body)) {
567
- return "minor";
568
- }
564
+ // Minor
565
+ if (/(^|\n)\s*(\*?\s*)?feat(?:\([^)]+\))?\s*:?/i.test(body)) {
566
+ return 'minor';
567
+ }
569
568
 
570
- // Patch
571
- if (/(^|\n)\s*(\*?\s*)?(fix|perf)(?:\([^)]+\))?\s*:?/i.test(body)) {
572
- return "patch";
573
- }
569
+ // Patch
570
+ if (/(^|\n)\s*(\*?\s*)?(fix|perf)(?:\([^)]+\))?\s*:?/i.test(body)) {
571
+ return 'patch';
572
+ }
574
573
 
575
- return null;
574
+ return null;
576
575
  }
577
576
 
578
577
  // Given many commits, collapse to a single bump level
579
578
  function collapseBumps(levels) {
580
- if (levels.includes("major")) {
581
- return "major";
582
- }
583
- if (levels.includes("minor")) {
584
- return "minor";
585
- }
586
- if (levels.includes("patch")) {
587
- return "patch";
588
- }
589
- return null;
579
+ if (levels.includes('major')) {
580
+ return 'major';
581
+ }
582
+ if (levels.includes('minor')) {
583
+ return 'minor';
584
+ }
585
+ if (levels.includes('patch')) {
586
+ return 'patch';
587
+ }
588
+ return null;
590
589
  }
591
590
 
592
591
  // Fetch full commit messages (%B) for SHAs and compute bump
593
592
  async function computeSemanticBumpForCommits(hashes, gitRawFn, semverignore) {
594
- if (!hashes.length) {
595
- return null;
596
- }
593
+ if (!hashes.length) {
594
+ return null;
595
+ }
597
596
 
598
- const levels = [];
599
- const semverIgnorePatterns = parseSemverIgnore(semverignore);
597
+ const levels = [];
598
+ const semverIgnorePatterns = parseSemverIgnore(semverignore);
600
599
 
601
- for (const h of hashes) {
602
- const msg = await gitRawFn(["show", "--format=%B", "-s", h]);
603
- const subject = msg.split(/\r?\n/)[0].trim();
604
- let level = classifySingleCommit(msg);
600
+ for (const h of hashes) {
601
+ const msg = await gitRawFn(['show', '--format=%B', '-s', h]);
602
+ const subject = msg.split(/\r?\n/)[0].trim();
603
+ let level = classifySingleCommit(msg);
605
604
 
606
- // 🔹 Apply --semverignore (treat matched commits as chores). Use full message (subject + body).
607
- if (semverIgnorePatterns.length > 0 && matchesAnyPattern(msg, semverIgnorePatterns)) {
608
- level = null;
609
- }
605
+ // 🔹 Apply --semverignore (treat matched commits as chores). Use full message (subject + body).
606
+ if (semverIgnorePatterns.length > 0 && matchesAnyPattern(msg, semverIgnorePatterns)) {
607
+ level = null;
608
+ }
610
609
 
611
- if (level) {
612
- levels.push(level);
613
- if (level === "major") break; // early exit if major is found
610
+ if (level) {
611
+ levels.push(level);
612
+ if (level === 'major') break; // early exit if major is found
613
+ }
614
614
  }
615
- }
616
615
 
617
- return collapseBumps(levels);
616
+ return collapseBumps(levels);
618
617
  }
619
618
 
620
619
  async function main() {
621
- try {
622
- if (!argv["no-fetch"]) {
623
- log(chalk.gray("Fetching remotes (git fetch --prune)..."));
624
- await git.fetch(["--prune"]);
625
- }
620
+ try {
621
+ // Check if gh CLI is installed when push-release is enabled
622
+ if (argv['push-release']) {
623
+ const ghInstalled = await checkGhCli();
624
+ if (!ghInstalled) {
625
+ err(chalk.yellow('\n⚠️ GitHub CLI (gh) is not installed or not in PATH.'));
626
+ err(chalk.gray(' The --push-release flag requires gh CLI to create pull requests.\n'));
627
+ err(chalk.cyan(' Install it from: https://cli.github.com/'));
628
+ err(chalk.cyan(' Or run without --push-release to skip PR creation.\n'));
629
+
630
+ const { proceed } = await inquirer.prompt([
631
+ {
632
+ type: 'confirm',
633
+ name: 'proceed',
634
+ message: 'Continue without creating a PR?',
635
+ default: false,
636
+ },
637
+ ]);
638
+
639
+ if (!proceed) {
640
+ log(chalk.yellow('Aborted by user.'));
641
+ return;
642
+ }
643
+
644
+ // Disable push-release if user chooses to continue
645
+ argv['push-release'] = false;
646
+ }
647
+ }
626
648
 
627
- const currentBranch = (await gitRaw(["rev-parse", "--abbrev-ref", "HEAD"])) || "HEAD";
649
+ if (!argv['no-fetch']) {
650
+ log(chalk.gray('Fetching remotes (git fetch --prune)...'));
651
+ await git.fetch(['--prune']);
652
+ }
628
653
 
629
- log(chalk.gray(`Comparing subjects since ${argv.since}`));
630
- log(chalk.gray(`Dev: ${argv.dev}`));
631
- log(chalk.gray(`Main: ${argv.main}`));
654
+ const currentBranch = (await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD'])) || 'HEAD';
632
655
 
633
- const [devCommits, mainSubjects] = await Promise.all([
634
- getDevCommits(argv.dev, argv.since),
635
- getSubjects(argv.main),
636
- ]);
656
+ log(chalk.gray(`Comparing subjects since ${argv.since}`));
657
+ log(chalk.gray(`Dev: ${argv.dev}`));
658
+ log(chalk.gray(`Main: ${argv.main}`));
637
659
 
638
- const missing = filterMissing(devCommits, mainSubjects);
660
+ const [devCommits, mainSubjects] = await Promise.all([getDevCommits(argv.dev, argv.since), getSubjects(argv.main)]);
639
661
 
640
- if (missing.length === 0) {
641
- log(chalk.green("✅ No missing commits found in the selected window."));
642
- return;
643
- }
662
+ const missing = filterMissing(devCommits, mainSubjects);
644
663
 
645
- const semverIgnore = argv["semver-ignore"];
646
- const indexByHash = new Map(missing.map((c, i) => [c.hash, i])); // 0=newest, larger=older
664
+ // Filter out commits matching --ignore-commits patterns
665
+ const ignoreCommitsPatterns = parseIgnoreCommits(argv['ignore-commits']);
666
+ const filteredMissing =
667
+ ignoreCommitsPatterns.length > 0
668
+ ? missing.filter(({ subject }) => !shouldIgnoreCommit(subject, ignoreCommitsPatterns))
669
+ : missing;
647
670
 
648
- let selected;
649
- if (argv["all-yes"]) {
650
- selected = missing.map((m) => m.hash);
651
- } else {
652
- selected = await selectCommitsInteractive(missing);
653
- if (!selected.length) {
654
- log(chalk.yellow("No commits selected. Exiting."));
655
- return;
656
- }
657
- }
671
+ if (filteredMissing.length === 0) {
672
+ log(chalk.green('✅ No missing commits found in the selected window.'));
673
+ return;
674
+ }
675
+
676
+ const semverIgnore = argv['ignore-semver'];
677
+ const indexByHash = new Map(filteredMissing.map((c, i) => [c.hash, i])); // 0=newest, larger=older
678
+
679
+ let selected;
680
+ if (argv['all-yes']) {
681
+ selected = filteredMissing.map((m) => m.hash);
682
+ } else {
683
+ selected = await selectCommitsInteractive(filteredMissing);
684
+ if (!selected.length) {
685
+ log(chalk.yellow('No commits selected. Exiting.'));
686
+ return;
687
+ }
688
+ }
658
689
 
659
- const bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a));
690
+ const bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a));
660
691
 
661
- if (argv.dry_run || argv["dry-run"]) {
662
- log(chalk.cyan("\n--dry-run: would cherry-pick (oldest → newest):"));
663
- for (const h of bottomToTop) {
664
- const subj = await gitRaw(["show", "--format=%s", "-s", h]);
665
- log(`- ${chalk.dim(`(${h.slice(0, 7)})`)} ${subj}`);
666
- }
667
- return;
668
- }
669
-
670
- if (argv["version-file"] && !argv["current-version"]) {
671
- const currentVersionFromPkg = await getPkgVersion(argv["version-file"]);
672
- argv["current-version"] = currentVersionFromPkg;
673
- }
692
+ if (argv.dry_run || argv['dry-run']) {
693
+ log(chalk.cyan('\n--dry-run: would cherry-pick (oldest → newest):'));
694
+ for (const h of bottomToTop) {
695
+ const subj = await gitRaw(['show', '--format=%s', '-s', h]);
696
+ log(`- ${chalk.dim(`(${h.slice(0, 7)})`)} ${subj}`);
697
+ }
698
+ return;
699
+ }
700
+
701
+ if (argv['version-file'] && !argv['current-version']) {
702
+ const currentVersionFromPkg = await getPkgVersion(argv['version-file']);
703
+ argv['current-version'] = currentVersionFromPkg;
704
+ }
705
+
706
+ let computedNextVersion = argv['current-version'];
707
+ if (argv['semantic-versioning']) {
708
+ if (!argv['current-version']) {
709
+ throw new Error(' --semantic-versioning requires --current-version X.Y.Z (or pass --version-file)');
710
+ }
711
+
712
+ // Bump is based on the commits you are about to apply (selected).
713
+ const bump = await computeSemanticBumpForCommits(bottomToTop, gitRaw, semverIgnore);
714
+
715
+ computedNextVersion = bump ? incrementVersion(argv['current-version'], bump) : argv['current-version'];
674
716
 
675
- let computedNextVersion = argv["current-version"];
676
- if (argv["semantic-versioning"]) {
677
- if (!argv["current-version"]) {
678
- throw new Error(
679
- " --semantic-versioning requires --current-version X.Y.Z (or pass --version-file)",
680
- );
681
- }
682
-
683
- // Bump is based on the commits you are about to apply (selected).
684
- const bump = await computeSemanticBumpForCommits(bottomToTop, gitRaw, semverIgnore);
685
-
686
- computedNextVersion = bump
687
- ? incrementVersion(argv["current-version"], bump)
688
- : argv["current-version"];
689
-
690
- log("");
691
- log(chalk.magenta("Semantic Versioning"));
692
- log(
693
- ` Current: ${chalk.bold(argv["current-version"])} ` +
694
- `Detected bump: ${chalk.bold(bump || "none")} ` +
695
- `Next: ${chalk.bold(computedNextVersion)}`,
696
- );
697
- }
698
-
699
- if (argv["create-release"]) {
700
- if (!argv["semantic-versioning"] || !argv["current-version"]) {
701
- throw new Error(
702
- " --create-release requires --semantic-versioning and --current-version X.Y.Z",
703
- );
704
- }
705
- if (!computedNextVersion) {
706
- throw new Error("Unable to determine release version. Check semantic-versioning inputs.");
707
- }
708
-
709
- const releaseBranch = `release/${computedNextVersion}`;
710
- const startPoint = argv.main; // e.g., 'origin/main' or a local ref
711
- await ensureReleaseBranchFresh(releaseBranch, startPoint);
712
-
713
- const changelogBody = await buildChangelogBody({
714
- version: computedNextVersion,
715
- hashes: bottomToTop,
716
- gitRawFn: gitRaw,
717
- semverIgnore, // raw flag value
718
- });
719
-
720
- await fsPromises.writeFile("RELEASE_CHANGELOG.md", changelogBody, "utf8");
721
- await gitRaw(["reset", "RELEASE_CHANGELOG.md"]);
722
- log(chalk.gray(`✅ Generated changelog for ${releaseBranch} → RELEASE_CHANGELOG.md`));
723
-
724
- log(chalk.cyan(`\nCreating ${chalk.bold(releaseBranch)} from ${chalk.bold(startPoint)}...`));
725
-
726
- await git.checkoutBranch(releaseBranch, startPoint);
727
-
728
- log(chalk.green(`✓ Ready on ${chalk.bold(releaseBranch)}. Cherry-picking will apply here.`));
729
- } else {
730
- // otherwise we stay on the current branch
731
- log(chalk.bold(`Base branch: ${currentBranch}`));
732
- }
733
-
734
- log(
735
- chalk.cyan(
736
- `\nCherry-picking ${bottomToTop.length} commit(s) onto ${currentBranch} (oldest → newest)...\n`,
737
- ),
738
- );
739
-
740
- const stats = await cherryPickSequential(bottomToTop);
741
-
742
- log(chalk.gray(`\nSummary → applied: ${stats.applied}, skipped: ${stats.skipped}`));
743
-
744
- if (stats.applied === 0) {
745
- err(
746
- chalk.yellow("\nNo commits were cherry-picked (all were skipped or unresolved). Aborting."),
747
- );
748
- // Abort any leftover state just in case
749
- try {
750
- await gitRaw(["cherry-pick", "--abort"]);
751
- } catch {}
752
- throw new Error("Nothing cherry-picked");
753
- }
754
-
755
- if (argv["push-release"]) {
756
- const baseBranchForGh = stripOrigin(argv.main); // 'origin/main' -> 'main'
757
- const prTitle = `Release ${computedNextVersion}`;
758
- const releaseBranch = `release/${computedNextVersion}`;
759
-
760
- const onBranch = await gitRaw(["rev-parse", "--abbrev-ref", "HEAD"]);
761
- if (!onBranch.startsWith(releaseBranch)) {
762
- throw new Error(`Version update should happen on a release branch. Current: ${onBranch}`);
763
- }
764
-
765
- log(chalk.cyan(`\nUpdating ${argv["version-file"]} version → ${computedNextVersion} ...`));
766
- await setPkgVersion(argv["version-file"], computedNextVersion);
767
- await git.add([argv["version-file"]]);
768
- const msg = argv["version-commit-message"].replace("{{version}}", computedNextVersion);
769
- await git.raw(["commit", "--no-verify", "-m", msg]);
770
-
771
- log(chalk.green(`✓ package.json updated and committed: ${msg}`));
772
-
773
- await git.push(["-u", "origin", releaseBranch, "--no-verify"]);
774
-
775
- const ghArgs = [
776
- "pr",
777
- "create",
778
- "--base",
779
- baseBranchForGh,
780
- "--head",
781
- releaseBranch,
782
- "--title",
783
- prTitle,
784
- "--body-file",
785
- "RELEASE_CHANGELOG.md",
786
- ];
787
- if (argv["draft-pr"]) {
788
- ghArgs.push("--draft");
789
- }
790
-
791
- await runGh(ghArgs);
792
- log(chalk.gray(`Pushed ${onBranch} with version bump.`));
793
- }
794
-
795
- const finalBranch = argv["create-release"]
796
- ? await gitRaw(["rev-parse", "--abbrev-ref", "HEAD"]) // should be release/*
797
- : currentBranch;
798
-
799
- log(chalk.green(`\n✅ Done on ${finalBranch}`));
800
- } catch (e) {
801
- err(chalk.red(`\n❌ Error: ${e.message || e}`));
802
- process.exit(1);
803
- }
717
+ log('');
718
+ log(chalk.magenta('Semantic Versioning'));
719
+ log(
720
+ ` Current: ${chalk.bold(argv['current-version'])} ` +
721
+ `Detected bump: ${chalk.bold(bump || 'none')} ` +
722
+ `Next: ${chalk.bold(computedNextVersion)}`,
723
+ );
724
+ }
725
+
726
+ if (argv['create-release']) {
727
+ if (!argv['semantic-versioning'] || !argv['current-version']) {
728
+ throw new Error(' --create-release requires --semantic-versioning and --current-version X.Y.Z');
729
+ }
730
+ if (!computedNextVersion) {
731
+ throw new Error('Unable to determine release version. Check semantic-versioning inputs.');
732
+ }
733
+
734
+ const releaseBranch = `release/${computedNextVersion}`;
735
+ const startPoint = argv.main; // e.g., 'origin/main' or a local ref
736
+ await ensureReleaseBranchFresh(releaseBranch, startPoint);
737
+
738
+ const changelogBody = await buildChangelogBody({
739
+ version: computedNextVersion,
740
+ hashes: bottomToTop,
741
+ gitRawFn: gitRaw,
742
+ semverIgnore, // raw flag value
743
+ });
744
+
745
+ await fsPromises.writeFile('RELEASE_CHANGELOG.md', changelogBody, 'utf8');
746
+ await gitRaw(['reset', 'RELEASE_CHANGELOG.md']);
747
+ log(chalk.gray(`✅ Generated changelog for ${releaseBranch} → RELEASE_CHANGELOG.md`));
748
+
749
+ log(chalk.cyan(`\nCreating ${chalk.bold(releaseBranch)} from ${chalk.bold(startPoint)}...`));
750
+
751
+ await git.checkoutBranch(releaseBranch, startPoint);
752
+
753
+ log(chalk.green(`✓ Ready on ${chalk.bold(releaseBranch)}. Cherry-picking will apply here.`));
754
+ } else {
755
+ // otherwise we stay on the current branch
756
+ log(chalk.bold(`Base branch: ${currentBranch}`));
757
+ }
758
+
759
+ log(chalk.cyan(`\nCherry-picking ${bottomToTop.length} commit(s) onto ${currentBranch} (oldest → newest)...\n`));
760
+
761
+ const stats = await cherryPickSequential(bottomToTop);
762
+
763
+ log(chalk.gray(`\nSummary → applied: ${stats.applied}, skipped: ${stats.skipped}`));
764
+
765
+ if (stats.applied === 0) {
766
+ err(chalk.yellow('\nNo commits were cherry-picked (all were skipped or unresolved). Aborting.'));
767
+ // Abort any leftover state just in case
768
+ try {
769
+ await gitRaw(['cherry-pick', '--abort']);
770
+ } catch {}
771
+ throw new Error('Nothing cherry-picked');
772
+ }
773
+
774
+ if (argv['push-release']) {
775
+ const baseBranchForGh = stripOrigin(argv.main); // 'origin/main' -> 'main'
776
+ const prTitle = `Release ${computedNextVersion}`;
777
+ const releaseBranch = `release/${computedNextVersion}`;
778
+
779
+ const onBranch = await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD']);
780
+ if (!onBranch.startsWith(releaseBranch)) {
781
+ throw new Error(`Version update should happen on a release branch. Current: ${onBranch}`);
782
+ }
783
+
784
+ log(chalk.cyan(`\nUpdating ${argv['version-file']} version ${computedNextVersion} ...`));
785
+ await setPkgVersion(argv['version-file'], computedNextVersion);
786
+ await git.add([argv['version-file']]);
787
+ const msg = argv['version-commit-message'].replace('{{version}}', computedNextVersion);
788
+ await git.raw(['commit', '--no-verify', '-m', msg]);
789
+
790
+ log(chalk.green(`✓ package.json updated and committed: ${msg}`));
791
+
792
+ await git.push(['-u', 'origin', releaseBranch, '--no-verify']);
793
+
794
+ const ghArgs = [
795
+ 'pr',
796
+ 'create',
797
+ '--base',
798
+ baseBranchForGh,
799
+ '--head',
800
+ releaseBranch,
801
+ '--title',
802
+ prTitle,
803
+ '--body-file',
804
+ 'RELEASE_CHANGELOG.md',
805
+ ];
806
+ if (argv['draft-pr']) {
807
+ ghArgs.push('--draft');
808
+ }
809
+
810
+ await runGh(ghArgs);
811
+ log(chalk.gray(`Pushed ${onBranch} with version bump.`));
812
+ }
813
+
814
+ const finalBranch = argv['create-release']
815
+ ? await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD']) // should be release/*
816
+ : currentBranch;
817
+
818
+ log(chalk.green(`\n✅ Done on ${finalBranch}`));
819
+ } catch (e) {
820
+ err(chalk.red(`\n❌ Error: ${e.message || e}`));
821
+ process.exit(1);
822
+ }
804
823
  }
805
824
 
806
825
  main();
@@ -810,181 +829,216 @@ main();
810
829
  */
811
830
 
812
831
  async function ensureReleaseBranchFresh(branchName, startPoint) {
813
- const branches = await git.branchLocal();
814
- const localExists = branches.all.includes(branchName);
815
- const remoteRef = await gitRaw(["ls-remote", "--heads", "origin", branchName]);
816
- const remoteExists = Boolean(remoteRef);
817
-
818
- if (!localExists && !remoteExists) {
819
- return;
820
- }
821
-
822
- const { action } = await inquirer.prompt([
823
- {
824
- type: "list",
825
- name: "action",
826
- message: `Release branch "${branchName}" already exists${localExists ? " locally" : ""}${remoteExists ? " on origin" : ""}. How do you want to proceed? (override, abort)`,
827
- choices: [
828
- { name: "Override (delete existing branch and recreate)", value: "override" },
829
- { name: "Abort", value: "abort" },
830
- ],
831
- },
832
- ]);
833
-
834
- if (action === "abort") {
835
- throw new Error(`Aborted: release branch "${branchName}" already exists.`);
836
- }
837
-
838
- // Ensure we are not on the branch before deleting local copy
839
- if (localExists) {
840
- if (branches.current === branchName) {
841
- const target = startPoint || "HEAD";
842
- log(chalk.gray(`Switching to ${target} before deleting ${branchName}...`));
843
- await gitRaw(["checkout", target]);
844
- }
845
- await gitRaw(["branch", "-D", branchName]);
846
- log(chalk.yellow(`↷ Deleted existing local branch ${branchName}`));
847
- }
848
-
849
- if (remoteExists) {
850
- try {
851
- await gitRaw(["push", "origin", "--delete", branchName]);
852
- log(chalk.yellow(`↷ Deleted existing remote branch origin/${branchName}`));
853
- } catch (e) {
854
- err(chalk.red(`Failed to delete remote branch origin/${branchName}: ${e.message || e}`));
855
- throw e;
832
+ const branches = await git.branchLocal();
833
+ const localExists = branches.all.includes(branchName);
834
+ const remoteRef = await gitRaw(['ls-remote', '--heads', 'origin', branchName]);
835
+ const remoteExists = Boolean(remoteRef);
836
+
837
+ if (!localExists && !remoteExists) {
838
+ return;
839
+ }
840
+
841
+ const { action } = await inquirer.prompt([
842
+ {
843
+ type: 'select',
844
+ name: 'action',
845
+ message: `Release branch "${branchName}" already exists${localExists ? ' locally' : ''}${
846
+ remoteExists ? ' on origin' : ''
847
+ }. How do you want to proceed? (override, abort)`,
848
+ choices: [
849
+ { name: 'Override (delete existing branch and recreate)', value: 'override' },
850
+ { name: 'Abort', value: 'abort' },
851
+ ],
852
+ },
853
+ ]);
854
+
855
+ if (action === 'abort') {
856
+ throw new Error(`Aborted: release branch "${branchName}" already exists.`);
857
+ }
858
+
859
+ // Ensure we are not on the branch before deleting local copy
860
+ if (localExists) {
861
+ if (branches.current === branchName) {
862
+ const target = startPoint || 'HEAD';
863
+ log(chalk.gray(`Switching to ${target} before deleting ${branchName}...`));
864
+ await gitRaw(['checkout', target]);
865
+ }
866
+ await gitRaw(['branch', '-D', branchName]);
867
+ log(chalk.yellow(`↷ Deleted existing local branch ${branchName}`));
868
+ }
869
+
870
+ if (remoteExists) {
871
+ try {
872
+ await gitRaw(['push', 'origin', '--delete', branchName]);
873
+ log(chalk.yellow(`↷ Deleted existing remote branch origin/${branchName}`));
874
+ } catch (e) {
875
+ err(chalk.red(`Failed to delete remote branch origin/${branchName}: ${e.message || e}`));
876
+ throw e;
877
+ }
856
878
  }
857
- }
858
879
  }
859
880
 
860
881
  async function buildChangelogBody({ version, hashes, gitRawFn, semverIgnore }) {
861
- const today = new Date().toISOString().slice(0, 10);
862
- const header = version ? `## Release ${version} — ${today}` : `## Release — ${today}`;
863
- const semverIgnorePatterns = parseSemverIgnore(semverIgnore);
882
+ const today = new Date().toISOString().slice(0, 10);
883
+ const header = version ? `## Release ${version} — ${today}` : `## Release — ${today}`;
884
+ const semverIgnorePatterns = parseSemverIgnore(semverIgnore);
864
885
 
865
- const breakings = [];
866
- const features = [];
867
- const fixes = [];
868
- const others = [];
886
+ const breakings = [];
887
+ const features = [];
888
+ const fixes = [];
889
+ const others = [];
869
890
 
870
- for (const h of hashes) {
871
- const msg = await gitRawFn(["show", "--format=%B", "-s", h]);
891
+ for (const h of hashes) {
892
+ const msg = await gitRawFn(['show', '--format=%B', '-s', h]);
872
893
 
873
- const subject = msg.split(/\r?\n/)[0].trim(); // first line of commit message
874
- const shaDisplay = shortSha(h);
894
+ const subject = msg.split(/\r?\n/)[0].trim(); // first line of commit message
895
+ const shaDisplay = shortSha(h);
875
896
 
876
- // normal classification first
877
- let level = classifySingleCommit(msg);
897
+ // normal classification first
898
+ let level = classifySingleCommit(msg);
878
899
 
879
- // ⬇ Apply semver-ignore logic
880
- const matched = matchesAnyPattern(msg, semverIgnorePatterns); // evaluate against full message
881
- if (matched) {
882
- level = null; // drop it into "Other"
883
- }
884
-
885
- switch (level) {
886
- case "major":
887
- breakings.push(`${shaDisplay} ${subject}`);
888
- break;
889
- case "minor":
890
- features.push(`${shaDisplay} ${subject}`);
891
- break;
892
- case "patch":
893
- fixes.push(`${shaDisplay} ${subject}`);
894
- break;
895
- default:
896
- others.push(`${shaDisplay} ${subject}`);
897
- break;
898
- }
899
- }
900
-
901
- const sections = [];
902
- if (breakings.length) {
903
- sections.push(`### ✨ Breaking Changes\n${breakings.join("\n")}`);
904
- }
905
- if (features.length) {
906
- sections.push(`### ✨ Features\n${features.join("\n")}`);
907
- }
908
- if (fixes.length) {
909
- sections.push(`### 🐛 Fixes\n${fixes.join("\n")}`);
910
- }
911
- if (others.length) {
912
- sections.push(`### 🧹 Others\n${others.join("\n")}`);
913
- }
914
-
915
- return `${header}\n\n${sections.join("\n\n")}\n`;
900
+ // ⬇ Apply ignore-semver logic
901
+ const matched = matchesAnyPattern(msg, semverIgnorePatterns); // evaluate against full message
902
+ if (matched) {
903
+ level = null; // drop it into "Other"
904
+ }
905
+
906
+ switch (level) {
907
+ case 'major':
908
+ breakings.push(`${shaDisplay} ${subject}`);
909
+ break;
910
+ case 'minor':
911
+ features.push(`${shaDisplay} ${subject}`);
912
+ break;
913
+ case 'patch':
914
+ fixes.push(`${shaDisplay} ${subject}`);
915
+ break;
916
+ default:
917
+ others.push(`${shaDisplay} ${subject}`);
918
+ break;
919
+ }
920
+ }
921
+
922
+ const sections = [];
923
+ if (breakings.length) {
924
+ sections.push(`### ✨ Breaking Changes\n${breakings.join('\n')}`);
925
+ }
926
+ if (features.length) {
927
+ sections.push(`### ✨ Features\n${features.join('\n')}`);
928
+ }
929
+ if (fixes.length) {
930
+ sections.push(`### 🐛 Fixes\n${fixes.join('\n')}`);
931
+ }
932
+ if (others.length) {
933
+ sections.push(`### 🧹 Others\n${others.join('\n')}`);
934
+ }
935
+
936
+ return `${header}\n\n${sections.join('\n\n')}\n`;
916
937
  }
917
938
  function shortSha(sha) {
918
- return String(sha).slice(0, 7);
939
+ return String(sha).slice(0, 7);
919
940
  }
920
941
 
921
942
  function stripOrigin(ref) {
922
- return ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
943
+ return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref;
944
+ }
945
+
946
+ async function checkGhCli() {
947
+ return new Promise((resolve) => {
948
+ const p = spawn('gh', ['--version'], { stdio: 'pipe' });
949
+ p.on('error', () => resolve(false));
950
+ p.on('close', (code) => resolve(code === 0));
951
+ });
923
952
  }
924
953
 
925
954
  async function runGh(args) {
926
- return new Promise((resolve, reject) => {
927
- const p = spawn("gh", args, { stdio: "inherit" });
928
- p.on("error", reject);
929
- p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`gh exited ${code}`))));
930
- });
955
+ return new Promise((resolve, reject) => {
956
+ const p = spawn('gh', args, { stdio: 'inherit' });
957
+ p.on('error', reject);
958
+ p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`gh exited ${code}`))));
959
+ });
931
960
  }
932
961
  async function readJson(filePath) {
933
- const raw = await fsPromises.readFile(filePath, "utf8");
934
- return JSON.parse(raw);
962
+ const raw = await fsPromises.readFile(filePath, 'utf8');
963
+ return JSON.parse(raw);
935
964
  }
936
965
 
937
966
  async function writeJson(filePath, data) {
938
- const text = `${JSON.stringify(data, null, 2)}\n`;
939
- await fsPromises.writeFile(filePath, text, "utf8");
967
+ const text = `${JSON.stringify(data, null, 2)}\n`;
968
+ await fsPromises.writeFile(filePath, text, 'utf8');
940
969
  }
941
970
 
942
971
  /** Read package.json version; throw if missing */
943
972
  async function getPkgVersion(pkgPath) {
944
- const pkg = await readJson(pkgPath);
945
- const v = pkg?.version;
946
- if (!v) {
947
- throw new Error(`No "version" field found in ${pkgPath}`);
948
- }
949
- return v;
973
+ const pkg = await readJson(pkgPath);
974
+ const v = pkg?.version;
975
+ if (!v) {
976
+ throw new Error(`No "version" field found in ${pkgPath}`);
977
+ }
978
+ return v;
950
979
  }
951
980
 
952
981
  /** Update package.json version in-place */
953
982
  async function setPkgVersion(pkgPath, nextVersion) {
954
- const pkg = await readJson(pkgPath);
955
- pkg.version = nextVersion;
956
- await writeJson(pkgPath, pkg);
983
+ const pkg = await readJson(pkgPath);
984
+ pkg.version = nextVersion;
985
+ await writeJson(pkgPath, pkg);
957
986
  }
958
987
 
959
988
  function parseSemverIgnore(argvValue) {
960
- if (!argvValue) return [];
961
- return argvValue
962
- .split(",")
963
- .map((p) => p.trim())
964
- .filter(Boolean)
965
- .map((pattern) => {
966
- try {
967
- return new RegExp(pattern, "i"); // case-insensitive on full message
968
- } catch (e) {
969
- err(chalk.red(`Invalid --semver-ignore pattern "${pattern}": ${e.message || e}`));
970
- return null;
971
- }
972
- })
973
- .filter(Boolean);
989
+ if (!argvValue) return [];
990
+ return argvValue
991
+ .split(',')
992
+ .map((p) => p.trim())
993
+ .filter(Boolean)
994
+ .map((pattern) => {
995
+ try {
996
+ return new RegExp(pattern, 'i'); // case-insensitive on full message
997
+ } catch (e) {
998
+ err(chalk.red(`Invalid --ignore-semver pattern "${pattern}": ${e.message || e}`));
999
+ return null;
1000
+ }
1001
+ })
1002
+ .filter(Boolean);
974
1003
  }
975
1004
 
976
1005
  function matchesAnyPattern(text, patterns) {
977
- if (!patterns || patterns.length === 0) return false;
978
-
979
- const matched = patterns.find((rx) => rx.test(text));
980
- if (matched) {
981
- log(
982
- chalk.cyan(
983
- `↷ Semver ignored (pattern: /${matched.source}/i): ${chalk.dim(`(${text.split("\n")[0]})`)}`,
984
- ),
985
- );
986
- return true;
987
- }
988
-
989
- return false;
1006
+ if (!patterns || patterns.length === 0) return false;
1007
+
1008
+ const matched = patterns.find((rx) => rx.test(text));
1009
+ if (matched) {
1010
+ log(chalk.cyan(`↷ Semver ignored (pattern: /${matched.source}/i): ${chalk.dim(`(${text.split('\n')[0]})`)}`));
1011
+ return true;
1012
+ }
1013
+
1014
+ return false;
1015
+ }
1016
+
1017
+ function parseIgnoreCommits(argvValue) {
1018
+ if (!argvValue) return [];
1019
+ return argvValue
1020
+ .split(',')
1021
+ .map((p) => p.trim())
1022
+ .filter(Boolean)
1023
+ .map((pattern) => {
1024
+ try {
1025
+ return new RegExp(pattern, 'i'); // case-insensitive matching
1026
+ } catch (e) {
1027
+ err(chalk.red(`Invalid --ignore-commits pattern "${pattern}": ${e.message || e}`));
1028
+ return null;
1029
+ }
1030
+ })
1031
+ .filter(Boolean);
1032
+ }
1033
+
1034
+ function shouldIgnoreCommit(subject, patterns) {
1035
+ if (!patterns || patterns.length === 0) return false;
1036
+
1037
+ const matched = patterns.find((rx) => rx.test(subject));
1038
+ if (matched) {
1039
+ log(chalk.gray(`↷ Ignoring commit (pattern: /${matched.source}/i): ${chalk.dim(subject.slice(0, 80))}`));
1040
+ return true;
1041
+ }
1042
+
1043
+ return false;
990
1044
  }