cherrypick-interactive 1.4.3 β†’ 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +190 -19
  2. package/cli.js +174 -45
  3. package/package.json +4 -6
  4. package/cli.backup.js +0 -193
package/README.md CHANGED
@@ -7,13 +7,15 @@
7
7
  ## 🚧 Motivation
8
8
 
9
9
  When you maintain long-lived branches like `dev` and `main`, keeping them in sync can get messy.
10
- Sometimes you rebase, sometimes you cherry-pick, sometimes you merge release branches β€” and every time, it’s easy to lose track of which commits actually made it into production.
10
+ Sometimes you rebase, sometimes you cherry-pick, sometimes you merge release branches β€” and every time, it's easy to lose track of which commits actually made it into production.
11
11
 
12
12
  **This CLI solves that pain point:**
13
13
 
14
14
  - It compares two branches (e.g. `origin/dev` vs `origin/main`)
15
15
  - Lists commits in `dev` that are *not yet* in `main`
16
16
  - Lets you choose which ones to cherry-pick interactively
17
+ - Handles merge conflicts with an interactive resolution wizard
18
+ - Preserves original commit messages perfectly (even with squashed commits)
17
19
  - (Optionally) bumps your semantic version, creates a release branch, updates `package.json`, and opens a GitHub draft PR for review
18
20
 
19
21
  No manual `git log` diffing. No risky merges. No guesswork.
@@ -25,6 +27,8 @@ No manual `git log` diffing. No risky merges. No guesswork.
25
27
  - πŸ” Finds commits in `dev` not present in `main`
26
28
  - πŸ—‚οΈ Lets you select which commits to cherry-pick (or pick all)
27
29
  - πŸͺœ Cherry-picks in the correct order (oldest β†’ newest)
30
+ - βš”οΈ **Interactive conflict resolution wizard** with multiple strategies
31
+ - 🎯 **Preserves exact commit messages** from squashed commits
28
32
  - πŸͺ„ Detects **semantic version bump** (`major`, `minor`, `patch`) from conventional commits
29
33
  - 🧩 Creates a `release/x.y.z` branch from `main`
30
34
  - 🧾 Generates a Markdown changelog from commits
@@ -48,7 +52,12 @@ npm install -g cherrypick-interactive
48
52
  ## πŸš€ Quick Start
49
53
 
50
54
  ```bash
51
- cherrypick-interactive --semantic-versioning --version-file ./package.json --create-release --push-release --draft-pr
55
+ cherrypick-interactive \
56
+ --semantic-versioning \
57
+ --version-file ./package.json \
58
+ --create-release \
59
+ --push-release \
60
+ --draft-pr
52
61
  ```
53
62
 
54
63
  βœ… This will:
@@ -57,7 +66,7 @@ cherrypick-interactive --semantic-versioning --version-file ./package.json
57
66
  3. Let you select which to cherry-pick
58
67
  4. Compute the next version from commit messages
59
68
  5. Create `release/<next-version>` from `main`
60
- 6. Cherry-pick the selected commits
69
+ 6. Cherry-pick the selected commits (with conflict resolution if needed)
61
70
  7. Update your `package.json` version and commit it
62
71
  8. Push the branch and open a **draft PR** on GitHub
63
72
 
@@ -71,7 +80,7 @@ cherrypick-interactive --semantic-versioning --version-file ./package.json
71
80
  cherrypick-interactive
72
81
  ```
73
82
 
74
- Lists commits in `origin/dev` that aren’t in `origin/main`, filtered by the last week.
83
+ Lists commits in `origin/dev` that aren't in `origin/main`, filtered by the last week.
75
84
 
76
85
  ### 2. Cherry-pick all missing commits automatically
77
86
 
@@ -85,6 +94,22 @@ cherrypick-interactive --all-yes
85
94
  cherrypick-interactive --dry-run
86
95
  ```
87
96
 
97
+ ### 4. Filter commits by pattern
98
+
99
+ ```bash
100
+ cherrypick-interactive --ignore-commits "^chore\(deps\)|^ci:"
101
+ ```
102
+
103
+ Excludes commits starting with `chore(deps)` or `ci:` from the selection list.
104
+
105
+ ### 5. Ignore certain commits from semantic versioning
106
+
107
+ ```bash
108
+ cherrypick-interactive --ignore-semver "bump|dependencies"
109
+ ```
110
+
111
+ Treats commits containing "bump" or "dependencies" as chores (no version bump).
112
+
88
113
  ---
89
114
 
90
115
  ## βš™οΈ Options
@@ -97,13 +122,15 @@ cherrypick-interactive --dry-run
97
122
  | `--no-fetch` | Skip `git fetch --prune` | `false` |
98
123
  | `--all-yes` | Cherry-pick all missing commits without prompt | `false` |
99
124
  | `--dry-run` | Show what would happen without applying changes | `false` |
100
- | `--semantic-versioning` | Detect semantic version bump from commits | `false` |
125
+ | `--semantic-versioning` | Detect semantic version bump from commits | `true` |
101
126
  | `--current-version` | Current version (if not reading from file) | β€” |
102
- | `--version-file` | Path to `package.json` (to read & update version) | β€” |
103
- | `--create-release` | Create `release/x.y.z` branch from `main` | `false` |
127
+ | `--version-file` | Path to `package.json` (to read & update version) | `./package.json` |
128
+ | `--create-release` | Create `release/x.y.z` branch from `main` | `true` |
104
129
  | `--push-release` | Push release branch to origin | `true` |
