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 +855 -49
- package/package.json +13 -4
- package/src/tui/App.js +194 -0
- package/src/tui/CommitList.js +28 -0
- package/src/tui/CommitRow.js +19 -0
- package/src/tui/Header.js +14 -0
- package/src/tui/KeyBar.js +20 -0
- package/src/tui/Preview.js +15 -0
- package/src/tui/html.js +4 -0
- package/src/tui/index.js +34 -0
- package/.github/workflows/release.yml +0 -107
- package/.yarnrc.yml +0 -1
- package/biome.json +0 -23
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('
|
|
64
|
-
type: '
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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: '
|
|
95
|
-
describe: '
|
|
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('
|
|
142
|
+
.option('format', {
|
|
98
143
|
type: 'string',
|
|
99
|
-
default: '
|
|
100
|
-
describe: '
|
|
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('
|
|
149
|
+
.option('dependency-strategy', {
|
|
103
150
|
type: 'string',
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
156
|
+
|
|
157
|
+
// ── Tracker options ──
|
|
158
|
+
.option('tracker', {
|
|
108
159
|
type: 'string',
|
|
109
|
-
describe:
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
713
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|