cherrypick-interactive 1.0.1 → 1.1.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/cli.js +247 -11
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -45,7 +45,7 @@ const argv = yargs(hideBin(process.argv))
|
|
|
45
45
|
})
|
|
46
46
|
.option('semantic-versioning', {
|
|
47
47
|
type: 'boolean',
|
|
48
|
-
default:
|
|
48
|
+
default: true,
|
|
49
49
|
describe: 'Compute next semantic version from selected (or missing) commits.'
|
|
50
50
|
})
|
|
51
51
|
.option('current-version', {
|
|
@@ -54,7 +54,7 @@ const argv = yargs(hideBin(process.argv))
|
|
|
54
54
|
})
|
|
55
55
|
.option('create-release', {
|
|
56
56
|
type: 'boolean',
|
|
57
|
-
default:
|
|
57
|
+
default: true,
|
|
58
58
|
describe: 'Create a release branch from --main named release/<computed-version> before cherry-picking.'
|
|
59
59
|
})
|
|
60
60
|
.option('push-release', {
|
|
@@ -69,6 +69,7 @@ const argv = yargs(hideBin(process.argv))
|
|
|
69
69
|
})
|
|
70
70
|
.option('version-file', {
|
|
71
71
|
type: 'string',
|
|
72
|
+
default: './package.json',
|
|
72
73
|
describe: 'Path to package.json (read current version; optional replacement for --current-version)'
|
|
73
74
|
})
|
|
74
75
|
.option('version-commit-message', {
|
|
@@ -129,6 +130,7 @@ async function selectCommitsInteractive(missing) {
|
|
|
129
130
|
}),
|
|
130
131
|
new inquirer.Separator(chalk.gray('── Oldest commits ──'))
|
|
131
132
|
]
|
|
133
|
+
const termHeight = process.stdout.rows || 24 // fallback for non-TTY environments
|
|
132
134
|
|
|
133
135
|
const { selected } = await inquirer.prompt([
|
|
134
136
|
{
|
|
@@ -136,28 +138,251 @@ async function selectCommitsInteractive(missing) {
|
|
|
136
138
|
name: 'selected',
|
|
137
139
|
message: `Select commits to cherry-pick (${missing.length} missing):`,
|
|
138
140
|
choices,
|
|
139
|
-
pageSize: Math.
|
|
141
|
+
pageSize: Math.max(10, Math.min(termHeight - 5, missing.length))
|
|
140
142
|
}
|
|
141
143
|
])
|
|
142
144
|
|
|
143
145
|
return selected
|
|
144
146
|
}
|
|
145
147
|
|
|
148
|
+
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
|
+
}
|
|
171
|
+
|
|
172
|
+
if (action === 'abort') {
|
|
173
|
+
await gitRaw(['cherry-pick', '--abort'])
|
|
174
|
+
throw new Error('Cherry-pick aborted by user.')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const res = await conflictsResolutionWizard(hash)
|
|
178
|
+
if (res === 'continued') {
|
|
179
|
+
// Successfully continued; this commit is now applied
|
|
180
|
+
return 'continued'
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function getConflictedFiles() {
|
|
186
|
+
const out = await gitRaw(['diff', '--name-only', '--diff-filter=U'])
|
|
187
|
+
return out ? out.split('\n').filter(Boolean) : []
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function assertNoUnmerged() {
|
|
191
|
+
const files = await getConflictedFiles()
|
|
192
|
+
return files.length === 0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
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
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
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
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function resolveSingleFileWizard(file) {
|
|
218
|
+
const { action } = await inquirer.prompt([
|
|
219
|
+
{
|
|
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}`))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return action
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
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
|
+
}
|
|
290
|
+
|
|
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
|
+
}
|
|
317
|
+
|
|
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
|
|
334
|
+
}
|
|
335
|
+
|
|
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
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (choice.type === 'global' && choice.action === 'back') {
|
|
352
|
+
return 'back'
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
146
357
|
async function cherryPickSequential(hashes) {
|
|
358
|
+
const result = { applied: 0, skipped: 0 }
|
|
359
|
+
|
|
147
360
|
for (const hash of hashes) {
|
|
148
361
|
try {
|
|
149
362
|
await gitRaw(['cherry-pick', hash])
|
|
150
363
|
const subject = await gitRaw(['show', '--format=%s', '-s', hash])
|
|
151
364
|
log(`${chalk.green('✓')} cherry-picked ${chalk.dim(`(${hash.slice(0, 7)})`)} ${subject}`)
|
|
365
|
+
result.applied += 1
|
|
152
366
|
} catch (e) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
}
|
|
159
382
|
}
|
|
160
383
|
}
|
|
384
|
+
|
|
385
|
+
return result
|
|
161
386
|
}
|
|
162
387
|
|
|
163
388
|
/**
|
|
@@ -352,7 +577,18 @@ async function main() {
|
|
|
352
577
|
|
|
353
578
|
log(chalk.cyan(`\nCherry-picking ${bottomToTop.length} commit(s) onto ${currentBranch} (oldest → newest)...\n`))
|
|
354
579
|
|
|
355
|
-
await cherryPickSequential(bottomToTop)
|
|
580
|
+
const stats = await cherryPickSequential(bottomToTop)
|
|
581
|
+
|
|
582
|
+
log(chalk.gray(`\nSummary → applied: ${stats.applied}, skipped: ${stats.skipped}`))
|
|
583
|
+
|
|
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
|
+
}
|
|
356
592
|
|
|
357
593
|
if (argv['push-release']) {
|
|
358
594
|
const baseBranchForGh = stripOrigin(argv.main) // 'origin/main' -> 'main'
|
|
@@ -368,7 +604,7 @@ async function main() {
|
|
|
368
604
|
await setPkgVersion(argv['version-file'], computedNextVersion)
|
|
369
605
|
await git.add([argv['version-file']])
|
|
370
606
|
const msg = argv['version-commit-message'].replace('{{version}}', computedNextVersion)
|
|
371
|
-
await git.raw(['commit', '--no-verify', '-m', msg])
|
|
607
|
+
await git.raw(['commit', '--no-verify', '-m', msg])
|
|
372
608
|
|
|
373
609
|
log(chalk.green(`✓ package.json updated and committed: ${msg}`))
|
|
374
610
|
|