105
130
  | `--draft-pr` | Create the GitHub PR as a draft | `false` |
106
131
  | `--version-commit-message` | Template for version bump commit | `chore(release): bump version to {{version}}` |
132
+ | `--ignore-semver` | Comma-separated regex patterns to ignore for semver | β€” |
133
+ | `--ignore-commits` | Comma-separated regex patterns to exclude commits | β€” |
107
134
 
108
135
  ---
109
136
 
@@ -117,6 +144,39 @@ The tool analyzes commit messages using **Conventional Commits**:
117
144
  | `feat:` | `feat(ui): add dark mode` | **minor** |
118
145
  | `fix:` / `perf:` | `fix(api): correct pagination offset` | **patch** |
119
146
 
147
+ Use `--ignore-semver` to treat certain commits as chores:
148
+
149
+ ```bash
150
+ cherrypick-interactive --ignore-semver "^chore\(deps\)|bump|merge"
151
+ ```
152
+
153
+ ---
154
+
155
+ ## βš”οΈ Interactive Conflict Resolution
156
+
157
+ When cherry-picking encounters conflicts, the tool provides an **interactive wizard**:
158
+
159
+ ### Conflict Resolution Options:
160
+
161
+ **Per-file resolution:**
162
+ - **Use ours** β€” Keep the current branch's version
163
+ - **Use theirs** β€” Accept the cherry-picked commit's version
164
+ - **Open in editor** β€” Manually resolve conflicts in your editor
165
+ - **Show diff** β€” View the conflicting changes
166
+ - **Mark resolved** β€” Stage the file as-is
167
+
168
+ **Bulk actions:**
169
+ - **Use ours for ALL** β€” Apply current branch's version to all conflicts
170
+ - **Use theirs for ALL** β€” Accept cherry-picked version for all conflicts
171
+ - **Stage ALL** β€” Mark all files as resolved
172
+ - **Launch mergetool** β€” Use Git's configured merge tool
173
+
174
+ ### Key Features:
175
+
176
+ βœ… **Preserves original commit messages** β€” Even when resolving conflicts, the commit message from the original commit in `dev` is maintained exactly
177
+ βœ… **Handles squashed commits** β€” Works correctly with squashed commits that contain multiple changes
178
+ βœ… **Resume cherry-picking** β€” After resolving conflicts, automatically continues with remaining commits
179
+
120
180
  ---
121
181
 
122
182
  ## πŸͺ΅ Example Run
@@ -129,18 +189,47 @@ Comparing subjects since 1 week ago
129
189
  Dev: origin/dev
130
190
  Main: origin/main
131
191
 
