cherrypick-interactive 1.0.0 → 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.
Files changed (2) hide show
  1. package/cli.js +247 -11
  2. 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: false,
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: false,
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.min(20, Math.max(8, missing.length))
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
- 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
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.commit(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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cherrypick-interactive",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Interactively cherry-pick commits that are in dev but not in main, using subject-based comparison.",
5
5
  "main": "cli.js",
6
6
  "bin": {