cherrypick-interactive 1.0.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/.yarnrc.yml ADDED
@@ -0,0 +1 @@
1
+ nodeLinker: node-modules
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sulhadin Γ–ney
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # πŸͺΆ cherrypick-interactive
2
+
3
+ ### Cherry-pick missing commits from `dev` to `main` β€” interactively and safely.
4
+
5
+ ---
6
+
7
+ ## 🚧 Motivation
8
+
9
+ When you maintain long-lived branches like `dev` and `main`, keeping them in sync can get messy.
10
+ Sometimes you rebase, sometimes you cherry-pick, sometimes you merge release branches β€” and every time, it’s easy to lose track of which commits actually made it into production.
11
+
12
+ **This CLI solves that pain point:**
13
+
14
+ - It compares two branches (e.g. `origin/dev` vs `origin/main`)
15
+ - Lists commits in `dev` that are *not yet* in `main`
16
+ - Lets you choose which ones to cherry-pick interactively
17
+ - (Optionally) bumps your semantic version, creates a release branch, updates `package.json`, and opens a GitHub draft PR for review
18
+
19
+ No manual `git log` diffing. No risky merges. No guesswork.
20
+
21
+ ---
22
+
23
+ ## 🧭 What it does
24
+
25
+ - πŸ” Finds commits in `dev` not present in `main`
26
+ - πŸ—‚οΈ Lets you select which commits to cherry-pick (or pick all)
27
+ - πŸͺœ Cherry-picks in the correct order (oldest β†’ newest)
28
+ - πŸͺ„ Detects **semantic version bump** (`major`, `minor`, `patch`) from conventional commits
29
+ - 🧩 Creates a `release/x.y.z` branch from `main`
30
+ - 🧾 Generates a Markdown changelog from commits
31
+ - 🧰 Optionally:
32
+ - updates `package.json` version
33
+ - commits and pushes it
34
+ - opens a **GitHub PR** (draft or normal)
35
+
36
+ ---
37
+
38
+ ## πŸ“¦ Installation
39
+
40
+ ```bash
41
+ npm install -g cherrypick-interactive
42
+ ```
43
+
44
+ (You can also run it directly without installing globally using `npx`.)
45
+
46
+ ---
47
+
48
+ ## πŸš€ Quick Start
49
+
50
+ ```bash
51
+ cherrypick-interactive --semantic-versioning --version-file ./package.json --create-release --push-release --draft-pr
52
+ ```
53
+
54
+ βœ… This will:
55
+ 1. Fetch `origin/dev` and `origin/main`
56
+ 2. List commits in `dev` missing from `main`
57
+ 3. Let you select which to cherry-pick
58
+ 4. Compute the next version from commit messages
59
+ 5. Create `release/<next-version>` from `main`
60
+ 6. Cherry-pick the selected commits
61
+ 7. Update your `package.json` version and commit it
62
+ 8. Push the branch and open a **draft PR** on GitHub
63
+
64
+ ---
65
+
66
+ ## 🧩 Common Use Cases
67
+
68
+ ### 1. Compare branches manually
69
+
70
+ ```bash
71
+ cherrypick-interactive
72
+ ```
73
+
74
+ Lists commits in `origin/dev` that aren’t in `origin/main`, filtered by the last week.
75
+
76
+ ### 2. Cherry-pick all missing commits automatically
77
+
78
+ ```bash
79
+ cherrypick-interactive --all-yes
80
+ ```
81
+
82
+ ### 3. Preview changes without applying them
83
+
84
+ ```bash
85
+ cherrypick-interactive --dry-run
86
+ ```
87
+
88
+ ---
89
+
90
+ ## βš™οΈ Options
91
+
92
+ | Flag | Description | Default |
93
+ |------|--------------|----------|
94
+ | `--dev` | Source branch (commits to copy) | `origin/dev` |
95
+ | `--main` | Target branch (commits already merged here will be skipped) | `origin/main` |
96
+ | `--since` | Git time window filter (e.g. `"2 weeks ago"`) | `1 week ago` |
97
+ | `--no-fetch` | Skip `git fetch --prune` | `false` |
98
+ | `--all-yes` | Cherry-pick all missing commits without prompt | `false` |
99
+ | `--dry-run` | Show what would happen without applying changes | `false` |
100
+ | `--semantic-versioning` | Detect semantic version bump from commits | `false` |
101
+ | `--current-version` | Current version (if not reading from file) | β€” |
102
+ | `--version-file` | Path to `package.json` (to read & update version) | β€” |
103
+ | `--create-release` | Create `release/x.y.z` branch from `main` | `false` |
104
+ | `--push-release` | Push release branch to origin | `true` |
105
+ | `--draft-pr` | Create the GitHub PR as a draft | `false` |
106
+ | `--version-commit-message` | Template for version bump commit | `chore(release): bump version to {{version}}` |
107
+
108
+ ---
109
+
110
+ ## 🧠 How Semantic Versioning Works
111
+
112
+ The tool analyzes commit messages using **Conventional Commits**:
113
+
114
+ | Prefix | Example | Bump |
115
+ |---------|----------|------|
116
+ | `BREAKING CHANGE:` | `feat(auth): BREAKING CHANGE: require MFA` | **major** |
117
+ | `feat:` | `feat(ui): add dark mode` | **minor** |
118
+ | `fix:` / `perf:` | `fix(api): correct pagination offset` | **patch** |
119
+
120
+ ---
121
+
122
+ ## πŸͺ΅ Example Run
123
+
124
+ ```bash
125
+ $ cherrypick-interactive --semantic-versioning --version-file ./package.json --create-release --draft-pr
126
+
127
+ Fetching remotes (git fetch --prune)...
128
+ Comparing subjects since 1 week ago
129
+ Dev: origin/dev
130
+ Main: origin/main
131
+
132
+ Select commits to cherry-pick (3 missing):
133
+ ❯◯ (850aa02) fix: crypto withdrawal payload
134
+ β—― (2995cea) feat: OTC offer account validation
135
+ β—― (84fe310) chore: bump dependencies
136
+
137
+ Semantic Versioning
138
+ Current: 1.20.0 Detected bump: minor Next: 1.21.0
139
+
140
+ Creating release/1.21.0 from origin/main...
141
+ βœ“ Ready on release/1.21.0. Cherry-picking will apply here.
142
+ βœ“ package.json updated and committed: chore(release): bump version to 1.21.0
143
+ βœ… Pull request created for release/1.21.0 β†’ main
144
+ ```
145
+
146
+ ---
147
+
148
+ ## 🧹 Why This Helps
149
+
150
+ If your team:
151
+ - Rebases or cherry-picks from `dev` β†’ `main`
152
+ - Uses temporary release branches
153
+ - Tracks semantic versions via commits
154
+
155
+ …this CLI saves time and reduces errors.
156
+ It automates a tedious, error-prone manual process into a single command that behaves like `yarn upgrade-interactive`, but for Git commits.
157
+
158
+ ---
159
+
160
+ ## 🧰 Requirements
161
+
162
+ - Node.js β‰₯ 18
163
+ - GitHub CLI (`gh`) installed and authenticated
164
+ - A clean working directory (no uncommitted changes)
165
+
166
+ ---
167
+
168
+ ## 🧾 License
169
+
170
+ **MIT** β€” free to use, modify, and distribute.
171
+
172
+ ---
173
+
174
+ ## πŸ§‘β€πŸ’» Contributing
175
+
176
+ 1. Clone the repo
177
+ 2. Run locally:
178
+ ```bash
179
+ node cli.js --dry-run
180
+ ```
181
+ 3. Test edge cases before submitting PRs
182
+ 4. Please follow Conventional Commits for your changes.
183
+
184
+ ---
185
+
186
+ > Created to make release management simpler and safer for teams who value clean Git history and predictable deployments.
package/cli.backup.js ADDED
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+
3
+ import chalk from 'chalk'
4
+ import inquirer from 'inquirer'
5
+ import simpleGit from 'simple-git'
6
+ import yargs from 'yargs'
7
+
8
+ import { hideBin } from 'yargs/helpers'
9
+
10
+ const git = simpleGit()
11
+
12
+ const argv = yargs(hideBin(process.argv))
13
+ .scriptName('cherrypick-interactive')
14
+ .usage('$0 [options]')
15
+ .option('dev', {
16
+ type: 'string',
17
+ default: 'origin/dev',
18
+ describe: 'Source branch (contains commits you want).'
19
+ })
20
+ .option('main', {
21
+ type: 'string',
22
+ default: 'origin/main',
23
+ describe: 'Comparison branch (commits present here will be filtered out).'
24
+ })
25
+ .option('since', {
26
+ type: 'string',
27
+ default: '1 week ago',
28
+ describe: 'Time window passed to git --since (e.g. "2 weeks ago", "1 month ago").'
29
+ })
30
+ .option('no-fetch', {
31
+ type: 'boolean',
32
+ default: false,
33
+ describe: "Skip 'git fetch --prune'."
34
+ })
35
+ .option('all-yes', {
36
+ type: 'boolean',
37
+ default: false,
38
+ describe: 'Non-interactive: cherry-pick ALL missing commits (oldest β†’ newest).'
39
+ })
40
+ .option('dry-run', {
41
+ type: 'boolean',
42
+ default: false,
43
+ describe: 'Print what would be cherry-picked and exit.'
44
+ })
45
+ .help()
46
+ .alias('h', 'help')
47
+ .alias('v', 'version').argv
48
+
49
+ const log = (...a) => console.log(...a)
50
+ const err = (...a) => console.error(...a)
51
+
52
+ async function gitRaw(args) {
53
+ const out = await git.raw(args)
54
+ return out.trim()
55
+ }
56
+
57
+ async function getSubjects(branch) {
58
+ const out = await gitRaw(['log', '--no-merges', '--pretty=%s', branch])
59
+ if (!out) {
60
+ return new Set()
61
+ }
62
+ return new Set(out.split('\n').filter(Boolean))
63
+ }
64
+
65
+ async function getDevCommits(branch, since) {
66
+ const out = await gitRaw(['log', '--no-merges', '--since=' + since, '--pretty=%H %s', branch])
67
+
68
+ if (!out) {
69
+ return []
70
+ }
71
+ return out.split('\n').map((line) => {
72
+ const firstSpace = line.indexOf(' ')
73
+ const hash = line.slice(0, firstSpace)
74
+ const subject = line.slice(firstSpace + 1)
75
+ return { hash, subject }
76
+ })
77
+ }
78
+
79
+ function filterMissing(devCommits, mainSubjects) {
80
+ return devCommits.filter(({ subject }) => !mainSubjects.has(subject))
81
+ }
82
+
83
+ async function selectCommitsInteractive(missing) {
84
+ const choices = [
85
+ new inquirer.Separator(chalk.gray('── Newest commits ──')),
86
+ ...missing.map(({ hash, subject }, idx) => {
87
+ // display-only trim to avoid accidental leading spaces
88
+ const displaySubject = subject.replace(/^[\s\u00A0]+/, '')
89
+ return {
90
+ name: `${chalk.dim(`(${hash.slice(0, 7)})`)} ${displaySubject}`,
91
+ value: hash,
92
+ short: displaySubject,
93
+ idx // we keep index for oldest→newest ordering later
94
+ }
95
+ }),
96
+ new inquirer.Separator(chalk.gray('── Oldest commits ──'))
97
+ ]
98
+
99
+ const { selected } = await inquirer.prompt([
100
+ {
101
+ type: 'checkbox',
102
+ name: 'selected',
103
+ message: `Select commits to cherry-pick (${missing.length} missing):`,
104
+ choices,
105
+ pageSize: Math.min(20, Math.max(8, missing.length))
106
+ }
107
+ ])
108
+
109
+ return selected
110
+ }
111
+
112
+ async function cherryPickSequential(hashes) {
113
+ for (const hash of hashes) {
114
+ try {
115
+ await gitRaw(['cherry-pick', hash])
116
+ const subject = await gitRaw(['show', '--format=%s', '-s', hash])
117
+ log(`${chalk.green('βœ“')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`)
118
+ } catch (e) {
119
+ err(chalk.red(`βœ– Cherry-pick failed on ${hash}`))
120
+ err(chalk.yellow('Resolve conflicts, then run:'))
121
+ err(chalk.yellow(' git add -A && git cherry-pick --continue'))
122
+ err(chalk.yellow('Or abort:'))
123
+ err(chalk.yellow(' git cherry-pick --abort'))
124
+ throw e
125
+ }
126
+ }
127
+ }
128
+
129
+ async function main() {
130
+ try {
131
+ if (!argv['no-fetch']) {
132
+ log(chalk.gray('Fetching remotes (git fetch --prune)...'))
133
+ await git.fetch(['--prune'])
134
+ }
135
+
136
+ const currentBranch = (await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD'])) || 'HEAD'
137
+
138
+ log(chalk.gray(`Comparing subjects since ${argv.since}`))
139
+ log(chalk.gray(`Dev: ${argv.dev}`))
140
+ log(chalk.gray(`Main: ${argv.main}`))
141
+
142
+ const [devCommits, mainSubjects] = await Promise.all([getDevCommits(argv.dev, argv.since), getSubjects(argv.main)])
143
+
144
+ const missing = filterMissing(devCommits, mainSubjects)
145
+
146
+ if (missing.length === 0) {
147
+ log(chalk.green('βœ… No missing commits found in the selected window.'))
148
+ return
149
+ }
150
+
151
+ // Prepare bottom→top ordering support
152
+ const indexByHash = new Map(missing.map((c, i) => [c.hash, i])) // 0=newest, larger=older
153
+
154
+ let selected
155
+ if (argv.yes) {
156
+ selected = missing.map((m) => m.hash)
157
+ } else {
158
+ selected = await selectCommitsInteractive(missing)
159
+ if (!selected.length) {
160
+ log(chalk.yellow('No commits selected. Exiting.'))
161
+ return
162
+ }
163
+ }
164
+
165
+ // Bottom β†’ Top (oldest β†’ newest)
166
+ const bottomToTop = [...selected].sort((a, b) => indexByHash.get(b) - indexByHash.get(a))
167
+
168
+ if (argv.dry_run || argv['dry-run']) {
169
+ log(chalk.cyan('\n--dry-run: would cherry-pick (oldest β†’ newest):'))
170
+ for (const h of bottomToTop) {
171
+ const subj = await gitRaw(['show', '--format=%s', '-s', h])
172
+ log(`- ${chalk.dim(`(${h.slice(0, 7)})`)} ${subj}`)
173
+ }
174
+ return
175
+ }
176
+
177
+ log(chalk.cyan(`\nCherry-picking ${bottomToTop.length} commit(s) onto ${currentBranch} (oldest β†’ newest)...\n`))
178
+ await cherryPickSequential(bottomToTop)
179
+ log(chalk.green(`\nβœ… Done on ${currentBranch}`))
180
+ } catch (e) {
181
+ err(chalk.red(`\n❌ Error: ${e.message || e}`))
182
+ process.exit(1)
183
+ }
184
+ }
185
+
186
+ main()
package/cli.js ADDED
@@ -0,0 +1,511 @@
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()
12
+
13
+ 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: false,
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: false,
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
+ describe: 'Path to package.json (read current version; optional replacement for --current-version)'
73
+ })
74
+ .option('version-commit-message', {
75
+ type: 'string',
76
+ default: 'chore(release): bump version to {{version}}',
77
+ describe: 'Commit message template for version bump. Use {{version}} placeholder.'
78
+ })
79
+ .help()
80
+ .alias('h', 'help')
81
+ .alias('v', 'version').argv
82
+
83
+ const log = (...a) => console.log(...a)
84
+ const err = (...a) => console.error(...a)
85
+
86
+ async function gitRaw(args) {
87
+ const out = await git.raw(args)
88
+ return out.trim()
89
+ }
90
+
91
+ async function getSubjects(branch) {
92
+ const out = await gitRaw(['log', '--no-merges', '--pretty=%s', branch])
93
+ if (!out) {
94
+ return new Set()
95
+ }
96
+ return new Set(out.split('\n').filter(Boolean))
97
+ }
98
+
99
+ async function getDevCommits(branch, since) {
100
+ const out = await gitRaw(['log', '--no-merges', '--since=' + since, '--pretty=%H %s', branch])
101
+
102
+ if (!out) {
103
+ return []
104
+ }
105
+ return out.split('\n').map((line) => {
106
+ const firstSpace = line.indexOf(' ')
107
+ const hash = line.slice(0, firstSpace)
108
+ const subject = line.slice(firstSpace + 1)
109
+ return { hash, subject }
110
+ })
111
+ }
112
+
113
+ function filterMissing(devCommits, mainSubjects) {
114
+ return devCommits.filter(({ subject }) => !mainSubjects.has(subject))
115
+ }
116
+
117
+ async function selectCommitsInteractive(missing) {
118
+ const choices = [
119
+ new inquirer.Separator(chalk.gray('── Newest commits ──')),
120
+ ...missing.map(({ hash, subject }, idx) => {
121
+ // display-only trim to avoid accidental leading spaces
122
+ const displaySubject = subject.replace(/^[\s\u00A0]+/, '')
123
+ return {
124
+ name: `${chalk.dim(`(${hash.slice(0, 7)})`)} ${displaySubject}`,
125
+ value: hash,
126
+ short: displaySubject,
127
+ idx // we keep index for oldest→newest ordering later
128
+ }
129
+ }),
130
+ new inquirer.Separator(chalk.gray('── Oldest commits ──'))
131
+ ]
132
+
133
+ const { selected } = await inquirer.prompt([
134
+ {
135
+ type: 'checkbox',
136
+ name: 'selected',
137
+ message: `Select commits to cherry-pick (${missing.length} missing):`,
138
+ choices,
139
+ pageSize: Math.min(20, Math.max(8, missing.length))
140
+ }
141
+ ])
142
+
143
+ return selected
144
+ }
145
+
146
+ async function cherryPickSequential(hashes) {
147
+ for (const hash of hashes) {
148
+ try {
149
+ await gitRaw(['cherry-pick', hash])
150
+ const subject = await gitRaw(['show', '--format=%s', '-s', hash])
151
+ log(`${chalk.green('βœ“')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`)
152
+ } catch (e) {
153
+ err(chalk.red(`βœ– Cherry-pick failed on ${hash}`))
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
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Semantic version bumping
165
+ * @returns {Promise<void>}
166
+ */
167
+ function parseVersion(v) {
168
+ const m = String(v || '')
169
+ .trim()
170
+ .match(/^(\d+)\.(\d+)\.(\d+)$/)
171
+ if (!m) {
172
+ throw new Error(`Invalid --current-version "${v}". Expected X.Y.Z`)
173
+ }
174
+ return { major: +m[1], minor: +m[2], patch: +m[3] }
175
+ }
176
+
177
+ function incrementVersion(version, bump) {
178
+ const cur = parseVersion(version)
179
+ if (bump === 'major') {
180
+ return `${cur.major + 1}.0.0`
181
+ }
182
+ if (bump === 'minor') {
183
+ return `${cur.major}.${cur.minor + 1}.0`
184
+ }
185
+ if (bump === 'patch') {
186
+ return `${cur.major}.${cur.minor}.${cur.patch + 1}`
187
+ }
188
+ return `${cur.major}.${cur.minor}.${cur.patch}`
189
+ }
190
+
191
+ function normalizeMessage(msg) {
192
+ // normalize whitespace; keep case-insensitive matching
193
+ return (msg || '').replace(/\r\n/g, '\n')
194
+ }
195
+
196
+ // Returns "major" | "minor" | "patch" | null for a single commit message
197
+ function classifySingleCommit(messageBody) {
198
+ const body = normalizeMessage(messageBody)
199
+
200
+ // Major
201
+ if (/\bBREAKING[- _]CHANGE(?:\([^)]+\))?\s*:?/i.test(body)) {
202
+ return 'major'
203
+ }
204
+
205
+ // Minor
206
+ if (/(^|\n)\s*(\*?\s*)?feat(?:\([^)]+\))?\s*:?/i.test(body)) {
207
+ return 'minor'
208
+ }
209
+
210
+ // Patch
211
+ if (/(^|\n)\s*(\*?\s*)?(fix|perf)(?:\([^)]+\))?\s*:?/i.test(body)) {
212
+ return 'patch'
213
+ }
214
+
215
+ return null
216
+ }
217
+
218
+ // Given many commits, collapse to a single bump level
219
+ function collapseBumps(levels) {
220
+ if (levels.includes('major')) {
221
+ return 'major'
222
+ }
223
+ if (levels.includes('minor')) {
224
+ return 'minor'
225
+ }
226
+ if (levels.includes('patch')) {
227
+ return 'patch'
228
+ }
229
+ return null
230
+ }
231
+
232
+ // Fetch full commit messages (%B) for SHAs and compute bump
233
+ async function computeSemanticBumpForCommits(hashes, gitRawFn) {
234
+ if (!hashes.length) {
235
+ return null
236
+ }
237
+
238
+ const levels = []
239
+ for (const h of hashes) {
240
+ const msg = await gitRawFn(['show', '--format=%B', '-s', h])
241
+ const level = classifySingleCommit(msg)
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)
250
+ }
251
+ async function main() {
252
+ try {
253
+ if (!argv['no-fetch']) {
254
+ log(chalk.gray('Fetching remotes (git fetch --prune)...'))
255
+ await git.fetch(['--prune'])
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
+ }
296
+
297
+ if (argv['version-file'] && !argv['current-version']) {
298
+ const currentVersionFromPkg = await getPkgVersion(argv['version-file'])
299
+ argv['current-version'] = currentVersionFromPkg
300
+ }
301
+
302
+ let computedNextVersion = argv['current-version']
303
+ if (argv['semantic-versioning']) {
304
+ if (!argv['current-version']) {
305
+ throw new Error(' --semantic-versioning requires --current-version X.Y.Z (or pass --version-file)')
306
+ }
307
+
308
+ // Bump is based on the commits you are about to apply (selected).
309
+ const bump = await computeSemanticBumpForCommits(bottomToTop, gitRaw)
310
+
311
+ computedNextVersion = bump ? incrementVersion(argv['current-version'], bump) : argv['current-version']
312
+
313
+ log('')
314
+ log(chalk.magenta('Semantic Versioning'))
315
+ log(
316
+ ` Current: ${chalk.bold(argv['current-version'])} ` +
317
+ `Detected bump: ${chalk.bold(bump || 'none')} ` +
318
+ `Next: ${chalk.bold(computedNextVersion)}`
319
+ )
320
+ }
321
+
322
+ if (argv['create-release']) {
323
+ if (!argv['semantic-versioning'] || !argv['current-version']) {
324
+ throw new Error(' --create-release requires --semantic-versioning and --current-version X.Y.Z')
325
+ }
326
+ if (!computedNextVersion) {
327
+ throw new Error('Unable to determine release version. Check semantic-versioning inputs.')
328
+ }
329
+
330
+ const releaseBranch = `release/${computedNextVersion}`
331
+ await ensureBranchDoesNotExistLocally(releaseBranch)
332
+ const startPoint = argv.main // e.g., 'origin/main' or a local ref
333
+
334
+ const changelogBody = await buildChangelogBody({
335
+ version: computedNextVersion,
336
+ hashes: bottomToTop,
337
+ gitRawFn: gitRaw
338
+ })
339
+
340
+ await fsPromises.writeFile('RELEASE_CHANGELOG.md', changelogBody, 'utf8')
341
+ log(chalk.gray(`βœ… Generated changelog for ${releaseBranch} β†’ RELEASE_CHANGELOG.md`))
342
+
343
+ log(chalk.cyan(`\nCreating ${chalk.bold(releaseBranch)} from ${chalk.bold(startPoint)}...`))
344
+
345
+ await git.checkoutBranch(releaseBranch, startPoint)
346
+
347
+ log(chalk.green(`βœ“ Ready on ${chalk.bold(releaseBranch)}. Cherry-picking will apply here.`))
348
+ } else {
349
+ // otherwise we stay on the current branch
350
+ log(chalk.bold(`Base branch: ${currentBranch}`))
351
+ }
352
+
353
+ log(chalk.cyan(`\nCherry-picking ${bottomToTop.length} commit(s) onto ${currentBranch} (oldest β†’ newest)...\n`))
354
+
355
+ await cherryPickSequential(bottomToTop)
356
+
357
+ if (argv['push-release']) {
358
+ const baseBranchForGh = stripOrigin(argv.main) // 'origin/main' -> 'main'
359
+ const prTitle = `Release ${computedNextVersion}`
360
+ const releaseBranch = `release/${computedNextVersion}`
361
+
362
+ const onBranch = await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD'])
363
+ if (!onBranch.startsWith(releaseBranch)) {
364
+ throw new Error(`Version update should happen on a release branch. Current: ${onBranch}`)
365
+ }
366
+
367
+ log(chalk.cyan(`\nUpdating ${argv['version-file']} version β†’ ${computedNextVersion} ...`))
368
+ await setPkgVersion(argv['version-file'], computedNextVersion)
369
+ await git.add([argv['version-file']])
370
+ const msg = argv['version-commit-message'].replace('{{version}}', computedNextVersion)
371
+ await git.commit(msg)
372
+
373
+ log(chalk.green(`βœ“ package.json updated and committed: ${msg}`))
374
+
375
+ await git.push(['-u', 'origin', releaseBranch, '--no-verify'])
376
+
377
+ const ghArgs = [
378
+ 'pr',
379
+ 'create',
380
+ '--base',
381
+ baseBranchForGh,
382
+ '--head',
383
+ releaseBranch,
384
+ '--title',
385
+ prTitle,
386
+ '--body-file',
387
+ 'RELEASE_CHANGELOG.md'
388
+ ]
389
+ if (argv['draft-pr']) {
390
+ ghArgs.push('--draft')
391
+ }
392
+
393
+ await runGh(ghArgs)
394
+ log(chalk.gray(`Pushed ${onBranch} with version bump.`))
395
+ }
396
+
397
+ const finalBranch = argv['create-release']
398
+ ? await gitRaw(['rev-parse', '--abbrev-ref', 'HEAD']) // should be release/*
399
+ : currentBranch
400
+
401
+ log(chalk.green(`\nβœ… Done on ${finalBranch}`))
402
+ } catch (e) {
403
+ err(chalk.red(`\n❌ Error: ${e.message || e}`))
404
+ process.exit(1)
405
+ }
406
+ }
407
+
408
+ main()
409
+
410
+ /**
411
+ * Utils
412
+ */
413
+
414
+ async function ensureBranchDoesNotExistLocally(branchName) {
415
+ const branches = await git.branchLocal()
416
+ if (branches.all.includes(branchName)) {
417
+ throw new Error(
418
+ `Release branch "${branchName}" already exists locally. ` + `Please delete it or choose a different version.`
419
+ )
420
+ }
421
+ }
422
+
423
+ async function buildChangelogBody({ version, hashes, gitRawFn }) {
424
+ const today = new Date().toISOString().slice(0, 10)
425
+ const header = version ? `## Release ${version} β€” ${today}` : `## Release β€” ${today}`
426
+
427
+ const breakings = []
428
+ const features = []
429
+ const fixes = []
430
+ const others = []
431
+
432
+ for (const h of hashes) {
433
+ const msg = await gitRawFn(['show', '--format=%B', '-s', h])
434
+ const level = classifySingleCommit(msg)
435
+
436
+ const subject = msg.split(/\r?\n/)[0].trim() // first line of commit message
437
+ const shaDisplay = shortSha(h)
438
+
439
+ switch (level) {
440
+ case 'major':
441
+ breakings.push(`${shaDisplay} ${subject}`)
442
+ break
443
+ case 'minor':
444
+ features.push(`${shaDisplay} ${subject}`)
445
+ break
446
+ case 'patch':
447
+ fixes.push(`${shaDisplay} ${subject}`)
448
+ break
449
+ default:
450
+ others.push(`${shaDisplay} ${subject}`)
451
+ break
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')}`)
464
+ }
465
+ if (others.length) {
466
+ sections.push(`### 🧹 Others\n${others.join('\n')}`)
467
+ }
468
+
469
+ return `${header}\n\n${sections.join('\n\n')}\n`
470
+ }
471
+ function shortSha(sha) {
472
+ return String(sha).slice(0, 7)
473
+ }
474
+
475
+ function stripOrigin(ref) {
476
+ return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref
477
+ }
478
+
479
+ async function runGh(args) {
480
+ return new Promise((resolve, reject) => {
481
+ const p = spawn('gh', args, { stdio: 'inherit' })
482
+ p.on('error', reject)
483
+ p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`gh exited ${code}`))))
484
+ })
485
+ }
486
+ async function readJson(filePath) {
487
+ const raw = await fsPromises.readFile(filePath, 'utf8')
488
+ return JSON.parse(raw)
489
+ }
490
+
491
+ async function writeJson(filePath, data) {
492
+ const text = JSON.stringify(data, null, 2) + '\n'
493
+ await fsPromises.writeFile(filePath, text, 'utf8')
494
+ }
495
+
496
+ /** Read package.json version; throw if missing */
497
+ async function getPkgVersion(pkgPath) {
498
+ const pkg = await readJson(pkgPath)
499
+ const v = pkg && pkg.version
500
+ if (!v) {
501
+ throw new Error(`No "version" field found in ${pkgPath}`)
502
+ }
503
+ return v
504
+ }
505
+
506
+ /** Update package.json version in-place */
507
+ async function setPkgVersion(pkgPath, nextVersion) {
508
+ const pkg = await readJson(pkgPath)
509
+ pkg.version = nextVersion
510
+ await writeJson(pkgPath, pkg)
511
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "cherrypick-interactive",
3
+ "version": "1.0.0",
4
+ "description": "Interactively cherry-pick commits that are in dev but not in main, using subject-based comparison.",
5
+ "main": "cli.js",
6
+ "bin": {
7
+ "cherrypick-interactive": "./cli.js"
8
+ },
9
+ "type": "module",
10
+ "engines": {
11
+ "node": ">=20"
12
+ },
13
+ "dependencies": {
14
+ "chalk": "^5.6.2",
15
+ "inquirer": "^12.9.6",
16
+ "simple-git": "^3.28.0",
17
+ "yargs": "^18.0.0"
18
+ },
19
+ "keywords": [
20
+ "git",
21
+ "cli",
22
+ "cherry-pick",
23
+ "interactive",
24
+ "devops"
25
+ ],
26
+ "license": "MIT",
27
+ "packageManager": "yarn@4.6.0"
28
+ }