132
- Select commits to cherry-pick (3 missing):
133
- ❯◯ (850aa02) fix: crypto withdrawal payload
134
- β—― (2995cea) feat: OTC offer account validation
135
- β—― (84fe310) chore: bump dependencies
192
+ βœ” Select commits to cherry-pick (8 missing):
193
+ βœ” #86c6105k9 - Add missing ATS configs in plists. (#1077)
194
+ βœ” chore(deps): bump actions/checkout from 3 to 6 (#1079)
195
+ βœ” #86c6q8y5r - Separete Corporate and Individual Registration (#1081)
196
+ βœ” #86c5wbbuc - Refactor splash related store initializations (#1082)
197
+ βœ” #86c6x2u20 - Remove deprecated reward center components (#1085)
198
+ βœ” #0 - Upgrade ruby version in workflows. (#1087)
199
+
200
+ β†· Semver ignored (pattern: /bump/i): (chore(deps): bump actions/checkout from 3 to 6 (#1079))
201
+ β†· Semver ignored (pattern: /bump/i): (#86c5wbbuc - Refactor splash related store initializations (#1082))
136
202
 
137
203
  Semantic Versioning
138
- Current: 1.20.0 Detected bump: minor Next: 1.21.0
204
+ Current: 4.36.0 Detected bump: minor Next: 4.37.0
205
+
206
+ Creating release/4.37.0 from origin/main...
207
+ βœ“ Ready on release/4.37.0. Cherry-picking will apply here.
208
+
209
+ Cherry-picking 6 commit(s) onto main (oldest β†’ newest)...
210
+
211
+ βœ“ cherry-picked (7ba30af) #86c6105k9 - Add missing ATS configs in plists. (#1077)
212
+ βœ“ cherry-picked (bbc70ed) chore(deps): bump actions/checkout from 3 to 6 (#1079)
213
+ βœ“ cherry-picked (287dbad) #86c6q8y5r - Separete Corporate and Individual Registration (#1081)
139
214
 
140
- Creating release/1.21.0 from origin/main...
141
- βœ“ Ready on release/1.21.0. Cherry-picking will apply here.
142
- βœ“ package.json updated and committed: chore(release): bump version to 1.21.0
143
- βœ… Pull request created for release/1.21.0 β†’ main
215
+ βœ– Cherry-pick has conflicts on 8eb07cb78148866d769684730d154e5cbeb2f331 (8eb07cb).
216
+ Conflicted files:
217
+ - android/app/build.gradle
218
+ - ios/bilira_wallet.xcodeproj/project.pbxproj
219
+
220
+ βœ” Choose how to proceed: Resolve conflicts now
221
+ βœ” Select a file to resolve or a global action: Use theirs for ALL
222
+
223
+ No conflicted files reported by git.
224
+ βœ“ cherry-picked (8eb07cb) #86c5wbbuc - Refactor splash related store initializations (#1082)
225
+ βœ“ cherry-picked (3ae27ee) #86c6x2u20 - Remove deprecated reward center components (#1085)
226
+ βœ“ cherry-picked (66d0419) #0 - Upgrade ruby version in workflows. (#1087)
227
+
228
+ Summary β†’ applied: 6, skipped: 0
229
+
230
+ Updating package.json version β†’ 4.37.0 ...
231
+ βœ“ package.json updated and committed: chore(release): bump version to 4.37.0
232
+ βœ… Pull request created for release/4.37.0 β†’ main
144
233
  ```
145
234
 
146
235
  ---
@@ -150,21 +239,68 @@ Creating release/1.21.0 from origin/main...
150
239
  If your team:
151
240
  - Rebases or cherry-picks from `dev` β†’ `main`
152
241
  - Uses temporary release branches
242
+ - Works with squashed commits
243
+ - Needs to handle merge conflicts gracefully
153
244
  - Tracks semantic versions via commits
154
245
 
155
246
  …this CLI saves time and reduces errors.
156
247
  It automates a tedious, error-prone manual process into a single command that behaves like `yarn upgrade-interactive`, but for Git commits.
157
248
 
249
+ **Special features:**
250
+ - βœ… Preserves exact commit messages (critical for squashed commits)
251
+ - βœ… Interactive conflict resolution without leaving the terminal
252
+ - βœ… Smart pattern-based filtering for commits and version detection
253
+ - βœ… Automatic changelog generation
254
+
158
255
  ---
159
256
 
160
257
  ## 🧰 Requirements
161
258
 
162
259
  - Node.js β‰₯ 18
163
- - GitHub CLI (`gh`) installed and authenticated
260
+ - Git β‰₯ 2.0
261
+ - **GitHub CLI (`gh`)** β€” *Optional, only required if using `--push-release`*
262
+ - Install from: https://cli.github.com/
263
+ - The tool will check if `gh` is installed and offer to continue without it
164
264
  - A clean working directory (no uncommitted changes)
165
265
 
166
266
  ---
167
267
 
268
+ ## 🎯 Best Practices
269
+
270
+ ### 1. Use `--ignore-commits` to filter noise
271
+
272
+ ```bash
273
+ cherrypick-interactive --ignore-commits "^ci:|^chore\(deps\):|Merge branch"
274
+ ```
275
+
276
+ Exclude CI updates, dependency bumps, and merge commits from selection.
277
+
278
+ ### 2. Use `--ignore-semver` for version accuracy
279
+
280
+ ```bash
281
+ cherrypick-interactive --ignore-semver "bump|dependencies|merge"
282
+ ```
283
+
284
+ Prevent certain commits from affecting semantic version calculation.
285
+
286
+ ### 3. Always use `--draft-pr` for review
287
+
288
+ ```bash
289
+ cherrypick-interactive --draft-pr
290
+ ```
291
+
292
+ Creates draft PRs so your team can review before merging.
293
+
294
+ ### 4. Test with `--dry-run` first
295
+
296
+ ```bash
297
+ cherrypick-interactive --dry-run
298
+ ```
299
+
300
+ See what would happen without making any changes.
301
+
302
+ ---
303
+
168
304
  ## 🧾 License
169
305
 
170
306
  **MIT** β€” free to use, modify, and distribute.
@@ -178,9 +314,44 @@ It automates a tedious, error-prone manual process into a single command that be
178
314
  ```bash
179
315
  node cli.js --dry-run
180
316
  ```
181
- 3. Test edge cases before submitting PRs
317
+ 3. Test edge cases before submitting PRs:
318
+ - Squashed commits with conflicts
319
+ - Empty cherry-picks
320
+ - Multiple conflict resolutions
182
321
  4. Please follow Conventional Commits for your changes.
183
322
 
184
323
  ---
185
324
 
186
- > Created to make release management simpler and safer for teams who value clean Git history and predictable deployments.
325
+ ## πŸ› Troubleshooting
326
+
327
+ ### "GitHub CLI (gh) is not installed"
328
+ The tool automatically checks for `gh` CLI when using `--push-release`. If not found, you'll be prompted to:
329
+ - Install it from https://cli.github.com/ and try again
330
+ - Or continue without creating a PR (the release branch will still be pushed)
331
+
332
+ You can also run without `--push-release` to skip PR creation entirely:
333
+ ```bash
334
+ cherrypick-interactive --create-release --no-push-release
335
+ ```
336
+
337
+ ### "Cherry-pick has conflicts"
338
+ Use the interactive wizard to resolve conflicts file-by-file or in bulk.
339
+
340
+ ### "Commit message changed after conflict resolution"
341
+ This issue has been fixed! The tool now preserves the original commit message using `git commit -C <hash>`.
342
+
343
+ ### "Version not detected correctly"
344
+ Use `--ignore-semver` to exclude commits that shouldn't affect versioning:
345
+ ```bash
346
+ cherrypick-interactive --ignore-semver "bump|chore\(deps\)"
347
+ ```
348
+
349
+ ### "Too many commits to review"
350
+ Use `--ignore-commits` to filter out noise, or adjust `--since` to a shorter time window:
351
+ ```bash
352
+ cherrypick-interactive --since "3 days ago" --ignore-commits "^ci:|^docs:"
353
+ ```
354
+
355
+ ---
356
+
357
+ > Created to make release management simpler and safer for teams who value clean Git history, predictable deployments, and efficient conflict resolution.
package/cli.js CHANGED
@@ -99,11 +99,16 @@ const argv = yargs(hideBin(process.argv))
99
99
  default: 'chore(release): bump version to {{version}}',
100
100
  describe: 'Commit message template for version bump. Use {{version}} placeholder.',
101
101
  })
102
- .option('semver-ignore', {
102
+ .option('ignore-semver', {
103
103
  type: 'string',
104
104
  describe:
105
105
  'Comma-separated regex patterns. If a commit message matches any, it will be treated as a chore for semantic versioning.',
106
106
  })
107
+ .option('ignore-commits', {
108
+ type: 'string',
109
+ describe:
110
+ 'Comma-separated regex patterns. If a commit message matches any, it will be omitted from the commit list.',
111
+ })
107
112
  .wrap(200)
108
113
  .help()
109
114
  .alias('h', 'help')
@@ -126,7 +131,7 @@ async function getSubjects(branch) {
126
131
  }
127
132
 
128
133
  async function getDevCommits(branch, since) {
129
- const out = await gitRaw(['log', '--no-merges', '--since=' + since, '--pretty=%H %s', branch]);
134
+ const out = await gitRaw(['log', '--no-merges', `--since=${since}`, '--pretty=%H %s', branch]);
130
135
 
131
136
  if (!out) {
132
137
  return [];
@@ -184,7 +189,7 @@ async function handleCherryPickConflict(hash) {
184
189
 
185
190
  const { action } = await inquirer.prompt([
186
191
  {
187
- type: 'list',
192
+ type: 'select',
188
193
  name: 'action',
189
194
  message: 'Choose how to proceed:',
190
195
  choices: [
@@ -262,7 +267,7 @@ async function showConflictsList() {
262
267
  }
263
268
  err(chalk.yellow('Conflicted files:'));
264
269
  for (const f of files) {
265
- err(' - ' + f);
270
+ err(` - ${f}`);
266
271
  }
267
272
  return files;
268
273
  }
@@ -270,7 +275,7 @@ async function showConflictsList() {
270
275
  async function resolveSingleFileWizard(file) {
271
276
  const { action } = await inquirer.prompt([
272
277
  {
273
- type: 'list',
278
+ type: 'select',
274
279
  name: 'action',
275
280
  message: `How to resolve "${file}"?`,
276
281
  choices: [
@@ -335,7 +340,7 @@ async function conflictsResolutionWizard(hash) {
335
340
  err(chalk.yellow('The previous cherry-pick is now empty.'));
336
341
  const { emptyAction } = await inquirer.prompt([
337
342
  {
338
- type: 'list',
343
+ type: 'select',
339
344
  name: 'emptyAction',
340
345
  message: 'No staged changes for this pick. Choose next step:',
341
346
  choices: [
@@ -352,8 +357,9 @@ async function conflictsResolutionWizard(hash) {
352
357
  return 'skipped';
353
358
  }
354
359
  if (emptyAction === 'empty-commit') {
355
- const subject = await gitRaw(['show', '--format=%s', '-s', hash]);
356
- await gitRaw(['commit', '--allow-empty', '-m', subject]);
360
+ const fullMessage = await gitRaw(['show', '--format=%B', '-s', hash]);
361
+ await gitRaw(['commit', '--allow-empty', '-m', fullMessage]);
362
+ const subject = await gitRaw(['show', '--format=%s', '-s', 'HEAD']);
357
363
  log(`${chalk.green('βœ“')} (empty) cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
358
364
  return 'continued';
359
365
  }
@@ -364,8 +370,9 @@ async function conflictsResolutionWizard(hash) {
364
370
  // (re-loop)
365
371
  } else {
366
372
  try {
367
- await gitRaw(['cherry-pick', '--continue']);
368
- const subject = await gitRaw(['show', '--format=%s', '-s', hash]);
373
+ // Use -C to copy the original commit message
374
+ await gitRaw(['commit', '-C', hash]);
375
+ const subject = await gitRaw(['show', '--format=%s', '-s', 'HEAD']);
369
376
  log(`${chalk.green('βœ“')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
370
377
  return 'continued';
371
378
  } catch (e) {
@@ -378,7 +385,7 @@ async function conflictsResolutionWizard(hash) {
378
385
 
379
386
  const { choice } = await inquirer.prompt([
380
387
  {
381
- type: 'list',
388
+ type: 'select',
382
389
  name: 'choice',
383
390
  message: 'Select a file to resolve or a global action:',
384
391
  pageSize: Math.min(20, Math.max(8, files.length + 5)),
@@ -432,7 +439,7 @@ async function conflictsResolutionWizard(hash) {
432
439
  err(chalk.yellow('No staged changes found for this cherry-pick.'));
433
440
  const { emptyAction } = await inquirer.prompt([
434
441
  {
435
- type: 'list',
442
+ type: 'select',
436
443
  name: 'emptyAction',
437
444
  message: 'This pick seems empty. Choose next step:',
438
445
  choices: [
@@ -448,9 +455,11 @@ async function conflictsResolutionWizard(hash) {
448
455
  log(chalk.yellow(`β†· Skipped empty pick ${chalk.dim(`(${hash.slice(0, 7)})`)}`));
449
456
  return 'skipped';
450
457
  }
458
+
451
459
  if (emptyAction === 'empty-commit') {
452
- const subject = await gitRaw(['show', '--format=%s', '-s', hash]);
453
- await gitRaw(['commit', '--allow-empty', '-m', subject]);
460
+ const fullMessage = await gitRaw(['show', '--format=%B', '-s', hash]);
461
+ await gitRaw(['commit', '--allow-empty', '-m', fullMessage]);
462
+ const subject = await gitRaw(['show', '--format=%s', '-s', 'HEAD']);
454
463
  log(`${chalk.green('βœ“')} (empty) cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
455
464
  return 'continued';
456
465
  }
@@ -461,8 +470,9 @@ async function conflictsResolutionWizard(hash) {
461
470
  }
462
471
 
463
472
  try {
464
- await gitRaw(['cherry-pick', '--continue']);
465
- const subject = await gitRaw(['show', '--format=%s', '-s', hash]);
473
+ // Use -C to copy the original commit message
474
+ await gitRaw(['commit', '-C', hash]);
475
+ const subject = await gitRaw(['show', '--format=%s', '-s', 'HEAD']);
466
476
  log(`${chalk.green('βœ“')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
467
477
  return 'continued';
468
478
  } catch (e) {
@@ -585,21 +595,16 @@ async function computeSemanticBumpForCommits(hashes, gitRawFn, semverignore) {
585
595
  }
586
596
 
587
597
  const levels = [];
598
+ const semverIgnorePatterns = parseSemverIgnore(semverignore);
588
599
 
589
600
  for (const h of hashes) {
590
601
  const msg = await gitRawFn(['show', '--format=%B', '-s', h]);
591
602
  const subject = msg.split(/\r?\n/)[0].trim();
592
603
  let level = classifySingleCommit(msg);
593
604
 
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
- }
605
+ // πŸ”Ή Apply --semverignore (treat matched commits as chores). Use full message (subject + body).
606
+ if (semverIgnorePatterns.length > 0 && matchesAnyPattern(msg, semverIgnorePatterns)) {
607
+ level = null;
603
608
  }
604
609
 
605
610
  if (level) {
@@ -613,6 +618,34 @@ async function computeSemanticBumpForCommits(hashes, gitRawFn, semverignore) {
613
618
 
614
619
  async function main() {
615
620
  try {
621
+ // Check if gh CLI is installed when push-release is enabled
622
+ if (argv['push-release']) {
623
+ const ghInstalled = await checkGhCli();
624
+ if (!ghInstalled) {
625
+ err(chalk.yellow('\n⚠️ GitHub CLI (gh) is not installed or not in PATH.'));
626
+ err(chalk.gray(' The --push-release flag requires gh CLI to create pull requests.\n'));
627
+ err(chalk.cyan(' Install it from: https://cli.github.com/'));
628
+ err(chalk.cyan(' Or run without --push-release to skip PR creation.\n'));
629
+
630
+ const { proceed } = await inquirer.prompt([
631
+ {
632
+ type: 'confirm',
633
+ name: 'proceed',
634
+ message: 'Continue without creating a PR?',
635
+ default: false,
636
+ },
637
+ ]);
638
+
639
+ if (!proceed) {
640
+ log(chalk.yellow('Aborted by user.'));
641
+ return;
642
+ }
643
+
644
+ // Disable push-release if user chooses to continue
645
+ argv['push-release'] = false;
646
+ }
647
+ }
648
+
616
649
  if (!argv['no-fetch']) {
617
650
  log(chalk.gray('Fetching remotes (git fetch --prune)...'));
618
651
  await git.fetch(['--prune']);
@@ -628,18 +661,26 @@ async function main() {
628
661
 
629
662
  const missing = filterMissing(devCommits, mainSubjects);
630
663
 
631
- if (missing.length === 0) {
664
+ // Filter out commits matching --ignore-commits patterns
665
+ const ignoreCommitsPatterns = parseIgnoreCommits(argv['ignore-commits']);
666
+ const filteredMissing =
667
+ ignoreCommitsPatterns.length > 0
668
+ ? missing.filter(({ subject }) => !shouldIgnoreCommit(subject, ignoreCommitsPatterns))
669
+ : missing;
670
+
671
+ if (filteredMissing.length === 0) {
632
672
  log(chalk.green('βœ… No missing commits found in the selected window.'));
633
673
  return;
634
674
  }
635
675
 
636
- const indexByHash = new Map(missing.map((c, i) => [c.hash, i])); // 0=newest, larger=older
676
+ const semverIgnore = argv['ignore-semver'];
677
+ const indexByHash = new Map(filteredMissing.map((c, i) => [c.hash, i])); // 0=newest, larger=older
637
678
 
638
679
  let selected;
639
680
  if (argv['all-yes']) {
640
- selected = missing.map((m) => m.hash);
681
+ selected = filteredMissing.map((m) => m.hash);
641
682
  } else {
642
- selected = await selectCommitsInteractive(missing);
683
+ selected = await selectCommitsInteractive(filteredMissing);
643
684
  if (!selected.length) {
644
685
  log(chalk.yellow('No commits selected. Exiting.'));
645
686
  return;
@@ -669,7 +710,7 @@ async function main() {
669
710
  }
670
711
 
671
712
  // Bump is based on the commits you are about to apply (selected).
672
- const bump = await computeSemanticBumpForCommits(bottomToTop, gitRaw, argv.semverignore);
713
+ const bump = await computeSemanticBumpForCommits(bottomToTop, gitRaw, semverIgnore);
673
714
 
674
715
  computedNextVersion = bump ? incrementVersion(argv['current-version'], bump) : argv['current-version'];
675
716
 
@@ -691,14 +732,14 @@ async function main() {
691
732
  }
692
733
 
693
734
  const releaseBranch = `release/${computedNextVersion}`;
694
- await ensureBranchDoesNotExistLocally(releaseBranch);
695
735
  const startPoint = argv.main; // e.g., 'origin/main' or a local ref
736
+ await ensureReleaseBranchFresh(releaseBranch, startPoint);
696
737
 
697
738
  const changelogBody = await buildChangelogBody({
698
739
  version: computedNextVersion,
699
740
  hashes: bottomToTop,
700
741
  gitRawFn: gitRaw,
701
- semverIgnore: argv.semverignore, // raw flag value
742
+ semverIgnore, // raw flag value
702
743
  });
703
744
 
704
745
  await fsPromises.writeFile('RELEASE_CHANGELOG.md', changelogBody, 'utf8');
@@ -787,12 +828,53 @@ main();
787
828
  * Utils
788
829
  */
789
830
 
790
- async function ensureBranchDoesNotExistLocally(branchName) {
831
+ async function ensureReleaseBranchFresh(branchName, startPoint) {
791
832
  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
- );
833
+ const localExists = branches.all.includes(branchName);
834
+ const remoteRef = await gitRaw(['ls-remote', '--heads', 'origin', branchName]);
835
+ const remoteExists = Boolean(remoteRef);
836
+
837
+ if (!localExists && !remoteExists) {
838
+ return;
839
+ }
840
+
841
+ const { action } = await inquirer.prompt([
842
+ {
843
+ type: 'select',
844
+ name: 'action',
845
+ message: `Release branch "${branchName}" already exists${localExists ? ' locally' : ''}${
846
+ remoteExists ? ' on origin' : ''
847
+ }. How do you want to proceed? (override, abort)`,
848
+ choices: [
849
+ { name: 'Override (delete existing branch and recreate)', value: 'override' },
850
+ { name: 'Abort', value: 'abort' },
851
+ ],
852
+ },
853
+ ]);
854
+
855
+ if (action === 'abort') {
856
+ throw new Error(`Aborted: release branch "${branchName}" already exists.`);
857
+ }
858
+
859
+ // Ensure we are not on the branch before deleting local copy
860
+ if (localExists) {
861
+ if (branches.current === branchName) {
862
+ const target = startPoint || 'HEAD';
863
+ log(chalk.gray(`Switching to ${target} before deleting ${branchName}...`));
864
+ await gitRaw(['checkout', target]);
865
+ }
866
+ await gitRaw(['branch', '-D', branchName]);
867
+ log(chalk.yellow(`β†· Deleted existing local branch ${branchName}`));
868
+ }
869
+
870
+ if (remoteExists) {
871
+ try {
872
+ await gitRaw(['push', 'origin', '--delete', branchName]);
873
+ log(chalk.yellow(`β†· Deleted existing remote branch origin/${branchName}`));
874
+ } catch (e) {
875
+ err(chalk.red(`Failed to delete remote branch origin/${branchName}: ${e.message || e}`));
876
+ throw e;
877
+ }
796
878
  }
797
879
  }
798
880
 
@@ -815,8 +897,8 @@ async function buildChangelogBody({ version, hashes, gitRawFn, semverIgnore }) {
815
897
  // normal classification first
816
898
  let level = classifySingleCommit(msg);
817
899
 
818
- // ⬇ Apply semver-ignore logic
819
- const matched = matchesAnyPattern(subject, semverIgnorePatterns);
900
+ // ⬇ Apply ignore-semver logic
901
+ const matched = matchesAnyPattern(msg, semverIgnorePatterns); // evaluate against full message
820
902
  if (matched) {
821
903
  level = null; // drop it into "Other"
822
904
  }
@@ -861,6 +943,14 @@ function stripOrigin(ref) {
861
943
  return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref;
862
944
  }
863
945
 
946
+ async function checkGhCli() {
947
+ return new Promise((resolve) => {
948
+ const p = spawn('gh', ['--version'], { stdio: 'pipe' });
949
+ p.on('error', () => resolve(false));
950
+ p.on('close', (code) => resolve(code === 0));
951
+ });
952
+ }
953
+
864
954
  async function runGh(args) {
865
955
  return new Promise((resolve, reject) => {
866
956
  const p = spawn('gh', args, { stdio: 'inherit' });
@@ -874,14 +964,14 @@ async function readJson(filePath) {
874
964
  }
875
965
 
876
966
  async function writeJson(filePath, data) {
877
- const text = JSON.stringify(data, null, 2) + '\n';
967
+ const text = `${JSON.stringify(data, null, 2)}\n`;
878
968
  await fsPromises.writeFile(filePath, text, 'utf8');
879
969
  }
880
970
 
881
971
  /** Read package.json version; throw if missing */
882
972
  async function getPkgVersion(pkgPath) {
883
973
  const pkg = await readJson(pkgPath);
884
- const v = pkg && pkg.version;
974
+ const v = pkg?.version;
885
975
  if (!v) {
886
976
  throw new Error(`No "version" field found in ${pkgPath}`);
887
977
  }
@@ -900,16 +990,55 @@ function parseSemverIgnore(argvValue) {
900
990
  return argvValue
901
991
  .split(',')
902
992
  .map((p) => p.trim())
993
+ .filter(Boolean)
994
+ .map((pattern) => {
995
+ try {
996
+ return new RegExp(pattern, 'i'); // case-insensitive on full message
997
+ } catch (e) {
998
+ err(chalk.red(`Invalid --ignore-semver pattern "${pattern}": ${e.message || e}`));
999
+ return null;
1000
+ }
1001
+ })
903
1002
  .filter(Boolean);
904
1003
  }
905
1004
 
906
1005
  function matchesAnyPattern(text, patterns) {
907
1006
  if (!patterns || patterns.length === 0) return false;
908
1007
 
909
- const result = patterns.some((rx) => text.includes(rx));
1008
+ const matched = patterns.find((rx) => rx.test(text));
1009
+ if (matched) {
1010
+ log(chalk.cyan(`β†· Semver ignored (pattern: /${matched.source}/i): ${chalk.dim(`(${text.split('\n')[0]})`)}`));
1011
+ return true;
1012
+ }
1013
+
1014
+ return false;
1015
+ }
1016
+
1017
+ function parseIgnoreCommits(argvValue) {
1018
+ if (!argvValue) return [];
1019
+ return argvValue
1020
+ .split(',')
1021
+ .map((p) => p.trim())
1022
+ .filter(Boolean)
1023
+ .map((pattern) => {
1024
+ try {
1025
+ return new RegExp(pattern, 'i'); // case-insensitive matching
1026
+ } catch (e) {
1027
+ err(chalk.red(`Invalid --ignore-commits pattern "${pattern}": ${e.message || e}`));
1028
+ return null;
1029
+ }
1030
+ })
1031
+ .filter(Boolean);
1032
+ }
910
1033
 
911
- if (result) {
912
- log(chalk.cyan(`β†· Semver ignored: ${chalk.dim(`(${text})`)}`));
1034
+ function shouldIgnoreCommit(subject, patterns) {
1035
+ if (!patterns || patterns.length === 0) return false;
1036
+
1037
+ const matched = patterns.find((rx) => rx.test(subject));
1038
+ if (matched) {
1039
+ log(chalk.gray(`β†· Ignoring commit (pattern: /${matched.source}/i): ${chalk.dim(subject.slice(0, 80))}`));
1040
+ return true;
913
1041
  }
914
- return result;
1042
+
1043
+ return false;
915
1044
  }
package/package.json CHANGED
@@ -1,11 +1,9 @@
1
1
  {
2
2
  "name": "cherrypick-interactive",
3
- "version": "1.4.3",
3
+ "version": "1.6.0",
4
4
  "description": "Interactively cherry-pick commits that are in dev but not in main, using subject-based comparison.",
5
5
  "main": "cli.js",
6
- "bin": {
7
- "cherrypick-interactive": "cli.js"
8
- },
6
+ "bin": "cli.js",
9
7
  "type": "module",
10
8
  "scripts": {
11
9
  "lint": "biome lint .",
@@ -19,9 +17,9 @@
19
17
  },
20
18
  "dependencies": {
21
19
  "chalk": "^5.6.2",
22
- "inquirer": "^12.9.6",
20
+ "inquirer": "^13.0.2",
23
21
  "semver": "^7.7.3",
24
- "simple-git": "^3.28.0",
22
+ "simple-git": "^3.30.0",
25
23
  "update-notifier": "^7.3.1",
26
24
  "yargs": "^18.0.0"
27
25
  },
package/cli.backup.js DELETED
@@ -1,193 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import chalk from "chalk";
4
- import inquirer from "inquirer";
5
- import simpleGit from "simple-git";
6
- import yargs from "yargs";
7
-
8
- import { hideBin } from "yargs/helpers";
9
-
10
- const git = simpleGit();
11
-
12
- const argv = yargs(hideBin(process.argv))
13
- .scriptName("cherrypick-interactive")
14
- .usage("$0 [options]")
15
- .option("dev", {
16
- type: "string",
17
- default: "origin/dev",
18
- describe: "Source branch (contains commits you want).",
19
- })
20
- .option("main", {
21
- type: "string",
22
- default: "origin/main",
23
- describe: "Comparison branch (commits present here will be filtered out).",
24
- })
25
- .option("since", {
26
- type: "string",
27
- default: "1 week ago",
28
- describe: 'Time window passed to git --since (e.g. "2 weeks ago", "1 month ago").',
29
- })
30
- .option("no-fetch", {
31
- type: "boolean",
32
- default: false,
33
- describe: "Skip 'git fetch --prune'.",
34
- })
35
- .option("all-yes", {
36
- type: "boolean",
37
- default: false,
38
- describe: "Non-interactive: cherry-pick ALL missing commits (oldest β†’ newest).",
39
- })
40
- .option("dry-run", {
41
- type: "boolean",
42
- default: false,
43
- describe: "Print what would be cherry-picked and exit.",
44
- })
45
- .help()
46
- .alias("h", "help")
47
- .alias("v", "version").argv;
48
-
49
- const log = (...a) => console.log(...a);
50
- const err = (...a) => console.error(...a);
51
-
52
- async function gitRaw(args) {
53
- const out = await git.raw(args);
54
- return out.trim();
55
- }
56
-
57
- async function getSubjects(branch) {
58
- const out = await gitRaw(["log", "--no-merges", "--pretty=%s", branch]);
59
- if (!out) {
60
- return new Set();
61
- }
62
- return new Set(out.split("\n").filter(Boolean));
63
- }
64
-
65
- async function getDevCommits(branch, since) {
66
- const out = await gitRaw(["log", "--no-merges", "--since=" + since, "--pretty=%H %s", branch]);
67
-
68
- if (!out) {
69
- return [];
70
- }
71
- return out.split("\n").map((line) => {
72
- const firstSpace = line.indexOf(" ");
73
- const hash = line.slice(0, firstSpace);
74
- const subject = line.slice(firstSpace + 1);
75
- return { hash, subject };
76
- });
77
- }
78
-
79
- function filterMissing(devCommits, mainSubjects) {
80
- return devCommits.filter(({ subject }) => !mainSubjects.has(subject));
81
- }
82
-
83
- async function selectCommitsInteractive(missing) {
84
- const choices = [
85
- new inquirer.Separator(chalk.gray("── Newest commits ──")),
86
- ...missing.map(({ hash, subject }, idx) => {
87
- // display-only trim to avoid accidental leading spaces
88
- const displaySubject = subject.replace(/^[\s\u00A0]+/, "");
89
- return {
90
- name: `${chalk.dim(`(${hash.slice(0, 7)})`)} ${displaySubject}`,
91
- value: hash,
92
- short: displaySubject,
93
- idx, // we keep index for oldest→newest ordering later
94
- };
95
- }),
96
- new inquirer.Separator(chalk.gray("── Oldest commits ──")),
97
- ];
98
-
99
- const { selected } = await inquirer.prompt([
100
- {
101
- type: "checkbox",
102
- name: "selected",
103
- message: `Select commits to cherry-pick (${missing.length} missing):`,
104
- choices,
105
- pageSize: Math.min(20, Math.max(8, missing.length)),
106
- },
107
- ]);
108
-
109
- return selected;
110
- }
111
-
112
- async function cherryPickSequential(hashes) {
113
- for (const hash of hashes) {
114
- try {
115
- await gitRaw(["cherry-pick", hash]);
116
- const subject = await gitRaw(["show", "--format=%s", "-s", hash]);
117
- log(`${chalk.green("βœ“")} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
118
- } catch (e) {
119
- err(chalk.red(`βœ– Cherry-pick failed on ${hash}`));
120
- err(chalk.yellow("Resolve conflicts, then run:"));
121
- err(chalk.yellow(" git add -A && git cherry-pick --continue"));
122
- err(chalk.yellow("Or abort:"));
123
- err(chalk.yellow(" git cherry-pick --abort"));
124
- throw e;
125
- }
126
- }
127
- }
128
-
129
- async function main() {
130
- try {
131
- if (!argv["no-fetch"]) {
132
- log(chalk.gray("Fetching remotes (git fetch --prune)..."));
133
- await git.fetch(["--prune"]);
134
- }
135
-
136
- const currentBranch = (await gitRaw(["rev-parse", "--abbrev-ref", "HEAD"])) || "HEAD";
137
-
138
- log(chalk.gray(`Comparing subjects since ${argv.since}`));
139
- log(chalk.gray(`Dev: ${argv.dev}`));
140
- log(chalk.gray(`Main: ${argv.main}`));
141
-
142
- const [devCommits, mainSubjects] = await Promise.all([
143
- getDevCommits(argv.dev, argv.since),
144
- getSubjects(argv.main),
145
- ]);
146
-
147
- const missing = filterMissing(devCommits, mainSubjects);
148
-
149
- if (missing.length === 0) {
150
- log(chalk.green("βœ… No missing commits found in the selected window."));
151
- return;
152
- }
153
-
154
- // Prepare bottom→top ordering support
155
- const indexByHash = new Map(missing.map((c, i) => [c.hash, i])); // 0=newest, larger=older
156
-
157
- let selected;
158
- if (argv.yes) {
159
- selected = missing.map((m) => m.hash);
160
- } else {
161
- selected = await selectCommitsInteractive(missing);
162
- if (!selected.length) {
163
- log(chalk.yellow("No commits selected. Exiting."));
164
- return;
165
- }
166
- }
167
-
168
- // Bottom β†’ Top (oldest β†’ newest)
169
- const bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a));
170
-
171
- if (argv.dry_run || argv["dry-run"]) {
172
- log(chalk.cyan("\n--dry-run: would cherry-pick (oldest β†’ newest):"));
173
- for (const h of bottomToTop) {
174
- const subj = await gitRaw(["show", "--format=%s", "-s", h]);
175
- log(`- ${chalk.dim(`(${h.slice(0, 7)})`)} ${subj}`);
176
- }
177
- return;
178
- }
179
-
180
- log(
181
- chalk.cyan(
182
- `\nCherry-picking ${bottomToTop.length} commit(s) onto ${currentBranch} (oldest β†’ newest)...\n`,
183
- ),
184
- );
185
- await cherryPickSequential(bottomToTop);
186
- log(chalk.green(`\nβœ… Done on ${currentBranch}`));
187
- } catch (e) {
188
- err(chalk.red(`\n❌ Error: ${e.message || e}`));
189
- process.exit(1);
190
- }
191
- }
192
-
193
- main();