@ 261105.4
current
import Session from './session.js';
import { runSync, runAsync, IGNORE } from './run.js';
import { getPrURL, parsePrURL } from './links.js';

const MAX_HISTORY = 10;
const OLDEST_ID = new Map([
  [8, 13000],
  [10, 20000],
  [11, 23000]
]);

export default class BackportSession extends Session {
  constructor(cli, dir, prid, target) {
    super(cli, dir, prid);
    this.target = target;
  }

  getChangedFiles(rev) {
    return runSync('git',
      ['diff-tree', '--no-commit-id', '--name-only', '-r', rev]
    ).trim().split('\n');
  }

  getPreviousCommits(rev, file, num) {
    let logs;
    try {
      logs = runSync('git',
        ['log', `-${num}`, '--format=%h', rev, '--', file]
      ).trim();
    } catch {
      return null;
    }
    if (!logs) {
      return [];
    }

    return logs.trim().split('\n');
  }

  getCommitMessage(rev) {
    return runSync('git',
      ['show', '--format=%B', '-s', rev]
    ).trim();
  }

  get stagingBranch() {
    return `v${this.target}.x-staging`;
  }

  getPotentialConflicts(rev, targetBranch) {
    const { cli } = this;
    const files = this.getChangedFiles(rev);
    const notBackported = new Map();
    const oldest = OLDEST_ID.get(this.target);
    for (const file of files) {
      cli.startSpinner(`Analyzing ancestors of ${file}`);
      // TODO(joyeecheung): if the file does not exit in the current revision,
      // warn about it and skip it.
      const ancestors = this.getPreviousCommits(`${rev}~1`, file, MAX_HISTORY);
      if (!ancestors) {
        cli.stopSpinner(`${file} does not exist in current working tree`,
          cli.SPINNER_STATUS.WARN);
        continue;
      }
      if (ancestors.length === 0) {
        cli.stopSpinner(`Cannot find ancestor commits of ${file}`,
          cli.SPINNER_STATUS.INFO);
        continue;
      }
      for (const ancestor of ancestors) {
        const message = this.getCommitMessage(ancestor);
        cli.updateSpinner(`Analyzing ${message.split('\n')[0]}...`);
        let data = parsePrURL(message);
        if (!data) {
          const match = message.match('/^PR-URL: #(\\d+)/');
          if (!match) {
            cli.stopSpinner(
              `Commit message of ${ancestor} is ill-formed, skipping`,
              cli.SPINNER_STATUS.WARN);
            cli.startSpinner(`Analyzing ancestors of ${file}`);
            continue;
          }
          data = {
            repo: this.repo,
            owner: this.owner,
            prid: parseInt(match[1])
          };
        }
        if (data.prid < oldest) {
          cli.updateSpinner(
            `Commit ${ancestor} iS too old, skipping`,
            cli.SPINNER_STATUS.WARN);
          break;
        }
        const backported = this.getCommitsFromBranch(
          data.prid, targetBranch
        );
        if (backported.length === 0) {
          const record = notBackported.get(ancestor);
          if (record) {
            record.files.add(file);
          } else {
            notBackported.set(ancestor, {
              prid: data.prid,
              url: getPrURL(data),
              commit: ancestor,
              title: message.split('\n')[0],
              files: new Set([file])
            });
          }
        }
      }
      cli.stopSpinner(`Analyzed ${file}`);
    }
    return notBackported;
  }

  warnForPotentialConflicts(rev) {
    const { cli } = this;
    const staging = this.stagingBranch;

    cli.log(`Looking for potential conflicts of ${rev}...`);
    const notBackported = this.getPotentialConflicts(rev, staging);

    if (notBackported.size === 0) {
      cli.info(`All ancestor commits of ${rev} have been backported`);
      return;
    }

    cli.warn(`The following ancestor commits of ${rev} are not on ${staging}`);
    for (const [commit, data] of notBackported) {
      cli.log(`  - ${commit} ${data.title}, ${data.url}`);
      for (const file of data.files) {
        cli.log(`    ${file}`);
      }
    }
  }

