cherrypick-interactive 1.4.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/cli.js +795 -720
  2. package/package.json +2 -8
  3. package/cli.backup.js +0 -193
package/cli.js CHANGED
@@ -1,512 +1,523 @@
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 (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`));
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`));
33
38
  }
34
39
 
35
40
  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: '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;
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;
111
118
 
112
119
  const log = (...a) => console.log(...a);
113
120
  const err = (...a) => console.error(...a);
114
121
 
115
122
  async function gitRaw(args) {
116
- const out = await git.raw(args);
117
- return out.trim();
123
+ const out = await git.raw(args);
124
+ return out.trim();
118
125
  }
119
126
 
120
127
  async function getSubjects(branch) {
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));
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
133
  }
127
134
 
128
135
  async function getDevCommits(branch, since) {
129
- const out = await gitRaw(['log', '--no-merges', '--since=' + since, '--pretty=%H %s', branch]);
136
+ const out = await gitRaw(["log", "--no-merges", `--since=${since}`, "--pretty=%H %s", branch]);
130
137
 
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
- });
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
+ });
140
147
  }
141
148
 
142
149
  function filterMissing(devCommits, mainSubjects) {
143
- return devCommits.filter(({ subject }) => !mainSubjects.has(subject));
150
+ return devCommits.filter(({ subject }) => !mainSubjects.has(subject));
144
151
  }
145
152
 
146
153
  async function selectCommitsInteractive(missing) {
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;
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;
174
181
  }
175
182
 
176
183
  async function handleCherryPickConflict(hash) {
177
- if (!(await isCherryPickInProgress())) {
178
- return 'skipped';
179
- }
184
+ if (!(await isCherryPickInProgress())) {
185
+ return "skipped";
186
+ }
180
187
 
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)
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)
184
191
 
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
- ]);
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
+ ]);
197
204
 
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
- }
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
210
 
204
- if (action === 'abort') {
205
- await gitRaw(['cherry-pick', '--abort']);
206
- throw new Error('Cherry-pick aborted by user.');
207
- }
211
+ if (action === "abort") {
212
+ await gitRaw(["cherry-pick", "--abort"]);
213
+ throw new Error("Cherry-pick aborted by user.");
214
+ }
208
215
 
209
- const res = await conflictsResolutionWizard(hash);
210
- if (res === 'continued' || res === 'skipped') {
211
- return res;
212
- }
216
+ const res = await conflictsResolutionWizard(hash);
217
+ if (res === "continued" || res === "skipped") {
218
+ return res;
213
219
  }
220
+ }
214
221
  }
215
222
 
216
223
  async function getConflictedFiles() {
217
- const out = await gitRaw(['diff', '--name-only', '--diff-filter=U']);
218
- return out ? out.split('\n').filter(Boolean) : [];
224
+ const out = await gitRaw(["diff", "--name-only", "--diff-filter=U"]);
225
+ return out ? out.split("\n").filter(Boolean) : [];
219
226
  }
220
227
 
221
228
  async function assertNoUnmerged() {
222
- const files = await getConflictedFiles();
223
- return files.length === 0;
229
+ const files = await getConflictedFiles();
230
+ return files.length === 0;
224
231
  }
225
232
 
226
233
  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
- }
234
+ try {
235
+ const head = await gitRaw(["rev-parse", "-q", "--verify", "CHERRY_PICK_HEAD"]);
236
+ return !!head;
237
+ } catch {
238
+ return false;
239
+ }
233
240
  }
234
241
 
235
242
  async function hasStagedChanges() {
236
- const out = await gitRaw(['diff', '--cached', '--name-only']);
237
- return !!out;
243
+ const out = await gitRaw(["diff", "--cached", "--name-only"]);
244
+ return !!out;
238
245
  }
239
246
 
240
247
  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;
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
253
  }
247
254
 
248
255
  async function runBin(bin, args) {
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
- });
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
261
  }
255
262
 
256
263
  async function showConflictsList() {
257
- const files = await getConflictedFiles();
264
+ const files = await getConflictedFiles();
258
265
 
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;
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;
268
275
  }
269
276
 
270
277
  async function resolveSingleFileWizard(file) {
271
- const { action } = await inquirer.prompt([
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([
272
309
  {
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
- ],
310
+ type: "confirm",
311
+ name: "stageNow",
312
+ message: "File edited. Stage it now?",
313
+ default: true,
284
314
  },
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}`));
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}`));
322
326
  }
