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