cherrypick-interactive 1.0.1 → 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 +685 -405
- package/package.json +14 -11
package/cli.js
CHANGED
|
@@ -1,163 +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
|
-
|
|
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);
|
|
85
111
|
|
|
86
112
|
async function gitRaw(args) {
|
|
87
|
-
|
|
88
|
-
|
|
113
|
+
const out = await git.raw(args);
|
|
114
|
+
return out.trim();
|
|
89
115
|
}
|
|
90
116
|
|
|
91
117
|
async function getSubjects(branch) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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));
|
|
97
123
|
}
|
|
98
124
|
|
|
99
125
|
async function getDevCommits(branch, since) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
});
|
|
111
137
|
}
|
|
112
138
|
|
|
113
139
|
function filterMissing(devCommits, mainSubjects) {
|
|
114
|
-
|
|
140
|
+
return devCommits.filter(({ subject }) => !mainSubjects.has(subject));
|
|
115
141
|
}
|
|
116
142
|
|
|
117
143
|
async function selectCommitsInteractive(missing) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function handleCherryPickConflict(hash) {
|
|
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)
|
|
177
|
+
|
|
178
|
+
const { action } = await inquirer.prompt([
|
|
179
|
+
{
|
|
180
|
+
type: "list",
|
|
181
|
+
name: "action",
|
|
182
|
+
message: "Choose how to proceed:",
|
|
183
|
+
choices: [
|
|
184
|
+
{ name: "Skip this commit", value: "skip" },
|
|
185
|
+
{ name: "Resolve conflicts now", value: "resolve" },
|
|
186
|
+
{ name: "Revoke and cancel (abort entire sequence)", value: "abort" },
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
if (action === "skip") {
|
|
192
|
+
await gitRaw(["cherry-pick", "--skip"]);
|
|
193
|
+
log(chalk.yellow(`↷ Skipped commit ${chalk.dim(`(${hash.slice(0, 7)})`)}`));
|
|
194
|
+
return "skipped";
|
|
195
|
+
}
|
|
196
|
+
|
|
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";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function getConflictedFiles() {
|
|
211
|
+
const out = await gitRaw(["diff", "--name-only", "--diff-filter=U"]);
|
|
212
|
+
return out ? out.split("\n").filter(Boolean) : [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function assertNoUnmerged() {
|
|
216
|
+
const files = await getConflictedFiles();
|
|
217
|
+
return files.length === 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function runBin(bin, args) {
|
|
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
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function showConflictsList() {
|
|
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;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function resolveSingleFileWizard(file) {
|
|
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([
|
|
134
274
|
{
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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}`));
|
|
291
|
+
}
|
|
292
|
+
} catch (e) {
|
|
293
|
+
err(chalk.red(`Action failed on ${file}: ${e.message || e}`));
|
|
294
|
+
}
|
|
142
295
|
|
|
143
|
-
|
|
296
|
+
return action;
|
|
144
297
|
}
|
|
145
298
|
|
|
146
|
-
async function
|
|
147
|
-
|
|
299
|
+
async function conflictsResolutionWizard(hash) {
|
|
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
|
+
}
|
|
315
|
+
|
|
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
|
+
}
|
|
345
|
+
|
|
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
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (choice.type === "global" && choice.action === "continue") {
|
|
365
|
+
if (await assertNoUnmerged()) {
|
|
148
366
|
try {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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";
|
|
152
371
|
} catch (e) {
|
|
153
|
-
|
|
154
|
-
err(chalk.yellow('Resolve conflicts, then run:'))
|
|
155
|
-
err(chalk.yellow(' git add -A && git cherry-pick --continue'))
|
|
156
|
-
err(chalk.yellow('Or abort:'))
|
|
157
|
-
err(chalk.yellow(' git cherry-pick --abort'))
|
|
158
|
-
throw e
|
|
372
|
+
err(chalk.red("`--continue` failed. Resolve remaining issues and try again."));
|
|
159
373
|
}
|
|
374
|
+
} else {
|
|
375
|
+
err(chalk.yellow("There are still unmerged files."));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (choice.type === "global" && choice.action === "back") {
|
|
380
|
+
return "back";
|
|
160
381
|
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function cherryPickSequential(hashes) {
|
|
386
|
+
const result = { applied: 0, skipped: 0 };
|
|
387
|
+
|
|
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;
|
|
405
|
+
}
|
|
406
|
+
} catch (abortErr) {
|
|
407
|
+
err(chalk.red(`✖ Cherry-pick aborted on ${hash}`));
|
|
408
|
+
throw abortErr;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return result;
|
|
161
414
|
}
|
|
162
415
|
|
|
163
416
|
/**
|
|
@@ -165,347 +418,374 @@ async function cherryPickSequential(hashes) {
|
|
|
165
418
|
* @returns {Promise<void>}
|
|
166
419
|
*/
|
|
167
420
|
function parseVersion(v) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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] };
|
|
175
428
|
}
|
|
176
429
|
|
|
177
430
|
function incrementVersion(version, bump) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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}`;
|
|
189
442
|
}
|
|
190
443
|
|
|
191
444
|
function normalizeMessage(msg) {
|
|
192
|
-
|
|
193
|
-
|
|
445
|
+
// normalize whitespace; keep case-insensitive matching
|
|
446
|
+
return (msg || "").replace(/\r\n/g, "\n");
|
|
194
447
|
}
|
|
195
448
|
|
|
196
449
|
// Returns "major" | "minor" | "patch" | null for a single commit message
|
|
197
450
|
function classifySingleCommit(messageBody) {
|
|
198
|
-
|
|
451
|
+
const body = normalizeMessage(messageBody);
|
|
199
452
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
453
|
+
// Major
|
|
454
|
+
if (/\bBREAKING[- _]CHANGE(?:\([^)]+\))?\s*:?/i.test(body)) {
|
|
455
|
+
return "major";
|
|
456
|
+
}
|
|
204
457
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
458
|
+
// Minor
|
|
459
|
+
if (/(^|\n)\s*(\*?\s*)?feat(?:\([^)]+\))?\s*:?/i.test(body)) {
|
|
460
|
+
return "minor";
|
|
461
|
+
}
|
|
209
462
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
463
|
+
// Patch
|
|
464
|
+
if (/(^|\n)\s*(\*?\s*)?(fix|perf)(?:\([^)]+\))?\s*:?/i.test(body)) {
|
|
465
|
+
return "patch";
|
|
466
|
+
}
|
|
214
467
|
|
|
215
|
-
|
|
468
|
+
return null;
|
|
216
469
|
}
|
|
217
470
|
|
|
218
471
|
// Given many commits, collapse to a single bump level
|
|
219
472
|
function collapseBumps(levels) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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;
|
|
230
483
|
}
|
|
231
484
|
|
|
232
485
|
// Fetch full commit messages (%B) for SHAs and compute bump
|
|
233
486
|
async function computeSemanticBumpForCommits(hashes, gitRawFn) {
|
|
234
|
-
|
|
235
|
-
|
|
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);
|
|
236
497
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (level) {
|
|
243
|
-
levels.push(level)
|
|
244
|
-
}
|
|
245
|
-
if (level === 'major') {
|
|
246
|
-
break
|
|
247
|
-
} // early exit if major is found
|
|
248
|
-
}
|
|
249
|
-
return collapseBumps(levels)
|
|
498
|
+
if (level === "major") {
|
|
499
|
+
break;
|
|
500
|
+
} // early exit if major is found
|
|
501
|
+
}
|
|
502
|
+
return collapseBumps(levels);
|
|
250
503
|
}
|
|
251
504
|
async function main() {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const currentBranch = (await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD'])) || 'HEAD'
|
|
259
|
-
|
|
260
|
-
log(chalk.gray(`Comparing subjects since ${argv.since}`))
|
|
261
|
-
log(chalk.gray(`Dev: ${argv.dev}`))
|
|
262
|
-
log(chalk.gray(`Main: ${argv.main}`))
|
|
263
|
-
|
|
264
|
-
const [devCommits, mainSubjects] = await Promise.all([getDevCommits(argv.dev, argv.since), getSubjects(argv.main)])
|
|
265
|
-
|
|
266
|
-
const missing = filterMissing(devCommits, mainSubjects)
|
|
267
|
-
|
|
268
|
-
if (missing.length === 0) {
|
|
269
|
-
log(chalk.green('✅ No missing commits found in the selected window.'))
|
|
270
|
-
return
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const indexByHash = new Map(missing.map((c, i) => [c.hash, i])) // 0=newest, larger=older
|
|
274
|
-
|
|
275
|
-
let selected
|
|
276
|
-
if (argv['all-yes']) {
|
|
277
|
-
selected = missing.map((m) => m.hash)
|
|
278
|
-
} else {
|
|
279
|
-
selected = await selectCommitsInteractive(missing)
|
|
280
|
-
if (!selected.length) {
|
|
281
|
-
log(chalk.yellow('No commits selected. Exiting.'))
|
|
282
|
-
return
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a))
|
|
287
|
-
|
|
288
|
-
if (argv.dry_run || argv['dry-run']) {
|
|
289
|
-
log(chalk.cyan('\n--dry-run: would cherry-pick (oldest → newest):'))
|
|
290
|
-
for (const h of bottomToTop) {
|
|
291
|
-
const subj = await gitRaw(['show', '--format=%s', '-s', h])
|
|
292
|
-
log(`- ${chalk.dim(`(${h.slice(0, 7)})`)} ${subj}`)
|
|
293
|
-
}
|
|
294
|
-
return
|
|
295
|
-
}
|
|
505
|
+
try {
|
|
506
|
+
if (!argv["no-fetch"]) {
|
|
507
|
+
log(chalk.gray("Fetching remotes (git fetch --prune)..."));
|
|
508
|
+
await git.fetch(["--prune"]);
|
|
509
|
+
}
|
|
296
510
|
|
|
297
|
-
|
|
298
|
-
const currentVersionFromPkg = await getPkgVersion(argv['version-file'])
|
|
299
|
-
argv['current-version'] = currentVersionFromPkg
|
|
300
|
-
}
|
|
511
|
+
const currentBranch = (await gitRaw(["rev-parse", "--abbrev-ref", "HEAD"])) || "HEAD";
|
|
301
512
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
throw new Error(' --semantic-versioning requires --current-version X.Y.Z (or pass --version-file)')
|
|
306
|
-
}
|
|
513
|
+
log(chalk.gray(`Comparing subjects since ${argv.since}`));
|
|
514
|
+
log(chalk.gray(`Dev: ${argv.dev}`));
|
|
515
|
+
log(chalk.gray(`Main: ${argv.main}`));
|
|
307
516
|
|
|
308
|
-
|
|
309
|
-
|
|
517
|
+
const [devCommits, mainSubjects] = await Promise.all([
|
|
518
|
+
getDevCommits(argv.dev, argv.since),
|
|
519
|
+
getSubjects(argv.main),
|
|
520
|
+
]);
|
|
310
521
|
|
|
311
|
-
|
|
522
|
+
const missing = filterMissing(devCommits, mainSubjects);
|
|
312
523
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
`Detected bump: ${chalk.bold(bump || 'none')} ` +
|
|
318
|
-
`Next: ${chalk.bold(computedNextVersion)}`
|
|
319
|
-
)
|
|
320
|
-
}
|
|
524
|
+
if (missing.length === 0) {
|
|
525
|
+
log(chalk.green("✅ No missing commits found in the selected window."));
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
321
528
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
}
|
|
329
541
|
|
|
330
|
-
|
|
331
|
-
await ensureBranchDoesNotExistLocally(releaseBranch)
|
|
332
|
-
const startPoint = argv.main // e.g., 'origin/main' or a local ref
|
|
542
|
+
const bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a));
|
|
333
543
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
+
}
|
|
339
552
|
|
|
340
|
-
|
|
341
|
-
|
|
553
|
+
if (argv["version-file"] && !argv["current-version"]) {
|
|
554
|
+
const currentVersionFromPkg = await getPkgVersion(argv["version-file"]);
|
|
555
|
+
argv["current-version"] = currentVersionFromPkg;
|
|
556
|
+
}
|
|
342
557
|
|
|
343
|
-
|
|
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
|
+
}
|
|
344
581
|
|
|
345
|
-
|
|
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
|
+
}
|
|
346
614
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
}
|
|
352
635
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
await runGh(ghArgs)
|
|
394
|
-
log(chalk.gray(`Pushed ${onBranch} with version bump.`))
|
|
395
|
-
}
|
|
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
|
+
}
|
|
396
675
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
676
|
+
const finalBranch = argv["create-release"]
|
|
677
|
+
? await gitRaw(["rev-parse", "--abbrev-ref", "HEAD"]) // should be release/*
|
|
678
|
+
: currentBranch;
|
|
400
679
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
+
}
|
|
406
685
|
}
|
|
407
686
|
|
|
408
|
-
main()
|
|
687
|
+
main();
|
|
409
688
|
|
|
410
689
|
/**
|
|
411
690
|
* Utils
|
|
412
691
|
*/
|
|
413
692
|
|
|
414
693
|
async function ensureBranchDoesNotExistLocally(branchName) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
}
|
|
421
701
|
}
|
|
422
702
|
|
|
423
703
|
async function buildChangelogBody({ version, hashes, gitRawFn }) {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
const sections = []
|
|
456
|
-
if (breakings.length) {
|
|
457
|
-
sections.push(`### ✨ Breaking Changes\n${breakings.join('\n')}`)
|
|
458
|
-
}
|
|
459
|
-
if (features.length) {
|
|
460
|
-
sections.push(`### ✨ Features\n${features.join('\n')}`)
|
|
461
|
-
}
|
|
462
|
-
if (fixes.length) {
|
|
463
|
-
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;
|
|
464
732
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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`;
|
|
470
750
|
}
|
|
471
751
|
function shortSha(sha) {
|
|
472
|
-
|
|
752
|
+
return String(sha).slice(0, 7);
|
|
473
753
|
}
|
|
474
754
|
|
|
475
755
|
function stripOrigin(ref) {
|
|
476
|
-
|
|
756
|
+
return ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
|
|
477
757
|
}
|
|
478
758
|
|
|
479
759
|
async function runGh(args) {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
+
});
|
|
485
765
|
}
|
|
486
766
|
async function readJson(filePath) {
|
|
487
|
-
|
|
488
|
-
|
|
767
|
+
const raw = await fsPromises.readFile(filePath, "utf8");
|
|
768
|
+
return JSON.parse(raw);
|
|
489
769
|
}
|
|
490
770
|
|
|
491
771
|
async function writeJson(filePath, data) {
|
|
492
|
-
|
|
493
|
-
|
|
772
|
+
const text = JSON.stringify(data, null, 2) + "\n";
|
|
773
|
+
await fsPromises.writeFile(filePath, text, "utf8");
|
|
494
774
|
}
|
|
495
775
|
|
|
496
776
|
/** Read package.json version; throw if missing */
|
|
497
777
|
async function getPkgVersion(pkgPath) {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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;
|
|
504
784
|
}
|
|
505
785
|
|
|
506
786
|
/** Update package.json version in-place */
|
|
507
787
|
async function setPkgVersion(pkgPath, nextVersion) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
788
|
+
const pkg = await readJson(pkgPath);
|
|
789
|
+
pkg.version = nextVersion;
|
|
790
|
+
await writeJson(pkgPath, pkg);
|
|
511
791
|
}
|