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