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.
Files changed (4) hide show
  1. package/biome.json +23 -0
  2. package/cli.backup.js +163 -156
  3. package/cli.js +652 -608
  4. package/package.json +14 -11
package/cli.js CHANGED
@@ -1,388 +1,416 @@
1
1
  #!/usr/bin/env node
2
- import chalk from 'chalk'
3
- import { promises as fsPromises } from '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
-
9
- import { hideBin } from 'yargs/helpers'
10
-
11
- const git = simpleGit()
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
- .scriptName('cherrypick-interactive')
15
- .usage('$0 [options]')
16
- .option('dev', {
17
- type: 'string',
18
- default: 'origin/dev',
19
- describe: 'Source branch (contains commits you want).'
20
- })
21
- .option('main', {
22
- type: 'string',
23
- default: 'origin/main',
24
- describe: 'Comparison branch (commits present here will be filtered out).'
25
- })
26
- .option('since', {
27
- type: 'string',
28
- default: '1 week ago',
29
- describe: 'Time window passed to git --since (e.g. "2 weeks ago", "1 month ago").'
30
- })
31
- .option('no-fetch', {
32
- type: 'boolean',
33
- default: false,
34
- describe: "Skip 'git fetch --prune'."
35
- })
36
- .option('all-yes', {
37
- type: 'boolean',
38
- default: false,
39
- describe: 'Non-interactive: cherry-pick ALL missing commits (oldest → newest).'
40
- })
41
- .option('dry-run', {
42
- type: 'boolean',
43
- default: false,
44
- describe: 'Print what would be cherry-picked and exit.'
45
- })
46
- .option('semantic-versioning', {
47
- type: 'boolean',
48
- default: true,
49
- describe: 'Compute next semantic version from selected (or missing) commits.'
50
- })
51
- .option('current-version', {
52
- type: 'string',
53
- describe: 'Current version (X.Y.Z). Required when --semantic-versioning is set.'
54
- })
55
- .option('create-release', {
56
- type: 'boolean',
57
- default: true,
58
- describe: 'Create a release branch from --main named release/<computed-version> before cherry-picking.'
59
- })
60
- .option('push-release', {
61
- type: 'boolean',
62
- default: true,
63
- describe: 'After creating the release branch, push and set upstream (origin).'
64
- })
65
- .option('draft-pr', {
66
- type: 'boolean',
67
- default: false,
68
- describe: 'Create the release PR as a draft.'
69
- })
70
- .option('version-file', {
71
- type: 'string',
72
- default: './package.json',
73
- describe: 'Path to package.json (read current version; optional replacement for --current-version)'
74
- })
75
- .option('version-commit-message', {
76
- type: 'string',
77
- default: 'chore(release): bump version to {{version}}',
78
- describe: 'Commit message template for version bump. Use {{version}} placeholder.'
79
- })
80
- .help()
81
- .alias('h', 'help')
82
- .alias('v', 'version').argv
83
-
84
- const log = (...a) => console.log(...a)
85
- const err = (...a) => console.error(...a)
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
- const out = await git.raw(args)
89
- return out.trim()
113
+ const out = await git.raw(args);
114
+ return out.trim();
90
115
  }
91
116
 
