1import { Command } from '@expo/commander'; 2import chalk from 'chalk'; 3import inquirer from 'inquirer'; 4import path from 'path'; 5 6import Git, { GitLog } from '../Git'; 7import logger from '../Logger'; 8 9type ActionOptions = { 10 from?: string; 11 to?: string; 12 startDate?: string; 13 startAtLatest: boolean; 14 dry: boolean; 15}; 16 17export default (program: Command) => { 18 program 19 .command('cherry-pick [packageNames...]') 20 .alias('cherrypick', 'cpk') 21 .option( 22 '-f, --from <string>', 23 'Source branch of commits to cherry-pick. Defaults to the current branch if `--to` is specified, and `main` otherwise.' 24 ) 25 .option( 26 '-t, --to <string>', 27 'Destination branch for commits to cherry-pick. Defaults to the current branch.' 28 ) 29 .option( 30 '-s, --start-date <string>', 31 'Date at which to start looking for commits to cherry-pick, ignoring any earlier commits. Format: YYYY-MM-DD' 32 ) 33 .option( 34 '--start-at-latest', 35 'Equivalent to `--start-date <latest-date>`, where `latest-date` is the author date of the most recent commit on the destination branch across all specified packages.', 36 false 37 ) 38 .option( 39 '-d, --dry', 40 'Dry run. Does not run any commands that actually modify the repository, and logs them instead', 41 false 42 ) 43 .description( 44 'Interactively creates and runs a command to cherry pick commits in a specific package (or set of packages) from one branch to another.' 45 ) 46 .asyncAction(main); 47}; 48 49async function main(packageNames: string[], options: ActionOptions): Promise<void> { 50 if (!options.dry && (await Git.hasUnstagedChangesAsync())) { 51 throw new Error( 52 'Cannot run this command with unstaged changes. Try again with a clean working directory.' 53 ); 54 } 55 56 const currentBranch = await Git.getCurrentBranchNameAsync(); 57 if (!options.from && !options.to && currentBranch === 'main') { 58 throw new Error('Must specify either `--from` or `--to` branch if main is checked out.'); 59 } 60 const source = options.from ?? (options.to ? currentBranch : 'main'); 61 const destination = options.to ?? currentBranch; 62 if (source === destination) { 63 throw new Error( 64 'Source and destination branches cannot be the same. Try specifying both `--from` and `--to`.' 65 ); 66 } 67 68 if (options.startAtLatest && options.startDate) { 69 throw new Error('Cannot specify both `--start-date` and `--start-at-latest`.'); 70 } 71 72 logger.info( 73 `\nLooking for commits to cherry-pick from ${chalk.bold(chalk.blue(source))} to ${chalk.bold( 74 chalk.blue(destination) 75 )}...\n` 76 ); 77 78 const packagePaths = packageNames.map((packageName) => path.join('.', 'packages', packageName)); 79 80 const mergeBase = await Git.mergeBaseAsync(source, destination); 81 const commitsOnDestinationBranch = await Git.logAsync({ 82 fromCommit: mergeBase, 83 toCommit: destination, 84 paths: packagePaths, 85 }); 86 87 let startDate: Date | null = null; 88 if (options.startDate) { 89 startDate = new Date(options.startDate); 90 } else if (options.startAtLatest) { 91 startDate = new Date(commitsOnDestinationBranch[0].authorDate); 92 } 93 94 const commitsBeforeStartDate: GitLog[] = []; 95 const candidateCommits = ( 96 await Git.logAsync({ 97 fromCommit: source, 98 toCommit: destination, 99 paths: packagePaths, 100 cherryPick: 'left', 101 symmetricDifference: true, 102 }) 103 ) 104 .reverse() 105 .filter((srcCommit) => { 106 // Git will sometimes return commits that have already been cherry-picked if the diff is 107 // slightly different. We filter them out here if the commit name/date/author matches 108 // another commit already on the destination branch. 109 const hasAlreadyBeenCherryPicked = commitsOnDestinationBranch.some( 110 (destCommit) => 111 srcCommit.authorDate === destCommit.authorDate && 112 srcCommit.authorName === destCommit.authorName && 113 srcCommit.title === destCommit.title 114 ); 115 if (hasAlreadyBeenCherryPicked) { 116 return false; 117 } 118 119 // Filter out any commits earlier than the startDate (if we have one) but also add them to 120 // another array so we can log them. 121 if (startDate && new Date(srcCommit.authorDate).getTime() < startDate.getTime()) { 122 commitsBeforeStartDate.push(srcCommit); 123 return false; 124 } 125 126 return true; 127 }); 128 129 if (commitsBeforeStartDate.length !== 0) { 130 logger.log(chalk.bold(chalk.red('Ignoring the following commits from before the start date:'))); 131 logger.log( 132 commitsBeforeStartDate 133 .map( 134 (commit) => 135 ` ❌ ${chalk.red(commit.hash.slice(0, 10))} ${commit.authorDate} ${chalk.magenta( 136 commit.authorName 137 )} ${commit.title}` 138 ) 139 .join('\n') 140 ); 141 logger.log(''); // new line 142 } 143 144 if (candidateCommits.length === 0) { 145 logger.success('There is nothing to cherry-pick.'); 146 return; 147 } 148 149 const { commitsToCherryPick } = await inquirer.prompt<{ commitsToCherryPick: string[] }>([ 150 { 151 type: 'checkbox', 152 name: 'commitsToCherryPick', 153 message: `Choose which commits to cherry-pick from ${chalk.blue(source)} to ${chalk.blue( 154 destination 155 )}\n ${chalk.green('●')} selected ○ unselected\n`, 156 choices: candidateCommits.map((commit) => ({ 157 value: commit.hash, 158 short: commit.hash, 159 name: `${chalk.yellow(commit.hash.slice(0, 10))} ${commit.authorDate} ${chalk.magenta( 160 commit.authorName 161 )} ${commit.title}`, 162 })), 163 default: candidateCommits.map((commit) => commit.hash), 164 pageSize: Math.min(candidateCommits.length, (process.stdout.rows || 100) - 4), 165 }, 166 ]); 167 168 logger.info(''); // new line 169 170 if (destination !== (await Git.getCurrentBranchNameAsync())) { 171 if (options.dry) { 172 logger.log(chalk.bold(chalk.yellow(`git checkout ${destination}`))); 173 } else { 174 logger.info(`Checking out ${chalk.bold(chalk.blue(destination))} branch...`); 175 await Git.checkoutAsync(destination); 176 } 177 } 178 179 // ensure we preserve the correct order of commits 180 const commitsToCherryPickOrdered = candidateCommits.filter((commit) => 181 commitsToCherryPick.includes(commit.hash) 182 ); 183 const commitHashes = commitsToCherryPickOrdered.map((commit) => commit.hash); 184 if (options.dry) { 185 logger.log(chalk.bold(chalk.yellow(`git cherry-pick ${commitHashes.join(' ')}`))); 186 } else { 187 logger.info(`Running ${chalk.yellow(`git cherry-pick ${commitHashes.join(' ')}`)}`); 188 try { 189 // pipe output to current process stdio to emulate user running this command directly 190 await Git.cherryPickAsync(commitHashes, { inheritStdio: true }); 191 } catch { 192 logger.error( 193 `Expotools: could not complete cherry-pick. Resolve the conflicts and continue as instructed by git above.` 194 ); 195 } 196 } 197} 198