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