cherrypick-interactive 1.7.1 → 1.8.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.
package/cli.js CHANGED
@@ -7,6 +7,7 @@ import chalk from 'chalk';
7
7
  import inquirer from 'inquirer';
8
8
  import semver from 'semver';
9
9
  import simpleGit from 'simple-git';
10
+ import isSafeRegex from 'safe-regex2';
10
11
  import updateNotifier from 'update-notifier';
11
12
  import yargs from 'yargs';
12
13
  import { hideBin } from 'yargs/helpers';
@@ -35,88 +36,214 @@ if (upd && semver.valid(upd.latest) && semver.valid(pkg.version) && semver.gt(up
35
36
  const argv = yargs(hideBin(process.argv))
36
37
  .scriptName('cherrypick-interactive')
37
38
  .usage('$0 [options]')
39
+ // ── Cherry-pick options ──
38
40
  .option('dev', {
39
41
  type: 'string',
40
42
  default: 'origin/dev',
41
43
  describe: 'Source branch (contains commits you want).',
44
+ group: 'Cherry-pick options:',
42
45
  })
43
46
  .option('main', {
44
47
  type: 'string',
45
48
  default: 'origin/main',
46
49
  describe: 'Comparison branch (commits present here will be filtered out).',
50
+ group: 'Cherry-pick options:',
47
51
  })
48
52
  .option('since', {
49
53
  type: 'string',
50
54
  default: '1 week ago',
51
55
  describe: 'Time window passed to git --since (e.g. "2 weeks ago", "1 month ago").',
56
+ group: 'Cherry-pick options:',
52
57
  })
53
58
  .option('no-fetch', {
54
59
  type: 'boolean',
55
60
  default: false,
56
61
  describe: "Skip 'git fetch --prune'.",
62
+ group: 'Cherry-pick options:',
57
63
  })
58
64
  .option('all-yes', {
59
65
  type: 'boolean',
60
66
  default: false,
61
67
  describe: 'Non-interactive: cherry-pick ALL missing commits (oldest → newest).',
68
+ group: 'Cherry-pick options:',
62
69
  })
63
- .option('dry-run', {
64
- type: 'boolean',
65
- default: false,
66
- describe: 'Print what would be cherry-picked and exit.',
70
+ .option('ignore-commits', {
71
+ type: 'string',
72
+ describe:
73
+ 'Comma-separated regex patterns. If a commit message matches any, it will be omitted from the commit list.',
74
+ group: 'Cherry-pick options:',
67
75
  })
76
+
77
+ // ── Version options ──
68
78
  .option('semantic-versioning', {
69
79
  type: 'boolean',
70
80
  default: true,
71
81
  describe: 'Compute next semantic version from selected (or missing) commits.',
82
+ group: 'Version options:',
72
83
  })
73
84
  .option('current-version', {
74
85
  type: 'string',
75
86
  describe: 'Current version (X.Y.Z). Required when --semantic-versioning is set.',
87
+ group: 'Version options:',
88
+ })
89
+ .option('version-file', {
90
+ type: 'string',
91
+ default: './package.json',
92
+ describe: 'Path to package.json (read current version; optional replacement for --current-version)',
93
+ group: 'Version options:',
94
+ })
95
+ .option('version-commit-message', {
96
+ type: 'string',
97
+ default: 'chore(release): bump version to {{version}}',
98
+ describe: 'Commit message template for version bump. Use {{version}} placeholder.',
99
+ group: 'Version options:',
76
100
  })
101
+ .option('ignore-semver', {
102
+ type: 'string',
103
+ describe:
104
+ 'Comma-separated regex patterns. If a commit message matches any, it will be treated as a chore for semantic versioning.',
105
+ group: 'Version options:',
106
+ })
107
+
108
+ // ── Release options ──
77
109
  .option('create-release', {
78
110
  type: 'boolean',
79
111
  default: true,
80
112
  describe: 'Create a release branch from --main named release/<computed-version> before cherry-picking.',
113
+ group: 'Release options:',
81
114
  })
82
115
  .option('push-release', {
83
116
  type: 'boolean',
84
117
  default: true,
85
118
  describe: 'After creating the release branch, push and set upstream (origin).',
119
+ group: 'Release options:',
86
120
  })
87
121
  .option('draft-pr', {
88
122
  type: 'boolean',
89
123
  default: false,
90
124
  describe: 'Create the release PR as a draft.',
125
+ group: 'Release options:',
91
126
  })
92
- .option('version-file', {
127
+
128
+ // ── CI options ──
129
+ .option('ci', {
130
+ type: 'boolean',
131
+ default: false,
132
+ describe: 'Enable CI mode (fully non-interactive).',
133
+ group: 'CI options:',
134
+ })
135
+ .option('conflict-strategy', {
93
136
  type: 'string',
94
- default: './package.json',
95
- describe: 'Path to package.json (read current version; optional replacement for --current-version)',
137
+ default: 'fail',
138
+ describe: 'How to handle conflicts: fail, ours, theirs, skip.',
139
+ choices: ['fail', 'ours', 'theirs', 'skip'],
140
+ group: 'CI options:',
96
141
  })
97
- .option('version-commit-message', {
142
+ .option('format', {
98
143
  type: 'string',
99
- default: 'chore(release): bump version to {{version}}',
100
- describe: 'Commit message template for version bump. Use {{version}} placeholder.',
144
+ default: 'text',
145
+ describe: 'Output format: text or json. JSON goes to stdout, logs to stderr.',
146
+ choices: ['text', 'json'],
147
+ group: 'CI options:',
101
148
  })
102
- .option('ignore-semver', {
149
+ .option('dependency-strategy', {
103
150
  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.',
151
+ default: 'warn',
152
+ describe: 'How to handle detected dependencies: warn, fail, ignore.',
153
+ choices: ['warn', 'fail', 'ignore'],
154
+ group: 'CI options:',
106
155
  })
107
- .option('ignore-commits', {
156
+
157
+ // ── Tracker options ──
158
+ .option('tracker', {
108
159
  type: 'string',
109
- describe:
110
- 'Comma-separated regex patterns. If a commit message matches any, it will be omitted from the commit list.',
160
+ describe: 'Built-in preset: clickup, jira, linear. Sets ticket-pattern automatically.',
161
+ choices: ['clickup', 'jira', 'linear'],
162
+ group: 'Tracker options:',
163
+ })
164
+ .option('ticket-pattern', {
165
+ type: 'string',
166
+ describe: 'Custom regex to capture ticket ID from commit message (must have one capture group).',
167
+ group: 'Tracker options:',
168
+ })
169
+ .option('tracker-url', {
170
+ type: 'string',
171
+ describe: 'URL template with {{id}} placeholder (required when using tracker).',
172
+ group: 'Tracker options:',
173
+ })
174
+
175
+ // ── Profile options ──
176
+ .option('profile', {
177
+ type: 'string',
178
+ describe: 'Load a named profile from .cherrypickrc.json.',
179
+ group: 'Profile options:',
180
+ })
181
+ .option('save-profile', {
182
+ type: 'string',
183
+ describe: 'Save current CLI flags as a named profile.',
184
+ group: 'Profile options:',
185
+ })
186
+ .option('list-profiles', {
187
+ type: 'boolean',
188
+ default: false,
189
+ describe: 'List available profiles and exit.',
190
+ group: 'Profile options:',
191
+ })
192
+
193
+ // ── Session options ──
194
+ .option('undo', {
195
+ type: 'boolean',
196
+ default: false,
197
+ describe: 'Reset current release branch to pre-cherry-pick state.',
198
+ group: 'Session options:',
111
199
  })
112
- .wrap(200)
200
+
201
+ // ── UI options ──
202
+ .option('no-tui', {
203
+ type: 'boolean',
204
+ default: false,
205
+ describe: 'Disable TUI dashboard, use simple inquirer checkbox instead.',
206
+ group: 'UI options:',
207
+ })
208
+ .option('dry-run', {
209
+ type: 'boolean',
210
+ default: false,
211
+ describe: 'Print what would be cherry-picked and exit.',
212
+ group: 'UI options:',
213
+ })
214
+
215
+ .wrap(Math.min(120, process.stdout.columns || 120))
113
216
  .help()
114
217
  .alias('h', 'help')
115
218
  .alias('v', 'version').argv;
116
219
 
117
- const log = (...a) => console.log(...a);
220
+ // When --format json, all log output goes to stderr so stdout is clean JSON
221
+ const isJsonFormat = process.argv.some((a, i) =>
222
+ (a === '--format' && process.argv[i + 1] === 'json') || a === '--format=json',
223
+ );
224
+ if (isJsonFormat) {
225
+ // Disable chalk colors for clean stderr in JSON mode
226
+ process.env.NO_COLOR = '1';
227
+ }
228
+ const log = (...a) => (isJsonFormat ? console.error(...a) : console.log(...a));
118
229
  const err = (...a) => console.error(...a);
119
230
 
231
+ // CI result collector (populated during execution, output at end)
232
+ const ciResult = {
233
+ version: { previous: null, next: null, bump: null },
234
+ branch: null,
235
+ commits: { applied: [], skipped: [], total: 0 },
236
+ changelog: null,
237
+ pr: { url: null },
238
+ };
239
+
240
+ class ExitError extends Error {
241
+ constructor(message, exitCode = 1) {
242
+ super(message);
243
+ this.exitCode = exitCode;
244
+ }
245
+ }
246
+
120
247
  async function gitRaw(args) {
121
248
  const out = await git.raw(args);
122
249
  return out.trim();
@@ -183,8 +310,43 @@ async function handleCherryPickConflict(hash) {
183
310
  return 'skipped';
184
311
  }
185
312
 
313
+ const strategy = argv['conflict-strategy'] || 'fail';
314
+
315
+ // CI mode: auto-resolve based on strategy
316
+ if (argv.ci) {
317
+ err(chalk.red(`\n✖ Cherry-pick has conflicts on ${hash} (${shortSha(hash)}).`));
318
+
319
+ if (strategy === 'fail') {
320
+ await gitRaw(['cherry-pick', '--abort']);
321
+ throw new ExitError('Conflict detected with --conflict-strategy fail. Aborting.', 1);
322
+ }
323
+
324
+ if (strategy === 'skip') {
325
+ await gitRaw(['cherry-pick', '--skip']);
326
+ log(chalk.yellow(`↷ Skipped commit ${chalk.dim(`(${shortSha(hash)})`)}`));
327
+ return 'skipped';
328
+ }
329
+
330
+ if (strategy === 'ours') {
331
+ await gitRaw(['checkout', '--ours', '.']);
332
+ await gitRaw(['add', '.']);
333
+ await gitRaw(['cherry-pick', '--continue']);
334
+ log(chalk.yellow(`⚠ Resolved with --ours: ${chalk.dim(`(${shortSha(hash)})`)}`));
335
+ return 'continued';
336
+ }
337
+
338
+ if (strategy === 'theirs') {
339
+ await gitRaw(['checkout', '--theirs', '.']);
340
+ await gitRaw(['add', '.']);
341
+ await gitRaw(['cherry-pick', '--continue']);
342
+ log(chalk.yellow(`⚠ Resolved with --theirs: ${chalk.dim(`(${shortSha(hash)})`)}`));
343
+ return 'continued';
344
+ }
345
+ }
346
+
347
+ // Interactive mode
186
348
  while (true) {
187
- err(chalk.red(`\n✖ Cherry-pick has conflicts on ${hash} (${hash.slice(0, 7)}).`));
349
+ err(chalk.red(`\n✖ Cherry-pick has conflicts on ${hash} (${shortSha(hash)}).`));
188
350
  await showConflictsList(); // prints conflicted files (if any)
189
351
 
190
352
  const { action } = await inquirer.prompt([
@@ -202,7 +364,7 @@ async function handleCherryPickConflict(hash) {
202
364
 
203
365
  if (action === 'skip') {
204
366
  await gitRaw(['cherry-pick', '--skip']);
205
- log(chalk.yellow(`↷ Skipped commit ${chalk.dim(`(${hash.slice(0, 7)})`)}`));
367
+ log(chalk.yellow(`↷ Skipped commit ${chalk.dim(`(${shortSha(hash)})`)}`));
206
368
  return 'skipped';
207
369
  }
208
370
 
@@ -490,24 +652,26 @@ async function conflictsResolutionWizard(hash) {
490
652
  }
491
653
 
492
654
  async function cherryPickSequential(hashes) {
493
- const result = { applied: 0, skipped: 0 };
655
+ const result = { applied: 0, skipped: 0, appliedHashes: [], skippedHashes: [] };
494
656
 
495
657
  for (const hash of hashes) {
496
658
  try {
497
659
  await gitRaw(['cherry-pick', hash]);
498
660
  const subject = await gitRaw(['show', '--format=%s', '-s', hash]);
499
- log(`${chalk.green('✓')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`);
661
+ log(`${chalk.green('✓')} cherry-picked ${chalk.dim(`(${shortSha(hash)})`)} ${subject}`);
500
662
  result.applied += 1;
663
+ result.appliedHashes.push(hash);
501
664
  } catch (e) {
502
665
  try {
503
666
  const action = await handleCherryPickConflict(hash);
504
667
  if (action === 'skipped') {
505
668
  result.skipped += 1;
669
+ result.skippedHashes.push(hash);
506
670
  continue;
507
671
  }
508
672
  if (action === 'continued') {
509
- // --continue başarıyla commit oluşturdu
510
673
  result.applied += 1;
674
+ result.appliedHashes.push(hash);
511
675
  }
512
676
  } catch (abortErr) {
513
677
  err(chalk.red(`✖ Cherry-pick aborted on ${hash}`));
@@ -616,8 +780,483 @@ async function computeSemanticBumpForCommits(hashes, gitRawFn, semverignore) {
616
780
  return collapseBumps(levels);
617
781
  }
618
782
 
783
+ // ── Profile helpers ──
784
+
785
+ const RC_FILENAME = '.cherrypickrc.json';
786
+
787
+ /** Allowlist of flags that can be saved in a profile */
788
+ const SAVEABLE_FLAGS = new Set([
789
+ 'dev', 'main', 'since', 'no-fetch', 'all-yes', 'ignore-commits',
790
+ 'semantic-versioning', 'current-version', 'version-file', 'version-commit-message', 'ignore-semver',
791
+ 'create-release', 'push-release', 'draft-pr', 'dry-run',
792
+ 'tracker', 'ticket-pattern', 'tracker-url',
793
+ 'no-tui',
794
+ ]);
795
+
796
+ async function getRepoRoot() {
797
+ return (await gitRaw(['rev-parse', '--show-toplevel'])).trim();
798
+ }
799
+
800
+ async function getRcPath() {
801
+ const root = await getRepoRoot();
802
+ return join(root, RC_FILENAME);
803
+ }
804
+
805
+ async function loadRcConfig() {
806
+ const rcPath = await getRcPath();
807
+ return (await readJson(rcPath)) || {};
808
+ }
809
+
810
+ async function saveRcConfig(config) {
811
+ const rcPath = await getRcPath();
812
+ await writeJson(rcPath, config);
813
+ }
814
+
815
+ async function loadProfile(name) {
816
+ const config = await loadRcConfig();
817
+ const profiles = config.profiles || {};
818
+ if (!profiles[name]) {
819
+ throw new Error(`Profile "${name}" not found in ${RC_FILENAME}. Available: ${Object.keys(profiles).join(', ') || '(none)'}`);
820
+ }
821
+ return profiles[name];
822
+ }
823
+
824
+ async function saveProfile(name, flags) {
825
+ const config = await loadRcConfig();
826
+ config.profiles = config.profiles || {};
827
+
828
+ if (config.profiles[name]) {
829
+ const { overwrite } = await inquirer.prompt([
830
+ {
831
+ type: 'confirm',
832
+ name: 'overwrite',
833
+ message: `Profile "${name}" already exists. Overwrite?`,
834
+ default: false,
835
+ },
836
+ ]);
837
+ if (!overwrite) {
838
+ log(chalk.yellow('Aborted — profile not saved.'));
839
+ return false;
840
+ }
841
+ }
842
+
843
+ const filtered = {};
844
+ for (const [key, value] of Object.entries(flags)) {
845
+ if (SAVEABLE_FLAGS.has(key)) {
846
+ filtered[key] = value;
847
+ }
848
+ }
849
+
850
+ config.profiles[name] = filtered;
851
+ await saveRcConfig(config);
852
+
853
+ log(chalk.green(`\n✓ Profile "${name}" saved to ${RC_FILENAME}:`));
854
+ log(JSON.stringify(filtered, null, 2));
855
+ return true;
856
+ }
857
+
858
+ async function listProfiles() {
859
+ const config = await loadRcConfig();
860
+ const profiles = config.profiles || {};
861
+ const names = Object.keys(profiles);
862
+
863
+ if (names.length === 0) {
864
+ log(chalk.yellow(`No profiles found in ${RC_FILENAME}.`));
865
+ return;
866
+ }
867
+
868
+ log(chalk.cyan(`\nProfiles in ${RC_FILENAME}:\n`));
869
+ for (const name of names) {
870
+ const flags = profiles[name];
871
+ const summary = Object.entries(flags)
872
+ .map(([k, v]) => `${k}=${v}`)
873
+ .join(', ');
874
+ log(` ${chalk.bold(name)} ${chalk.dim(summary)}`);
875
+ }
876
+ log('');
877
+ }
878
+
879
+ function applyProfile(profile, currentArgv) {
880
+ for (const [key, value] of Object.entries(profile)) {
881
+ // CLI flags (explicit) override profile values.
882
+ // yargs sets properties from defaults — we detect explicit CLI flags
883
+ // by checking if the key is in the raw process.argv
884
+ const cliFlag = `--${key}`;
885
+ const wasExplicit = process.argv.some((a) => a === cliFlag || a.startsWith(`${cliFlag}=`));
886
+ if (!wasExplicit) {
887
+ currentArgv[key] = value;
888
+ // Also set camelCase version for yargs compatibility
889
+ const camel = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
890
+ if (camel !== key) currentArgv[camel] = value;
891
+ }
892
+ }
893
+ }
894
+
895
+ // ── TUI detection ──
896
+
897
+ const MIN_TUI_ROWS = 15;
898
+ const MIN_TUI_COLS = 60;
899
+
900
+ function shouldUseTui() {
901
+ // Explicit opt-out
902
+ if (argv['no-tui'] || argv.noTui) return false;
903
+ // CI mode or all-yes: no interactive UI needed
904
+ if (argv.ci || argv['all-yes']) return false;
905
+ // Non-interactive terminal
906
+ if (!process.stdout.isTTY) return false;
907
+ // CI environment variable
908
+ if (process.env.CI === 'true' || process.env.CI === '1') return false;
909
+ // Windows: fallback to inquirer
910
+ if (process.platform === 'win32') return false;
911
+ // Terminal too small
912
+ const rows = process.stdout.rows || 24;
913
+ const cols = process.stdout.columns || 80;
914
+ if (rows < MIN_TUI_ROWS || cols < MIN_TUI_COLS) return false;
915
+ return true;
916
+ }
917
+
918
+ async function selectCommitsWithTuiOrFallback(commits) {
919
+ if (shouldUseTui()) {
920
+ const { renderCommitSelector } = await import('./src/tui/index.js');
921
+ return renderCommitSelector(commits, gitRaw, {
922
+ devBranch: argv.dev,
923
+ mainBranch: argv.main,
924
+ since: argv.since,
925
+ });
926
+ }
927
+ return selectCommitsInteractive(commits);
928
+ }
929
+
930
+ // ── Session helpers (undo/rollback) ──
931
+
932
+ const SESSION_FILENAME = '.cherrypick-session.json';
933
+
934
+ async function getSessionPath() {
935
+ const root = await getRepoRoot();
936
+ return join(root, SESSION_FILENAME);
937
+ }
938
+
939
+ async function saveSession({ branch, checkpoint, commits }) {
940
+ const sessionPath = await getSessionPath();
941
+ const data = {
942
+ branch,
943
+ checkpoint,
944
+ timestamp: new Date().toISOString(),
945
+ commits,
946
+ };
947
+ await writeJson(sessionPath, data);
948
+ }
949
+
950
+ async function loadSession() {
951
+ const sessionPath = await getSessionPath();
952
+ return readJson(sessionPath);
953
+ }
954
+
955
+ async function deleteSession() {
956
+ const sessionPath = await getSessionPath();
957
+ try {
958
+ await fsPromises.unlink(sessionPath);
959
+ } catch (e) {
960
+ if (e.code !== 'ENOENT') throw e;
961
+ }
962
+ }
963
+
964
+ async function hasRemoteTrackingBranch() {
965
+ try {
966
+ await gitRaw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
967
+ return true;
968
+ } catch {
969
+ return false;
970
+ }
971
+ }
972
+
973
+ async function handleUndo() {
974
+ // --undo + --ci is not allowed
975
+ if (argv.ci) {
976
+ throw new ExitError('--undo is interactive-only and cannot be used with --ci. In CI, re-run the pipeline instead.', 1);
977
+ }
978
+
979
+ const session = await loadSession();
980
+ if (!session) {
981
+ throw new ExitError(`No active session to undo. (${SESSION_FILENAME} not found)`, 1);
982
+ }
983
+
984
+ const currentBranch = await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD']);
985
+
986
+ // Warn if on a different branch
987
+ if (currentBranch !== session.branch) {
988
+ log(chalk.yellow(`⚠ You are on "${currentBranch}" but the session was created on "${session.branch}".`));
989
+ const { switchBranch } = await inquirer.prompt([
990
+ { type: 'confirm', name: 'switchBranch', message: `Switch to ${session.branch}?`, default: true },
991
+ ]);
992
+ if (switchBranch) {
993
+ await gitRaw(['checkout', session.branch]);
994
+ } else {
995
+ log(chalk.yellow('Aborted.'));
996
+ return;
997
+ }
998
+ }
999
+
1000
+ // Validate checkpoint is an ancestor of current HEAD
1001
+ try {
1002
+ await gitRaw(['merge-base', '--is-ancestor', session.checkpoint, 'HEAD']);
1003
+ } catch {
1004
+ throw new ExitError(`Checkpoint ${shortSha(session.checkpoint)} is not an ancestor of current HEAD. Session may be corrupt.`, 1);
1005
+ }
1006
+
1007
+ // Divergence check: count commits between checkpoint and HEAD
1008
+ const revCount = await gitRaw(['rev-list', '--count', `${session.checkpoint}..HEAD`]);
1009
+ const commitsSinceCheckpoint = Number.parseInt(revCount.trim(), 10);
1010
+ const expectedCommits = session.commits?.length || 0;
1011
+
1012
+ if (commitsSinceCheckpoint > expectedCommits) {
1013
+ throw new ExitError(
1014
+ `Branch has diverged: ${commitsSinceCheckpoint} commits since checkpoint, but session recorded ${expectedCommits}. Someone else may have pushed. Aborting to prevent data loss.`,
1015
+ 1,
1016
+ );
1017
+ }
1018
+
1019
+ // Confirmation
1020
+ log(chalk.yellow(`\n⚠ WARNING: This will rewrite remote history for ${session.branch}.`));
1021
+ log(chalk.yellow(' Anyone else working on this branch will be affected.\n'));
1022
+ log(` Checkpoint: ${chalk.dim(shortSha(session.checkpoint))}`);
1023
+ log(` Commits to discard: ${commitsSinceCheckpoint}`);
1024
+ log(chalk.gray(' This is an all-or-nothing rollback — individual commits cannot be selectively removed.\n'));
1025
+
1026
+ const { proceed } = await inquirer.prompt([
1027
+ { type: 'confirm', name: 'proceed', message: 'Continue?', default: false },
1028
+ ]);
1029
+
1030
+ if (!proceed) {
1031
+ log(chalk.yellow('Aborted.'));
1032
+ return;
1033
+ }
1034
+
1035
+ // Reset
1036
+ await gitRaw(['reset', '--hard', session.checkpoint]);
1037
+ log(chalk.green(`✓ Branch reset to ${shortSha(session.checkpoint)}.`));
1038
+
1039
+ // Force push if remote exists
1040
+ if (await hasRemoteTrackingBranch()) {
1041
+ await gitRaw(['push', '--force-with-lease']);
1042
+ log(chalk.green('✓ Force pushed with --force-with-lease.'));
1043
+ } else {
1044
+ log(chalk.gray('(No remote tracking branch — skipped push)'));
1045
+ }
1046
+
1047
+ // Clean up
1048
+ await deleteSession();
1049
+
1050
+ // Summary
1051
+ log(chalk.green(`\nBranch ${session.branch} has been reset to ${shortSha(session.checkpoint)}. You can now re-select commits.`));
1052
+
1053
+ // Offer to re-open selection
1054
+ const { reopen } = await inquirer.prompt([
1055
+ { type: 'confirm', name: 'reopen', message: 'Re-open commit selection?', default: true },
1056
+ ]);
1057
+
1058
+ return reopen;
1059
+ }
1060
+
1061
+ // ── Dependency detection helpers ──
1062
+
1063
+ const MAX_DEPENDENCY_COMMITS = 200;
1064
+
1065
+ /**
1066
+ * Batch-fetch changed files for a list of commit hashes in a single git call.
1067
+ * Returns Map<hash, Set<filePath>>.
1068
+ */
1069
+ async function batchGetChangedFiles(hashes, gitRawFn) {
1070
+ if (hashes.length === 0) return new Map();
1071
+
1072
+ const fileMap = new Map();
1073
+ for (const h of hashes) fileMap.set(h, new Set());
1074
+
1075
+ // Single batched call: git log --name-only --pretty=format:COMMIT:%H
1076
+ const raw = await gitRawFn([
1077
+ 'log', '--name-only', '--pretty=format:COMMIT:%H',
1078
+ '--no-walk', ...hashes,
1079
+ ]);
1080
+
1081
+ let currentHash = null;
1082
+ for (const line of raw.split('\n')) {
1083
+ if (line.startsWith('COMMIT:')) {
1084
+ currentHash = line.slice(7);
1085
+ } else if (currentHash && line.trim()) {
1086
+ const set = fileMap.get(currentHash);
1087
+ if (set) set.add(line.trim());
1088
+ }
1089
+ }
1090
+
1091
+ return fileMap;
1092
+ }
1093
+
1094
+ /**
1095
+ * Detect potential dependencies: selected commits that share files with
1096
+ * earlier unselected commits.
1097
+ *
1098
+ * @param {string[]} selected - selected hashes (oldest→newest order)
1099
+ * @param {Array<{hash,subject}>} unselected - unselected commit objects
1100
+ * @param {Array<{hash,subject}>} allCommits - all commits in original order (newest→oldest)
1101
+ * @param {Function} gitRawFn
1102
+ * @returns {Array<{selected, dependency, sharedFiles}>}
1103
+ */
1104
+ async function detectDependencies(selected, unselected, allCommits, gitRawFn) {
1105
+ const totalCommits = selected.length + unselected.length;
1106
+ if (totalCommits > MAX_DEPENDENCY_COMMITS) {
1107
+ log(chalk.yellow(`⚠ Skipping dependency detection: ${totalCommits} commits exceeds limit (${MAX_DEPENDENCY_COMMITS}).`));
1108
+ return [];
1109
+ }
1110
+
1111
+ const allHashes = [...selected, ...unselected.map((c) => c.hash)];
1112
+ const fileMap = await batchGetChangedFiles(allHashes, gitRawFn);
1113
+
1114
+ // Build order index: position in original commit list (newest=0, oldest=N)
1115
+ const orderIndex = new Map(allCommits.map((c, i) => [c.hash, i]));
1116
+
1117
+ const results = [];
1118
+ const selectedSet = new Set(selected);
1119
+
1120
+ for (const selHash of selected) {
1121
+ const selFiles = fileMap.get(selHash) || new Set();
1122
+ const selOrder = orderIndex.get(selHash) ?? 0;
1123
+
1124
+ for (const unsel of unselected) {
1125
+ const unselOrder = orderIndex.get(unsel.hash) ?? 0;
1126
+ // Only check unselected commits that are OLDER (higher index = older in newest-first order)
1127
+ if (unselOrder <= selOrder) continue;
1128
+
1129
+ const unselFiles = fileMap.get(unsel.hash) || new Set();
1130
+ const shared = [...selFiles].filter((f) => unselFiles.has(f));
1131
+
1132
+ if (shared.length > 0) {
1133
+ results.push({
1134
+ selected: selHash,
1135
+ dependency: unsel.hash,
1136
+ sharedFiles: shared,
1137
+ });
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ return results;
1143
+ }
1144
+
1145
+ // ── Tracker helpers ──
1146
+
1147
+ const TRACKER_PRESETS = {
1148
+ clickup: '#([a-z0-9]+)',
1149
+ jira: '([A-Z]+-\\d+)',
1150
+ linear: '\\[([A-Z]+-\\d+)\\]',
1151
+ };
1152
+
1153
+ function parseTrackerConfig(currentArgv) {
1154
+ let pattern = currentArgv['ticket-pattern'];
1155
+ const url = currentArgv['tracker-url'];
1156
+ const preset = currentArgv.tracker;
1157
+
1158
+ // Load from .cherrypickrc.json tracker section if not set via CLI
1159
+ // (will be populated after loadRcConfig is called in main)
1160
+
1161
+ if (!pattern && !url && !preset) return null;
1162
+
1163
+ if (preset && !pattern) {
1164
+ pattern = TRACKER_PRESETS[preset];
1165
+ if (!pattern) {
1166
+ throw new Error(`Unknown tracker preset "${preset}". Available: ${Object.keys(TRACKER_PRESETS).join(', ')}`);
1167
+ }
1168
+ }
1169
+
1170
+ if (pattern && !url) {
1171
+ throw new Error('--ticket-pattern requires --tracker-url to be set.');
1172
+ }
1173
+ if (url && !pattern && !preset) {
1174
+ throw new Error('--tracker-url requires --ticket-pattern or --tracker to be set.');
1175
+ }
1176
+
1177
+ let compiled;
1178
+ try {
1179
+ compiled = new RegExp(pattern);
1180
+ } catch (e) {
1181
+ throw new Error(`Invalid --ticket-pattern regex "${pattern}": ${e.message}`);
1182
+ }
1183
+
1184
+ if (!isSafeRegex(compiled)) {
1185
+ throw new Error(`Pattern rejected — potential catastrophic backtracking: "${pattern}"`);
1186
+ }
1187
+
1188
+ // Validate capture group
1189
+ const groups = new RegExp(`${pattern}|`).exec('').length - 1;
1190
+ if (groups < 1) {
1191
+ throw new Error('Pattern must have one capture group for the ticket ID.');
1192
+ }
1193
+
1194
+ return { pattern: compiled, url };
1195
+ }
1196
+
1197
+ function linkifyTicket(subject, trackerConfig) {
1198
+ if (!trackerConfig) return subject;
1199
+ const { pattern, url } = trackerConfig;
1200
+ const match = pattern.exec(subject);
1201
+ if (!match) return subject;
1202
+
1203
+ const fullMatch = match[0]; // entire matched text (e.g., "#86c8w62wx")
1204
+ const capturedId = match[1]; // capture group (e.g., "86c8w62wx")
1205
+ const link = url.replace('{{id}}', capturedId);
1206
+ return subject.replace(fullMatch, `[${fullMatch}](${link})`);
1207
+ }
1208
+
1209
+ async function loadTrackerFromRc() {
1210
+ try {
1211
+ const config = await loadRcConfig();
1212
+ return config.tracker || null;
1213
+ } catch {
1214
+ return null;
1215
+ }
1216
+ }
1217
+
619
1218
  async function main() {
620
1219
  try {
1220
+ // ── Undo handling (must run before anything else) ──
1221
+ if (argv.undo) {
1222
+ const shouldReopen = await handleUndo();
1223
+ if (!shouldReopen) return;
1224
+ argv.undo = false;
1225
+ // Fall through to normal flow (re-open selection)
1226
+ }
1227
+
1228
+ // ── CI mode: implicitly enable --all-yes ──
1229
+ if (argv.ci) {
1230
+ argv['all-yes'] = true;
1231
+ argv.allYes = true;
1232
+ }
1233
+
1234
+ // ── Profile handling (must run before any other logic) ──
1235
+ if (argv['list-profiles']) {
1236
+ await listProfiles();
1237
+ return;
1238
+ }
1239
+
1240
+ if (argv['save-profile']) {
1241
+ const name = argv['save-profile'];
1242
+ await saveProfile(name, argv);
1243
+ return;
1244
+ }
1245
+
1246
+ if (argv['profile']) {
1247
+ const profile = await loadProfile(argv['profile']);
1248
+ applyProfile(profile, argv);
1249
+ }
1250
+
1251
+ // ── Tracker config (merge CLI flags with .cherrypickrc.json) ──
1252
+ if (!argv['ticket-pattern'] && !argv['tracker']) {
1253
+ const rcTracker = await loadTrackerFromRc();
1254
+ if (rcTracker) {
1255
+ if (rcTracker['ticket-pattern'] && !argv['ticket-pattern']) argv['ticket-pattern'] = rcTracker['ticket-pattern'];
1256
+ if (rcTracker['tracker-url'] && !argv['tracker-url']) argv['tracker-url'] = rcTracker['tracker-url'];
1257
+ }
1258
+ }
1259
+
621
1260
  // Check if gh CLI is installed when push-release is enabled
622
1261
  if (argv['push-release']) {
623
1262
  const ghInstalled = await checkGhCli();
@@ -670,6 +1309,7 @@ async function main() {
670
1309
 
671
1310
  if (filteredMissing.length === 0) {
672
1311
  log(chalk.green('✅ No missing commits found in the selected window.'));
1312
+ if (argv.ci) throw new ExitError('No commits found.', 2);
673
1313
  return;
674
1314
  }
675
1315
 
@@ -680,49 +1320,165 @@ async function main() {
680
1320
  if (argv['all-yes']) {
681
1321
  selected = filteredMissing.map((m) => m.hash);
682
1322
  } else {
683
- selected = await selectCommitsInteractive(filteredMissing);
1323
+ selected = await selectCommitsWithTuiOrFallback(filteredMissing);
684
1324
  if (!selected.length) {
685
1325
  log(chalk.yellow('No commits selected. Exiting.'));
686
1326
  return;
687
1327
  }
688
1328
  }
689
1329
 
690
- const bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a));
1330
+ let bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a));
1331
+
1332
+ // ── Dependency detection ──
1333
+ const depStrategy = argv['dependency-strategy'] || 'warn';
1334
+ if (depStrategy !== 'ignore') {
1335
+ const selectedSet = new Set(selected);
1336
+ const unselected = filteredMissing.filter((c) => !selectedSet.has(c.hash));
1337
+
1338
+ if (unselected.length > 0) {
1339
+ const deps = await detectDependencies(bottomToTop, unselected, filteredMissing, gitRaw);
1340
+
1341
+ if (deps.length > 0) {
1342
+ // Show warnings
1343
+ log(chalk.yellow('\n⚠ Potential dependency detected (file-level heuristic — may be a false positive):\n'));
1344
+ for (const dep of deps) {
1345
+ const selSubj = await gitRaw(['show', '--format=%s', '-s', dep.selected]);
1346
+ const depSubj = await gitRaw(['show', '--format=%s', '-s', dep.dependency]);
1347
+ log(` Selected: ${chalk.dim(`(${shortSha(dep.selected)})`)} ${selSubj}`);
1348
+ log(` Depends on: ${chalk.dim(`(${shortSha(dep.dependency)})`)} ${depSubj} ${chalk.red('[NOT SELECTED]')}`);
1349
+ log(` Shared files: ${chalk.dim(dep.sharedFiles.join(', '))}\n`);
1350
+ }
691
1351
 
692
- if (argv.dry_run || argv['dry-run']) {
693
- log(chalk.cyan('\n--dry-run: would cherry-pick (oldest newest):'));
694
- for (const h of bottomToTop) {
695
- const subj = await gitRaw(['show', '--format=%s', '-s', h]);
696
- log(`- ${chalk.dim(`(${h.slice(0, 7)})`)} ${subj}`);
1352
+ if (argv.ci) {
1353
+ if (depStrategy === 'fail') {
1354
+ throw new ExitError('Dependency check failed (--dependency-strategy fail). Aborting.', 4);
1355
+ }
1356
+ // warn: already logged above, continue
1357
+ } else {
1358
+ const missingHashes = [...new Set(deps.map((d) => d.dependency))];
1359
+ const { choice } = await inquirer.prompt([
1360
+ {
1361
+ type: 'list',
1362
+ name: 'choice',
1363
+ message: 'How would you like to proceed?',
1364
+ choices: [
1365
+ { name: 'Include missing commits and continue', value: 'include' },
1366
+ { name: 'Go back to selection', value: 'back' },
1367
+ { name: 'Continue anyway (may cause conflicts)', value: 'continue' },
1368
+ ],
1369
+ },
1370
+ ]);
1371
+
1372
+ if (choice === 'include') {
1373
+ log(chalk.cyan('\nCommits to be added:'));
1374
+ for (const h of missingHashes) {
1375
+ const subj = await gitRaw(['show', '--format=%s', '-s', h]);
1376
+ log(` + ${chalk.dim(`(${shortSha(h)})`)} ${subj}`);
1377
+ }
1378
+ const { confirm } = await inquirer.prompt([
1379
+ { type: 'confirm', name: 'confirm', message: 'Add these commits?', default: true },
1380
+ ]);
1381
+ if (confirm) {
1382
+ selected = [...selected, ...missingHashes];
1383
+ bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a));
1384
+ log(chalk.green(`✓ ${missingHashes.length} commit(s) added. Total: ${selected.length}`));
1385
+ }
1386
+ } else if (choice === 'back') {
1387
+ selected = await selectCommitsWithTuiOrFallback(filteredMissing);
1388
+ if (!selected.length) {
1389
+ log(chalk.yellow('No commits selected. Exiting.'));
1390
+ return;
1391
+ }
1392
+ bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a));
1393
+ }
1394
+ // 'continue': proceed as-is
1395
+ }
1396
+ }
697
1397
  }
698
- return;
699
1398
  }
700
1399
 
1400
+ // ── Version computation (moved before preview) ──
701
1401
  if (argv['version-file'] && !argv['current-version']) {
702
1402
  const currentVersionFromPkg = await getPkgVersion(argv['version-file']);
703
1403
  argv['current-version'] = currentVersionFromPkg;
704
1404
  }
705
1405
 
706
1406
  let computedNextVersion = argv['current-version'];
1407
+ let detectedBump = null;
707
1408
  if (argv['semantic-versioning']) {
708
1409
  if (!argv['current-version']) {
709
1410
  throw new Error(' --semantic-versioning requires --current-version X.Y.Z (or pass --version-file)');
710
1411
  }
711
1412
 
712
- // Bump is based on the commits you are about to apply (selected).
713
- const bump = await computeSemanticBumpForCommits(bottomToTop, gitRaw, semverIgnore);
714
-
715
- computedNextVersion = bump ? incrementVersion(argv['current-version'], bump) : argv['current-version'];
1413
+ detectedBump = await computeSemanticBumpForCommits(bottomToTop, gitRaw, semverIgnore);
1414
+ computedNextVersion = detectedBump ? incrementVersion(argv['current-version'], detectedBump) : argv['current-version'];
716
1415
 
717
1416
  log('');
718
1417
  log(chalk.magenta('Semantic Versioning'));
719
1418
  log(
720
1419
  ` Current: ${chalk.bold(argv['current-version'])} ` +
721
- `Detected bump: ${chalk.bold(bump || 'none')} ` +
1420
+ `Detected bump: ${chalk.bold(detectedBump || 'none')} ` +
722
1421
  `Next: ${chalk.bold(computedNextVersion)}`,
723
1422
  );
724
1423
  }
725
1424
 
1425
+ // ── Changelog preview ──
1426
+ let trackerConfig = null;
1427
+ try {
1428
+ trackerConfig = parseTrackerConfig(argv);
1429
+ } catch (e) {
1430
+ err(chalk.red(e.message));
1431
+ }
1432
+
1433
+ const previewChangelog = await buildChangelogBody({
1434
+ version: computedNextVersion,
1435
+ hashes: bottomToTop,
1436
+ gitRawFn: gitRaw,
1437
+ semverIgnore,
1438
+ trackerConfig,
1439
+ });
1440
+
1441
+ const isDryRun = argv.dry_run || argv['dry-run'];
1442
+
1443
+ // Show preview
1444
+ log(chalk.cyan('\n── Changelog Preview ──────────────────'));
1445
+ if (computedNextVersion && argv['current-version'] && computedNextVersion !== argv['current-version']) {
1446
+ log(chalk.gray(`Previous: ${argv['current-version']} → Next: ${computedNextVersion} (${detectedBump} bump)`));
1447
+ }
1448
+ log('');
1449
+ log(previewChangelog);
1450
+ log(chalk.gray(`${bottomToTop.length} commits selected`));
1451
+ log(chalk.cyan('──────────────────────────────────────'));
1452
+
1453
+ if (isDryRun) {
1454
+ log(chalk.cyan('\n--dry-run: would cherry-pick (oldest → newest):'));
1455
+ for (const h of bottomToTop) {
1456
+ const subj = await gitRaw(['show', '--format=%s', '-s', h]);
1457
+ log(`- ${chalk.dim(`(${h.slice(0, 7)})`)} ${subj}`);
1458
+ }
1459
+ return;
1460
+ }
1461
+
1462
+ // Confirmation (skip in CI)
1463
+ if (!argv.ci && !argv['all-yes']) {
1464
+ const { proceed } = await inquirer.prompt([
1465
+ {
1466
+ type: 'confirm',
1467
+ name: 'proceed',
1468
+ message: 'Proceed with cherry-pick?',
1469
+ default: false,
1470
+ },
1471
+ ]);
1472
+ if (!proceed) {
1473
+ log(chalk.yellow('Aborted by user.'));
1474
+ return;
1475
+ }
1476
+ }
1477
+
1478
+ if (argv.ci) {
1479
+ err(chalk.gray('[CI] Changelog preview logged. Proceeding automatically.'));
1480
+ }
1481
+
726
1482
  if (argv['create-release']) {
727
1483
  if (!argv['semantic-versioning'] || !argv['current-version']) {
728
1484
  throw new Error(' --create-release requires --semantic-versioning and --current-version X.Y.Z');
@@ -735,14 +1491,7 @@ async function main() {
735
1491
  const startPoint = argv.main; // e.g., 'origin/main' or a local ref
736
1492
  await ensureReleaseBranchFresh(releaseBranch, startPoint);
737
1493
 
738
- const changelogBody = await buildChangelogBody({
739
- version: computedNextVersion,
740
- hashes: bottomToTop,
741
- gitRawFn: gitRaw,
742
- semverIgnore, // raw flag value
743
- });
744
-
745
- await fsPromises.writeFile('RELEASE_CHANGELOG.md', changelogBody, 'utf8');
1494
+ await fsPromises.writeFile('RELEASE_CHANGELOG.md', previewChangelog, 'utf8');
746
1495
  await gitRaw(['reset', 'RELEASE_CHANGELOG.md']);
747
1496
  log(chalk.gray(`✅ Generated changelog for ${releaseBranch} → RELEASE_CHANGELOG.md`));
748
1497
 
@@ -756,12 +1505,25 @@ async function main() {
756
1505
  log(chalk.bold(`Base branch: ${currentBranch}`));
757
1506
  }
758
1507
 
1508
+ // ── Save session checkpoint before cherry-pick ──
1509
+ const checkpointHash = await gitRaw(['rev-parse', 'HEAD']);
1510
+ const sessionBranch = await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD']);
1511
+ await saveSession({
1512
+ branch: sessionBranch,
1513
+ checkpoint: checkpointHash,
1514
+ commits: bottomToTop,
1515
+ });
1516
+
759
1517
  log(chalk.cyan(`\nCherry-picking ${bottomToTop.length} commit(s) onto ${currentBranch} (oldest → newest)...\n`));
760
1518
 
761
1519
  const stats = await cherryPickSequential(bottomToTop);
762
1520
 
763
1521
  log(chalk.gray(`\nSummary → applied: ${stats.applied}, skipped: ${stats.skipped}`));
764
1522
 
1523
+ // Populate CI result with cherry-pick stats
1524
+ ciResult.commits.applied = stats.appliedHashes;
1525
+ ciResult.commits.skipped = stats.skippedHashes;
1526
+
765
1527
  if (stats.applied === 0) {
766
1528
  err(chalk.yellow('\nNo commits were cherry-picked (all were skipped or unresolved). Aborting.'));
767
1529
  // Abort any leftover state just in case
@@ -815,10 +1577,36 @@ async function main() {
815
1577
  ? await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD']) // should be release/*
816
1578
  : currentBranch;
817
1579
 
1580
+ // ── Populate CI result ──
1581
+ ciResult.version.previous = argv['current-version'] || null;
1582
+ ciResult.version.next = computedNextVersion || null;
1583
+ ciResult.version.bump = detectedBump || null;
1584
+ ciResult.branch = finalBranch;
1585
+ ciResult.commits.total = bottomToTop.length;
1586
+ ciResult.changelog = previewChangelog;
1587
+
1588
+ // ── JSON output (--format json) ──
1589
+ if (isJsonFormat) {
1590
+ console.log(JSON.stringify(ciResult, null, 2));
1591
+ }
1592
+
1593
+ // Clean up session on success
1594
+ await deleteSession();
1595
+
818
1596
  log(chalk.green(`\n✅ Done on ${finalBranch}`));
819
1597
  } catch (e) {
820
1598
  err(chalk.red(`\n❌ Error: ${e.message || e}`));
821
- process.exit(1);
1599
+
1600
+ // Clean up session on error too
1601
+ try { await deleteSession(); } catch { /* ignore cleanup errors */ }
1602
+
1603
+ // Output partial JSON result on error
1604
+ if (isJsonFormat) {
1605
+ console.log(JSON.stringify(ciResult, null, 2));
1606
+ }
1607
+
1608
+ const code = e instanceof ExitError ? e.exitCode : (argv.ci ? 3 : 1);
1609
+ process.exit(code);
822
1610
  }
823
1611
  }
824
1612
 
@@ -878,7 +1666,7 @@ async function ensureReleaseBranchFresh(branchName, startPoint) {
878
1666
  }
879
1667
  }
880
1668
 
881
- async function buildChangelogBody({ version, hashes, gitRawFn, semverIgnore }) {
1669
+ async function buildChangelogBody({ version, hashes, gitRawFn, semverIgnore, trackerConfig }) {
882
1670
  const today = new Date().toISOString().slice(0, 10);
883
1671
  const header = version ? `## Release ${version} — ${today}` : `## Release — ${today}`;
884
1672
  const semverIgnorePatterns = parseSemverIgnore(semverIgnore);
@@ -888,10 +1676,14 @@ async function buildChangelogBody({ version, hashes, gitRawFn, semverIgnore }) {
888
1676
  const fixes = [];
889
1677
  const others = [];
890
1678
 
1679
+ let linkedCount = 0;
1680
+
891
1681
  for (const h of hashes) {
892
1682
  const msg = await gitRawFn(['show', '--format=%B', '-s', h]);
893
1683
 
894
- const subject = msg.split(/\r?\n/)[0].trim(); // first line of commit message
1684
+ const rawSubject = msg.split(/\r?\n/)[0].trim(); // first line of commit message
1685
+ const subject = linkifyTicket(rawSubject, trackerConfig);
1686
+ if (trackerConfig && subject !== rawSubject) linkedCount++;
895
1687
  const shaDisplay = shortSha(h);
896
1688
 
897
1689
  // normal classification first
@@ -919,6 +1711,10 @@ async function buildChangelogBody({ version, hashes, gitRawFn, semverIgnore }) {
919
1711
  }
920
1712
  }
921
1713
 
1714
+ if (trackerConfig) {
1715
+ log(chalk.gray(`Tracker: ${linkedCount} of ${hashes.length} commits had ticket IDs linked.`));
1716
+ }
1717
+
922
1718
  const sections = [];
923
1719
  if (breakings.length) {
924
1720
  sections.push(`### ✨ Breaking Changes\n${breakings.join('\n')}`);
@@ -1018,7 +1814,12 @@ function parseSemverIgnore(argvValue) {
1018
1814
  .filter(Boolean)
1019
1815
  .map((pattern) => {
1020
1816
  try {
1021
- return new RegExp(pattern, 'i'); // case-insensitive on full message
1817
+ const rx = new RegExp(pattern, 'i');
1818
+ if (!isSafeRegex(rx)) {
1819
+ err(chalk.red(`Rejected --ignore-semver pattern "${pattern}" — potential catastrophic backtracking`));
1820
+ return null;
1821
+ }
1822
+ return rx;
1022
1823
  } catch (e) {
1023
1824
  err(chalk.red(`Invalid --ignore-semver pattern "${pattern}": ${e.message || e}`));
1024
1825
  return null;
@@ -1047,7 +1848,12 @@ function parseIgnoreCommits(argvValue) {
1047
1848
  .filter(Boolean)
1048
1849
  .map((pattern) => {
1049
1850
  try {
1050
- return new RegExp(pattern, 'i'); // case-insensitive matching
1851
+ const rx = new RegExp(pattern, 'i');
1852
+ if (!isSafeRegex(rx)) {
1853
+ err(chalk.red(`Rejected --ignore-commits pattern "${pattern}" — potential catastrophic backtracking`));
1854
+ return null;
1855
+ }
1856
+ return rx;
1051
1857
  } catch (e) {
1052
1858
  err(chalk.red(`Invalid --ignore-commits pattern "${pattern}": ${e.message || e}`));
1053
1859
  return null;