327
+ } catch (e) {
328
+ err(chalk.red(`Action failed on ${file}: ${e.message || e}`));
329
+ }
323
330
 
324
- return action;
331
+ return action;
325
332
  }
326
333
 
327
334
  async function conflictsResolutionWizard(hash) {
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
- }
378
-
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
- },
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
+ },
399
354
  ]);
400
355
 
401
- if (!choice) {
402
- continue;
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";
403
368
  }
369
+ if (emptyAction === "back") {
370
+ // ← FIX #1: really go back to the conflict menu (do NOT try --continue)
371
+ continue;
372
+ }
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
+ }
404
387
 
405
- if (choice.type === 'file') {
406
- await resolveSingleFileWizard(choice.file);
407
- continue;
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
+
410
+ if (!choice) {
411
+ continue;
412
+ }
413
+
414
+ if (choice.type === "file") {
415
+ await resolveSingleFileWizard(choice.file);
416
+ continue;
417
+ }
418
+
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
408
432
  }
433
+ }
434
+ continue;
435
+ }
409
436
 
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
- }
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
425
470
  continue;
471
+ }
426
472
  }
427
473
 
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
- }
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."));
474
481
  }
482
+ } else {
483
+ err(chalk.yellow("There are still unmerged files."));
484
+ }
485
+ }
475
486
 
476
- if (choice.type === 'global' && choice.action === 'back') {
477
- return 'back';
478
- }
487
+ if (choice.type === "global" && choice.action === "back") {
488
+ return "back";
479
489
  }
490
+ }
480
491
  }
481
492
 
482
493
  async function cherryPickSequential(hashes) {
483
- const result = { applied: 0, skipped: 0 };
494
+ const result = { applied: 0, skipped: 0 };
484
495
 
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
- }
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;
506
512
  }
513
+ } catch (abortErr) {
514
+ err(chalk.red(`✖ Cherry-pick aborted on ${hash}`));
515
+ throw abortErr;
516
+ }
507
517
  }
518
+ }
508
519
 
509
- return result;
520
+ return result;
510
521
  }
511
522
 
512
523
  /**
@@ -514,271 +525,282 @@ async function cherryPickSequential(hashes) {
514
525
  * @returns {Promise<void>}
515
526
  */
516
527
  function parseVersion(v) {
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] };
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] };
524
535
  }
525
536
 
526
537
  function incrementVersion(version, bump) {
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}`;
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}`;
538
549
  }
539
550
 
540
551
  function normalizeMessage(msg) {
541
- // normalize whitespace; keep case-insensitive matching
542
- return (msg || '').replace(/\r\n/g, '\n');
552
+ // normalize whitespace; keep case-insensitive matching
553
+ return (msg || "").replace(/\r\n/g, "\n");
543
554
  }
544
555
 
545
556
  // Returns "major" | "minor" | "patch" | null for a single commit message
546
557
  function classifySingleCommit(messageBody) {
547
- const body = normalizeMessage(messageBody);
558
+ const body = normalizeMessage(messageBody);
548
559
 
549
- // Major
550
- if (/\bBREAKING[- _]CHANGE(?:\([^)]+\))?\s*:?/i.test(body)) {
551
- return 'major';
552
- }
560
+ // Major
561
+ if (/\bBREAKING[- _]CHANGE(?:\([^)]+\))?\s*:?/i.test(body)) {
562
+ return "major";
563
+ }
553
564
 
554
- // Minor
555
- if (/(^|\n)\s*(\*?\s*)?feat(?:\([^)]+\))?\s*:?/i.test(body)) {
556
- return 'minor';
557
- }
565
+ // Minor
566
+ if (/(^|\n)\s*(\*?\s*)?feat(?:\([^)]+\))?\s*:?/i.test(body)) {
567
+ return "minor";
568
+ }
558
569
 
559
- // Patch
560
- if (/(^|\n)\s*(\*?\s*)?(fix|perf)(?:\([^)]+\))?\s*:?/i.test(body)) {
561
- return 'patch';
562
- }
570
+ // Patch
571
+ if (/(^|\n)\s*(\*?\s*)?(fix|perf)(?:\([^)]+\))?\s*:?/i.test(body)) {
572
+ return "patch";
573
+ }
563
574
 
564
- return null;
575
+ return null;
565
576
  }
566
577
 