92
117
  async function getSubjects(branch) {
93
- const out = await gitRaw(['log', '--no-merges', '--pretty=%s', branch])
94
- if (!out) {
95
- return new Set()
96
- }
97
- return new Set(out.split('\n').filter(Boolean))
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
- const out = await gitRaw(['log', '--no-merges', '--since=' + since, '--pretty=%H %s', branch])
102
-
103
- if (!out) {
104
- return []
105
- }
106
- return out.split('\n').map((line) => {
107
- const firstSpace = line.indexOf(' ')
108
- const hash = line.slice(0, firstSpace)
109
- const subject = line.slice(firstSpace + 1)
110
- return { hash, subject }
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
- return devCommits.filter(({ subject }) => !mainSubjects.has(subject))
140
+ return devCommits.filter(({ subject }) => !mainSubjects.has(subject));
116
141
  }
117
142
 
118
143
  async function selectCommitsInteractive(missing) {
119
- const choices = [
120
- new inquirer.Separator(chalk.gray('── Newest commits ──')),
121
- ...missing.map(({ hash, subject }, idx) => {
122
- // display-only trim to avoid accidental leading spaces
123
- const displaySubject = subject.replace(/^[\s\u00A0]+/, '')
124
- return {
125
- name: `${chalk.dim(`(${hash.slice(0, 7)})`)} ${displaySubject}`,
126
- value: hash,
127
- short: displaySubject,
128
- idx // we keep index for oldest→newest ordering later
129
- }
130
- }),
131
- new inquirer.Separator(chalk.gray('── Oldest commits ──'))
132
- ]
133
- const termHeight = process.stdout.rows || 24 // fallback for non-TTY environments
134
-
135
- const { selected } = await inquirer.prompt([
136
- {
137
- type: 'checkbox',
138
- name: 'selected',
139
- message: `Select commits to cherry-pick (${missing.length} missing):`,
140
- choices,
141
- pageSize: Math.max(10, Math.min(termHeight - 5, missing.length))
142
- }
143
- ])
144
-
145
- return selected
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
- while (true) {
150
- err(chalk.red(`\n✖ Cherry-pick has conflicts on ${hash} (${hash.slice(0, 7)}).`))
151
- await showConflictsList() // prints conflicted files (if any)
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
- if (action === 'abort') {
173
- await gitRaw(['cherry-pick', '--abort'])
174
- throw new Error('Cherry-pick aborted by user.')
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
- const res = await conflictsResolutionWizard(hash)
178
- if (res === 'continued') {
179
- // Successfully continued; this commit is now applied
180
- return 'continued'
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
- const out = await gitRaw(['diff', '--name-only', '--diff-filter=U'])
187
- return out ? out.split('\n').filter(Boolean) : []
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
- const files = await getConflictedFiles()
192
- return files.length === 0
216
+ const files = await getConflictedFiles();
217
+ return files.length === 0;
193
218
  }
194
219
 
195
220
  async function runBin(bin, args) {
196
- return new Promise((resolve, reject) => {
197
- const p = spawn(bin, args, { stdio: 'inherit' })
198
- p.on('error', reject)
199
- p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${bin} exited ${code}`))))
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
- const files = await getConflictedFiles()
205
-
206
- if (!files.length) {
207
- log(chalk.green('No conflicted files reported by git.'))
208
- return []
209
- }
210
- err(chalk.yellow('Conflicted files:'))
211
- for (const f of files) {
212
- err(' - ' + f)
213
- }
214
- return files
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
- const { action } = await inquirer.prompt([
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
- type: 'list',
221
- name: 'action',
222
- message: `How to resolve "${file}"?`,
223
- choices: [
224
- { name: 'Use ours (current branch)', value: 'ours' },
225
- { name: 'Use theirs (picked commit)', value: 'theirs' },
226
- { name: 'Open in editor', value: 'edit' },
227
- { name: 'Show diff', value: 'diff' },
228
- { name: 'Mark resolved (stage file)', value: 'stage' },
229
- { name: 'Back', value: 'back' }
230
- ]
231
- }
232
- ])
233
-
234
- try {
235
- if (action === 'ours') {
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
- return action
296
+ return action;
272
297
  }
273
298
 
274
299
  async function conflictsResolutionWizard(hash) {
275
- // Loop until no conflicts remain and continue succeeds
276
- while (true) {
277
- const files = await showConflictsList()
278
- if (files.length === 0) {
279
- try {
280
- await gitRaw(['cherry-pick', '--continue'])
281
- const subject = await gitRaw(['show', '--format=%s', '-s', hash])
282
- log(`${chalk.green('')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`)
283
- return 'continued'
284
- } catch (e) {
285
- err(chalk.red('`git cherry-pick --continue` failed:'))
286
- err(String(e.message || e))
287
- // fall back to loop
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
- const { choice } = await inquirer.prompt([
292
- {
293
- type: 'list',
294
- name: 'choice',
295
- message: 'Select a file to resolve or a global action:',
296
- pageSize: Math.min(20, Math.max(8, files.length + 5)),
297
- choices: [
298
- ...files.map((f) => ({ name: f, value: { type: 'file', file: f } })),
299
- new inquirer.Separator(chalk.gray('─ Actions ─')),
300
- { name: 'Use ours for ALL', value: { type: 'all', action: 'ours-all' } },
301
- { name: 'Use theirs for ALL', value: { type: 'all', action: 'theirs-all' } },
302
- { name: 'Stage ALL', value: { type: 'all', action: 'stage-all' } },
303
- { name: 'Launch mergetool (all)', value: { type: 'all', action: 'mergetool-all' } },
304
- { name: 'Try to continue (run --continue)', value: { type: 'global', action: 'continue' } },
305
- { name: 'Back to main conflict menu', value: { type: 'global', action: 'back' } }
306
- ]
307
- }
308
- ])
309
-
310
- if (!choice) {
311
- continue
312
- }
313
- if (choice.type === 'file') {
314
- await resolveSingleFileWizard(choice.file)
315
- continue
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
- if (choice.type === 'all') {
319
- for (const f of files) {
320
- if (choice.action === 'ours-all') {
321
- await gitRaw(['checkout', '--ours', f])
322
- await git.add([f])
323
- } else if (choice.action === 'theirs-all') {
324
- await gitRaw(['checkout', '--theirs', f])
325
- await git.add([f])
326
- } else if (choice.action === 'stage-all') {
327
- await git.add([f])
328
- } else if (choice.action === 'mergetool-all') {
329
- await runBin('git', ['mergetool'])
330
- break // mergetool all opens sequentially; re-loop to re-check state
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
- if (choice.type === 'global' && choice.action === 'continue') {
337
- if (await assertNoUnmerged()) {
338
- try {
339
- await gitRaw(['cherry-pick', '--continue'])
340
- const subject = await gitRaw(['show', '--format=%s', '-s', hash])
341
- log(`${chalk.green('')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`)
342
- return 'continued'
343
- } catch (e) {
344
- err(chalk.red('`--continue` failed. Resolve remaining issues and try again.'))
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
- if (choice.type === 'global' && choice.action === 'back') {
352
- return 'back'
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
- const result = { applied: 0, skipped: 0 }
386
+ const result = { applied: 0, skipped: 0 };
359
387
 
360
- for (const hash of hashes) {
361
- try {
362
- await gitRaw(['cherry-pick', hash])
363
- const subject = await gitRaw(['show', '--format=%s', '-s', hash])
364
- log(`${chalk.green('')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`)
365
- result.applied += 1
366
- } catch (e) {
367
- try {
368
- const action = await handleCherryPickConflict(hash)
369
- if (action === 'skipped') {
370
- result.skipped += 1
371
- continue
372
- }
373
- if (action === 'continued') {
374
- // --continue başarıyla commit oluşturdu
375
- result.applied += 1
376
- continue
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
- return result
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
- const m = String(v || '')
394
- .trim()
395
- .match(/^(\d+)\.(\d+)\.(\d+)$/)
396
- if (!m) {
397
- throw new Error(`Invalid --current-version "${v}". Expected X.Y.Z`)
398
- }
399
- return { major: +m[1], minor: +m[2], patch: +m[3] }
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
- const cur = parseVersion(version)
404
- if (bump === 'major') {
405
- return `${cur.major + 1}.0.0`
406
- }
407
- if (bump === 'minor') {
408
- return `${cur.major}.${cur.minor + 1}.0`
409
- }
410
- if (bump === 'patch') {
411
- return `${cur.major}.${cur.minor}.${cur.patch + 1}`
412
- }
413
- return `${cur.major}.${cur.minor}.${cur.patch}`
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
- // normalize whitespace; keep case-insensitive matching
418
- return (msg || '').replace(/\r\n/g, '\n')
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
- const body = normalizeMessage(messageBody)
451
+ const body = normalizeMessage(messageBody);
424
452
 
425
- // Major
426
- if (/\bBREAKING[- _]CHANGE(?:\([^)]+\))?\s*:?/i.test(body)) {
427
- return 'major'
428
- }
453
+ // Major
454
+ if (/\bBREAKING[- _]CHANGE(?:\([^)]+\))?\s*:?/i.test(body)) {
455
+ return "major";
456
+ }
429
457
 
430
- // Minor
431
- if (/(^|\n)\s*(\*?\s*)?feat(?:\([^)]+\))?\s*:?/i.test(body)) {
432
- return 'minor'
433
- }
458
+ // Minor
459
+ if (/(^|\n)\s*(\*?\s*)?feat(?:\([^)]+\))?\s*:?/i.test(body)) {
460
+ return "minor";
461
+ }
434
462
 
435
- // Patch
436
- if (/(^|\n)\s*(\*?\s*)?(fix|perf)(?:\([^)]+\))?\s*:?/i.test(body)) {
437
- return 'patch'
438
- }
463
+ // Patch
464
+ if (/(^|\n)\s*(\*?\s*)?(fix|perf)(?:\([^)]+\))?\s*:?/i.test(body)) {
465
+ return "patch";
466
+ }
439
467
 
440
- return null
468
+ return null;
441
469
  }
442
470
 
443
471
  // Given many commits, collapse to a single bump level
444
472
  function collapseBumps(levels) {
445
- if (levels.includes('major')) {
446
- return 'major'
447
- }
448
- if (levels.includes('minor')) {
449
- return 'minor'
450
- }
451
- if (levels.includes('patch')) {
452
- return 'patch'
453
- }
454
- return null
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
- if (!hashes.length) {
460
- return null
461
- }
462
-
463
- const levels = []
464
- for (const h of hashes) {
465
- const msg = await gitRawFn(['show', '--format=%B', '-s', h])
466
- const level = classifySingleCommit(msg)
467
- if (level) {
468
- levels.push(level)
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
- return collapseBumps(levels)
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
- try {
478
- if (!argv['no-fetch']) {
479
- log(chalk.gray('Fetching remotes (git fetch --prune)...'))
480
- await git.fetch(['--prune'])
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
- log('')
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
- if (argv['create-release']) {
548
- if (!argv['semantic-versioning'] || !argv['current-version']) {
549
- throw new Error(' --create-release requires --semantic-versioning and --current-version X.Y.Z')
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
- const releaseBranch = `release/${computedNextVersion}`
556
- await ensureBranchDoesNotExistLocally(releaseBranch)
557
- const startPoint = argv.main // e.g., 'origin/main' or a local ref
517
+ const [devCommits, mainSubjects] = await Promise.all([
518
+ getDevCommits(argv.dev, argv.since),
519
+ getSubjects(argv.main),
520
+ ]);
558
521
 
559
- const changelogBody = await buildChangelogBody({
560
- version: computedNextVersion,
561
- hashes: bottomToTop,
562
- gitRawFn: gitRaw
563
- })
522
+ const missing = filterMissing(devCommits, mainSubjects);
564
523
 
565
- await fsPromises.writeFile('RELEASE_CHANGELOG.md', changelogBody, 'utf8')
566
- log(chalk.gray(`✅ Generated changelog for ${releaseBranch} RELEASE_CHANGELOG.md`))
524
+ if (missing.length === 0) {
525
+ log(chalk.green("✅ No missing commits found in the selected window."));
526
+ return;
527
+ }
567
528
 
568
- log(chalk.cyan(`\nCreating ${chalk.bold(releaseBranch)} from ${chalk.bold(startPoint)}...`))
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
- await git.checkoutBranch(releaseBranch, startPoint)
542
+ const bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a));
571
543
 
572
- log(chalk.green(`✓ Ready on ${chalk.bold(releaseBranch)}. Cherry-picking will apply here.`))
573
- } else {
574
- // otherwise we stay on the current branch
575
- log(chalk.bold(`Base branch: ${currentBranch}`))
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
- log(chalk.cyan(`\nCherry-picking ${bottomToTop.length} commit(s) onto ${currentBranch} (oldest → newest)...\n`))
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
- const stats = await cherryPickSequential(bottomToTop)
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
- log(chalk.gray(`\nSummary → applied: ${stats.applied}, skipped: ${stats.skipped}`))
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
- if (stats.applied === 0) {
585
- err(chalk.yellow('\nNo commits were cherry-picked (all were skipped or unresolved). Aborting.'))
586
- // Abort any leftover state just in case
587
- try {
588
- await gitRaw(['cherry-pick', '--abort'])
589
- } catch {}
590
- throw new Error('Nothing cherry-picked')
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
- if (argv['push-release']) {
594
- const baseBranchForGh = stripOrigin(argv.main) // 'origin/main' -> 'main'
595
- const prTitle = `Release ${computedNextVersion}`
596
- const releaseBranch = `release/${computedNextVersion}`
597
-
598
- const onBranch = await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD'])
599
- if (!onBranch.startsWith(releaseBranch)) {
600
- throw new Error(`Version update should happen on a release branch. Current: ${onBranch}`)
601
- }
602
-
603
- log(chalk.cyan(`\nUpdating ${argv['version-file']} version → ${computedNextVersion} ...`))
604
- await setPkgVersion(argv['version-file'], computedNextVersion)
605
- await git.add([argv['version-file']])
606
- const msg = argv['version-commit-message'].replace('{{version}}', computedNextVersion)
607
- await git.raw(['commit', '--no-verify', '-m', msg])
608
-
609
- log(chalk.green(`✓ package.json updated and committed: ${msg}`))
610
-
611
- await git.push(['-u', 'origin', releaseBranch, '--no-verify'])
612
-
613
- const ghArgs = [
614
- 'pr',
615
- 'create',
616
- '--base',
617
- baseBranchForGh,
618
- '--head',
619
- releaseBranch,
620
- '--title',
621
- prTitle,
622
- '--body-file',
623
- 'RELEASE_CHANGELOG.md'
624
- ]
625
- if (argv['draft-pr']) {
626
- ghArgs.push('--draft')
627
- }
628
-
629
- await runGh(ghArgs)
630
- log(chalk.gray(`Pushed ${onBranch} with version bump.`))
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
- const finalBranch = argv['create-release']
634
- ? await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD']) // should be release/*
635
- : currentBranch
676
+ const finalBranch = argv["create-release"]
677
+ ? await gitRaw(["rev-parse", "--abbrev-ref", "HEAD"]) // should be release/*
678
+ : currentBranch;
636
679
 
637
- log(chalk.green(`\n✅ Done on ${finalBranch}`))
638
- } catch (e) {
639
- err(chalk.red(`\n❌ Error: ${e.message || e}`))
640
- process.exit(1)
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
- const branches = await git.branchLocal()
652
- if (branches.all.includes(branchName)) {
653
- throw new Error(
654
- `Release branch "${branchName}" already exists locally. ` + `Please delete it or choose a different version.`
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
- const today = new Date().toISOString().slice(0, 10)
661
- const header = version ? `## Release ${version} — ${today}` : `## Release — ${today}`
662
-
663
- const breakings = []
664
- const features = []
665
- const fixes = []
666
- const others = []
667
-
668
- for (const h of hashes) {
669
- const msg = await gitRawFn(['show', '--format=%B', '-s', h])
670
- const level = classifySingleCommit(msg)
671
-
672
- const subject = msg.split(/\r?\n/)[0].trim() // first line of commit message
673
- const shaDisplay = shortSha(h)
674
-
675
- switch (level) {
676
- case 'major':
677
- breakings.push(`${shaDisplay} ${subject}`)
678
- break
679
- case 'minor':
680
- features.push(`${shaDisplay} ${subject}`)
681
- break
682
- case 'patch':
683
- fixes.push(`${shaDisplay} ${subject}`)
684
- break
685
- default:
686
- others.push(`${shaDisplay} ${subject}`)
687
- break
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
- if (others.length) {
702
- sections.push(`### 🧹 Others\n${others.join('\n')}`)
703
- }
704
-
705
- return `${header}\n\n${sections.join('\n\n')}\n`
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
- return String(sha).slice(0, 7)
752
+ return String(sha).slice(0, 7);
709
753
  }
710
754
 
711
755
  function stripOrigin(ref) {
712
- return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref
756
+ return ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
713
757
  }
714
758
 
715
759
  async function runGh(args) {
716
- return new Promise((resolve, reject) => {
717
- const p = spawn('gh', args, { stdio: 'inherit' })
718
- p.on('error', reject)
719
- p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`gh exited ${code}`))))
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
- const raw = await fsPromises.readFile(filePath, 'utf8')
724
- return JSON.parse(raw)
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
- const text = JSON.stringify(data, null, 2) + '\n'
729
- await fsPromises.writeFile(filePath, text, 'utf8')
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
- const pkg = await readJson(pkgPath)
735
- const v = pkg && pkg.version
736
- if (!v) {
737
- throw new Error(`No "version" field found in ${pkgPath}`)
738
- }
739
- return v
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
- const pkg = await readJson(pkgPath)
745
- pkg.version = nextVersion
746
- await writeJson(pkgPath, pkg)
788
+ const pkg = await readJson(pkgPath);
789
+ pkg.version = nextVersion;
790
+ await writeJson(pkgPath, pkg);
747
791
  }