  async backport() {
    const { cli } = this;
    // TODO(joyeechuneg): add more warnings
    const { prid } = this;
    const url = getPrURL(this);
    cli.log(`Looking for commits of ${url} on main...`);

    const commits = this.getCommitsFromBranch(prid, 'main');

    if (commits.length === 0) {
      cli.error('Could not find any commit matching the PR');
      throw new Error(IGNORE);
    }

    cli.ok('Found the following commits:');
    for (const commit of commits) {
      cli.log(`    - ${commit.sha} ${commit.title}`);
    }

    if (!this.isLocalBranchExists(this.stagingBranch)) {
      const shouldCreateStagingBranch = await cli.prompt(
        `It seems like ${this.stagingBranch} is missing locally, ` +
        'do you want to create it locally to get ready for backporting?', {
          defaultAnswer: true
        });

      if (shouldCreateStagingBranch) {
        this.syncBranchWithUpstream(this.stagingBranch);
      }
    } else if (!this.isBranchUpToDateWithUpstream(this.stagingBranch)) {
      const shouldSyncBranch = await cli.prompt(
        `It seems like your ${this.stagingBranch} is behind the ${this.upstream} remote ` +
        'do you want to sync it?', { defaultAnswer: true });

      if (shouldSyncBranch) {
        this.syncBranchWithUpstream(this.stagingBranch);
      }
    }

    const newBranch = `backport-${this.prid}-to-${this.target}`;
    const shouldCheckout = await cli.prompt(
      `Do you want to checkout to a new branch \`${newBranch}\`` +
      ' to start backporting?', { defaultAnswer: false });

    if (shouldCheckout) {
      await runAsync('git', ['checkout', '-b', newBranch, this.stagingBranch]);
    }

    const shouldAnalyze = await cli.prompt(
      'Do you want to analyze the dependencies of the commits? ' +
      '(this could take a while)');
    if (shouldAnalyze) {
      for (const commit of commits) {
        this.warnForPotentialConflicts(commit.sha);
      }
    }

    const cherries = commits.map(i => i.sha).reverse();
    const pendingCommands = [
      `git cherry-pick ${cherries.join(' ')}`,
      'git push -u <your-fork-remote> <your-branch-name>'
    ];
    const shouldPick = await cli.prompt(
      'Do you want to cherry-pick the commits?');
    if (!shouldPick) {
      this.hintCommands(pendingCommands);
      return;
    }

    cli.log(`Running \`${pendingCommands[0]}\`...`);
    pendingCommands.shift();
    await runAsync('git', ['cherry-pick', ...cherries]);
    this.hintCommands(pendingCommands);
  }

  hintCommands(commands) {
    this.cli.log('Tips: run the following commands to complete backport');
    for (const command of commands) {
      this.cli.log(`$ ${command}`);
    }
  }

  getCommitsFromBranch(prid, branch, loose = true) {
    let re;
    const url = getPrURL({ prid, repo: this.repo, owner: this.owner });
    re = `--grep=PR-URL: ${url}`;

    let commits = runSync('git', [
      'log', re, '--format=%h %s', branch
    ]).trim();
    if (!commits) {
      if (!loose) {
        return [];
      }
      re = `--grep=PR-URL: #${prid}\\b`;
      commits = runSync('git', [
        'log', re, '--format=%h %s', branch
      ]).trim();
      if (!commits) {
        return [];
      }
    }

    return commits.split('\n').map((i) => {
      const match = i.match(/(\w+) (.+)/);
      return {
        sha: match[1],
        title: match[2]
      };
    });
  }

  getCurrentBranch() {
    return runSync('git',
      ['rev-parse', '--abbrev-ref', 'HEAD']
    ).trim();
  }

  updateUpstreamRefs(branchName) {
    runSync('git',
      ['fetch', this.upstream, branchName]
    );
  }

  getBranchCommit(branch) {
    return runSync('git',
      ['rev-parse', branch]
    ).trim();
  }

  isBranchUpToDateWithUpstream(branch) {
    this.updateUpstreamRefs(branch);

    const localCommit = this.getBranchCommit(branch);
    const upstreamCommit = this.getBranchCommit(`${this.upstream}/${branch}`);

    return localCommit === upstreamCommit;
  }

  isLocalBranchExists(branch) {
    try {
      // will exit with code 1 if branch does not exist
      runSync('git',
        ['rev-parse', '--verify', '--quiet', branch]
      );
      return true;
    } catch {
      return false;
    }
  }

  syncBranchWithUpstream(branch) {
    const currentBranch = this.getCurrentBranch();

    runSync('git',
      [
        currentBranch !== branch ? 'fetch' : 'pull',
        this.upstream,
        `${branch}:${branch}`,
        '-f'
      ]
    );
  }
}