567
578
  // Given many commits, collapse to a single bump level
568
579
  function collapseBumps(levels) {
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;
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
590
  }
580
591
 
581
592
  // Fetch full commit messages (%B) for SHAs and compute bump
582
593
  async function computeSemanticBumpForCommits(hashes, gitRawFn, semverignore) {
583
- if (!hashes.length) {
584
- return null;
585
- }
586
-
587
- const levels = [];
594
+ if (!hashes.length) {
595
+ return null;
596
+ }
588
597
 
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);
598
+ const levels = [];
599
+ const semverIgnorePatterns = parseSemverIgnore(semverignore);
593
600
 
594
- // 🔹 Apply --semverignore
595
- const semverIgnorePatterns = parseSemverIgnore(semverignore);
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);
596
605
 
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
- }
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
+ }
604
610
 
605
- if (level) {
606
- levels.push(level);
607
- if (level === 'major') break; // early exit if major is found
608
- }
611
+ if (level) {
612
+ levels.push(level);
613
+ if (level === "major") break; // early exit if major is found
609
614
  }
615
+ }
610
616
 
611
- return collapseBumps(levels);
617
+ return collapseBumps(levels);
612
618
  }
613
619
 
614
620
  async function main() {
615
- try {
616
- if (!argv['no-fetch']) {
617
- log(chalk.gray('Fetching remotes (git fetch --prune)...'));
618
- await git.fetch(['--prune']);
619
- }
620
-
621
- const currentBranch = (await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD'])) || 'HEAD';
622
-
623
- log(chalk.gray(`Comparing subjects since ${argv.since}`));
624
- log(chalk.gray(`Dev: ${argv.dev}`));
625
- log(chalk.gray(`Main: ${argv.main}`));
626
-
627
- const [devCommits, mainSubjects] = await Promise.all([getDevCommits(argv.dev, argv.since), getSubjects(argv.main)]);
628
-
629
- const missing = filterMissing(devCommits, mainSubjects);
630
-
631
- if (missing.length === 0) {
632
- log(chalk.green('✅ No missing commits found in the selected window.'));
633
- return;
634
- }
635
-
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
- }
621
+ try {
622
+ if (!argv["no-fetch"]) {
623
+ log(chalk.gray("Fetching remotes (git fetch --prune)..."));
624
+ await git.fetch(["--prune"]);
625
+ }
648
626
 
649
- const bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a));
627
+ const currentBranch = (await gitRaw(["rev-parse", "--abbrev-ref", "HEAD"])) || "HEAD";
650
628
 
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
- }
629
+ log(chalk.gray(`Comparing subjects since ${argv.since}`));
630
+ log(chalk.gray(`Dev: ${argv.dev}`));
631
+ log(chalk.gray(`Main: ${argv.main}`));
659
632
 
660
- if (argv['version-file'] && !argv['current-version']) {
661
- const currentVersionFromPkg = await getPkgVersion(argv['version-file']);
662
- argv['current-version'] = currentVersionFromPkg;
663
- }
633
+ const [devCommits, mainSubjects] = await Promise.all([
634
+ getDevCommits(argv.dev, argv.since),
635
+ getSubjects(argv.main),
636
+ ]);
664
637
 
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
- }
638
+ const missing = filterMissing(devCommits, mainSubjects);
670
639
 
671
- // Bump is based on the commits you are about to apply (selected).
672
- const bump = await computeSemanticBumpForCommits(bottomToTop, gitRaw, argv.semverignore);
640
+ if (missing.length === 0) {
641
+ log(chalk.green("✅ No missing commits found in the selected window."));
642
+ return;
643
+ }
673
644
 
674
- computedNextVersion = bump ? incrementVersion(argv['current-version'], bump) : argv['current-version'];
645
+ const semverIgnore = argv["semver-ignore"];
646
+ const indexByHash = new Map(missing.map((c, i) => [c.hash, i])); // 0=newest, larger=older
647
+
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
+ }
675
658
 
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
- }
659
+ const bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a));
684
660
 
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
- }
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
+ }
717
669
 
718
- log(chalk.cyan(`\nCherry-picking ${bottomToTop.length} commit(s) onto ${currentBranch} (oldest → newest)...\n`));
670
+ if (argv["version-file"] && !argv["current-version"]) {
671
+ const currentVersionFromPkg = await getPkgVersion(argv["version-file"]);
672
+ argv["current-version"] = currentVersionFromPkg;
673
+ }
719
674
 
