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