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