720
- const stats = await cherryPickSequential(bottomToTop);
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
+ }
721
698
 
722
- log(chalk.gray(`\nSummary → applied: ${stats.applied}, skipped: ${stats.skipped}`));
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
+ }
723
733
 
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
- }
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
+ }
732
754
 
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
- }
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
+ }
772
794
 
773
- const finalBranch = argv['create-release']
774
- ? await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD']) // should be release/*
775
- : currentBranch;
795
+ const finalBranch = argv["create-release"]
796
+ ? await gitRaw(["rev-parse", "--abbrev-ref", "HEAD"]) // should be release/*
797
+ : currentBranch;
776
798
 
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
- }
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
+ }
782
804
  }
783
805
 
784
806
  main();
@@ -787,129 +809,182 @@ main();
787
809
  * Utils
788
810
  */
789
811
 
790
- async function ensureBranchDoesNotExistLocally(branchName) {
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
- );
812
+ 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;
796
856
  }
857
+ }
797
858
  }
798
859
 
799
860
  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);
861
+ const today = new Date().toISOString().slice(0, 10);
862
+ const header = version ? `## Release ${version} — ${today}` : `## Release — ${today}`;
863
+ const semverIgnorePatterns = parseSemverIgnore(semverIgnore);
803
864
 
804
- const breakings = [];
805
- const features = [];
806
- const fixes = [];
807
- const others = [];
865
+ const breakings = [];
866
+ const features = [];
867
+ const fixes = [];
868
+ const others = [];
808
869
 
809
- for (const h of hashes) {
810
- const msg = await gitRawFn(['show', '--format=%B', '-s', h]);
870
+ for (const h of hashes) {
871
+ const msg = await gitRawFn(["show", "--format=%B", "-s", h]);
811
872
 
812
- const subject = msg.split(/\r?\n/)[0].trim(); // first line of commit message
813
- const shaDisplay = shortSha(h);
873
+ const subject = msg.split(/\r?\n/)[0].trim(); // first line of commit message
874
+ const shaDisplay = shortSha(h);
814
875
 
815
- // normal classification first
816
- let level = classifySingleCommit(msg);
876
+ // normal classification first
877
+ let level = classifySingleCommit(msg);
817
878
 
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
- }
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"
838
883
  }
839
884
 
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')}`);
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;
846
898
  }
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`;
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`;
855
916
  }
856
917
  function shortSha(sha) {
857
- return String(sha).slice(0, 7);
918
+ return String(sha).slice(0, 7);
858
919
  }
859
920
 
860
921
  function stripOrigin(ref) {
861
- return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref;
922
+ return ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
862
923
  }
863
924
 
864
925
  async function runGh(args) {
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
- });
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
+ });
870
931
  }
871
932
  async function readJson(filePath) {
872
- const raw = await fsPromises.readFile(filePath, 'utf8');
873
- return JSON.parse(raw);
933
+ const raw = await fsPromises.readFile(filePath, "utf8");
934
+ return JSON.parse(raw);
874
935
  }
875
936
 
876
937
  async function writeJson(filePath, data) {
877
- const text = JSON.stringify(data, null, 2) + '\n';
878
- await fsPromises.writeFile(filePath, text, 'utf8');
938
+ const text = `${JSON.stringify(data, null, 2)}\n`;
939
+ await fsPromises.writeFile(filePath, text, "utf8");
879
940
  }
880
941
 
881
942
  /** Read package.json version; throw if missing */
882
943
  async function getPkgVersion(pkgPath) {
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;
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;
889
950
  }
890
951
 
891
952
  /** Update package.json version in-place */
892
953
  async function setPkgVersion(pkgPath, nextVersion) {
893
- const pkg = await readJson(pkgPath);
894
- pkg.version = nextVersion;
895
- await writeJson(pkgPath, pkg);
954
+ const pkg = await readJson(pkgPath);
955
+ pkg.version = nextVersion;
956
+ await writeJson(pkgPath, pkg);
896
957
  }
897
958
 
898
959
  function parseSemverIgnore(argvValue) {
899
- if (!argvValue) return [];
900
- return argvValue
901
- .split(',')
902
- .map((p) => p.trim())
903
- .filter(Boolean);
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);
904
974
  }
905
975
 
906
976
  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;
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;
915
990
  }