current commit
260807.1 · chore: lint unused catch bindings (#1137)
import path from 'node:path';
import fs from 'node:fs';

import { getMergedConfig, getNcuDir } from './config.js';
import { readJson, writeJson, readFile, writeFile } from './file.js';
import {
  runAsync, runSync, forceRunAsync
} from './run.js';
import {
  shortSha
} from './utils.js';

const APPLYING = 'APPLYING';
const STARTED = 'STARTED';
const AMENDING = 'AMENDING';

export default class Session {
  constructor(cli, dir, prid, argv, warnForMissing = true) {
    this.cli = cli;
    this.dir = dir;
    this.prid = prid;
    this.config = getMergedConfig(this.dir, undefined, argv);
    this.gpgSign = argv?.['gpg-sign']
      ? (argv['gpg-sign'] === true ? ['-S'] : ['-S', argv['gpg-sign']])
      : [];

    if (warnForMissing) {
      const { upstream, owner, repo } = this;
      if (this.warnForMissing()) {
        throw new Error('Failed to create new session');
      }

      const upstreamHref = runSync('git', [
        'config', '--get',
      `remote.${upstream}.url`]).trim();
      if (!upstreamHref.endsWith(`${owner}/${repo}.git`) &&
          !upstreamHref.endsWith(`${owner}/${repo}`)) {
        cli.warn('Remote repository URL does not point to the expected ' +
        `repository ${owner}/${repo}`);
        this.crossRepoPR = true;
      }
    }
  }

  get session() {
    return readJson(this.sessionPath);
  }

  get gitDir() {
    return path.join(this.dir, '.git');
  }

  get ncuDir() {
    return getNcuDir(this.dir);
  }

  get argv() {
    // TODO(joyeecheung): remove this and make argv an object
    return {
      owner: this.owner,
      repo: this.repo,
      upstream: this.upstream,
      branch: this.branch,
      readme: this.readme,
      waitTimeSingleApproval: this.waitTimeSingleApproval,
      waitTimeMultiApproval: this.waitTimeMultiApproval,
      updateDeprecations: this.updateDeprecations,
      ciType: this.ciType,
      prid: this.prid,
      checkCI: this.checkCI
    };
  }

  get sessionPath() {
    return path.join(this.ncuDir, 'land');
  }

  get owner() {
    return this.config.owner || 'nodejs';
  }

  get repo() {
    return this.config.repo || 'node';
  }

  get upstream() {
    return this.config.upstream;
  }

  get branch() {
    return this.config.branch;
  }

  get username() {
    return this.config.username;
  }

  get readme() {
    return this.config.readme;
  }

  get waitTimeSingleApproval() {
    return this.config.waitTimeSingleApproval;
  }

  get waitTimeMultiApproval() {
    return this.config.waitTimeMultiApproval;
  }

  get ciType() {
    return this.config.ciType || 'nodejs';
  }

  get pullName() {
    return `${this.owner}/${this.repo}/pulls/${this.prid}`;
  }

  get pullDir() {
    return path.join(this.ncuDir, `${this.prid}`);
  }

  get updateDeprecations() {
    return this.config.updateDeprecations || 'yes';
  }

  startLanding() {
    writeJson(this.sessionPath, {
      state: STARTED,
      prid: this.prid,
      backport: this.backport,
      // Filter out getters, those are likely encrypted data we don't need on the session.
      config: Object.entries(Object.getOwnPropertyDescriptors(this.config))
        .reduce((pv, [key, desc]) => {
          if (Object.hasOwn(desc, 'value')) pv[key] = desc.value;
          return pv;
        }, { __proto__: null }),
    });
  }

  // TODO(joyeecheung): more states
  // - STARTED (fetching metadata)
  // - DOWNLOADING (downloading the patch)
  // - PATCHING (git am)
  // - AMENDING (git rebase or just amending messages)
  // - DONE

  startApplying() {
    this.updateSession({
      state: APPLYING
    });
  }

  startAmending() {
    this.updateSession({
      state: AMENDING
    });
  }

  cleanFiles() {
    let sess;
    try {
      sess = this.session;
    } catch {
      return fs.rmSync(this.sessionPath, { recursive: true, force: true });
    }

    if (sess.prid && sess.prid === this.prid) {
      fs.rmSync(this.pullDir, { recursive: true, force: true });
    }
    fs.rmSync(this.sessionPath, { recursive: true, force: true });
  }

  get statusPath() {
    return path.join(this.pullDir, 'status');
  }

  get status() {
    return readJson(this.statusPath);
  }

  get metadataPath() {
    return path.join(this.pullDir, 'metadata');
  }

  get metadata() {
    return readFile(this.metadataPath);
  }

  get commitInfoPath() {
    return path.join(this.pullDir, 'commit-info');
  }

  get commitInfo() {
    return readJson(this.commitInfoPath);
  }

  getMessagePath(rev) {
    return path.join(this.pullDir, `${shortSha(rev)}.COMMIT_EDITMSG`);
  }

  updateSession(update) {
    const old = this.session;
    writeJson(this.sessionPath, Object.assign(old, update));
  }

  saveStatus(status) {
    writeJson(this.statusPath, status);
  }

  saveMetadata(status) {
    writeFile(this.metadataPath, status.metadata);
  }

  saveCommitInfo(commitInfo) {
    writeJson(this.commitInfoPath, commitInfo);
  }

  saveMessage(rev, message) {
    const file = this.getMessagePath(rev);
    writeFile(file, message);
    return file;
  }

  hasStarted() {
    return !!this.session.prid && this.session.prid === this.prid;
  }

  isApplying() {
    return this.session.state === APPLYING;
  }

  readyToAmend() {
    if (this.session.state === AMENDING) {
      return true;
    } else if (this.isApplying()) {
      return !this.cherryPickInProgress();
    } else {
      return false;
    }
  }

  readyToFinal() {
    if (this.amInProgress() || this.cherryPickInProgress()) {
      return false;  // git am/rebase in progress
    }
    return this.session.state === AMENDING;
  }

  // Refs: https://github.com/git/git/blob/99de064/git-rebase.sh#L208-L228
  // XXX: This may be unused at this point?
  amInProgress() {
    const amPath = path.join(this.gitDir, 'rebase-apply', 'applying');
    return fs.existsSync(amPath);
  }

  rebaseInProgress() {
    if (this.amInProgress()) {
      return false;
    }

    const normalRebasePath = path.join(this.gitDir, 'rebase-apply');
    const mergeRebasePath = path.join(this.gitDir, 'rebase-merge');
    return fs.existsSync(normalRebasePath) || fs.existsSync(mergeRebasePath);
  }

  cherryPickInProgress() {
    const cpPath = path.join(this.gitDir, 'CHERRY_PICK_HEAD');
    return fs.existsSync(cpPath);
  }

  restore() {
    const sess = this.session;
    if (sess.prid) {
      this.prid = sess.prid;
      this.backport = sess.backport;
      this.config = sess.config;
    }
    return this;
  }

  async tryAbortAm() {
    const { cli } = this;
    if (!this.amInProgress()) {
      return cli.ok('No git am in progress');
    }
    const shouldAbortAm = await cli.prompt(
      'Abort previous git am sessions?');
    if (shouldAbortAm) {
      await forceRunAsync('git', ['am', '--abort']);
      cli.ok('Aborted previous git am sessions');
    }
  }

  async tryAbortCherryPick() {
    const { cli } = this;
    if (!this.cherryPickInProgress()) {
      return cli.ok('No git cherry-pick in progress');
    }
    const shouldAbortCherryPick = await cli.prompt(
      'Abort previous git cherry-pick sessions?');
    if (shouldAbortCherryPick) {
      await forceRunAsync('git', ['cherry-pick', '--abort']);
      cli.ok('Aborted previous git cherry-pick sessions');
    }
  }

  async tryAbortRebase() {
    const { cli } = this;
    if (!this.rebaseInProgress()) {
      return cli.ok('No git rebase in progress');
    }
    const shouldAbortRebase = await cli.prompt(
      'Abort previous git rebase sessions?');
    if (shouldAbortRebase) {
      await forceRunAsync('git', ['rebase', '--abort']);
      cli.ok('Aborted previous git rebase sessions');
    }
  }

  async tryResetBranch() {
    const { cli, upstream, branch } = this;
    await this.tryAbortCherryPick();
    await this.tryAbortAm();
    await this.tryAbortRebase();

    const branchName = `${upstream}/${branch}`;
    const shouldResetHead = await cli.prompt(
      `Do you want to try reset the local ${branch} branch to ${branchName}?`);
    if (shouldResetHead) {
      await this.tryResetHead();
    }
  }

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

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

  getUpstreamHead() {
    const { upstream, branch } = this;
    return runSync('git', ['rev-parse', `${upstream}/${branch}`]).trim();
  }

  getStrayCommits(verbose) {
    const { upstream, branch, cli } = this;
    const base = cli.hasDetachedHEAD || `${upstream}/${branch}`;
    const ref = `${base}...HEAD`;
    const gitCmd = verbose
      ? ['log', '--oneline', '--reverse', ref]
      : ['rev-list', '--reverse', ref];
    const revs = runSync('git', gitCmd).trim();
    return revs ? revs.split('\n') : [];
  }

  async tryResetHead() {
    const { cli, upstream, branch } = this;
    const branchName = `${upstream}/${branch}`;
    cli.startSpinner(`Bringing ${branchName} up to date...`);
    const maybeUnshallow = fs.existsSync('.git/shallow') ? ['--unshallow'] : [];
    await runAsync('git', ['fetch', ...maybeUnshallow, upstream, branch]);
    cli.stopSpinner(`${branchName} is now up-to-date`);
    const stray = this.getStrayCommits(true);
    if (!stray.length) {
      return;
    }
    cli.log(`${branch} is out of sync with ${branchName}. ` +
            'Mismatched commits:\n' +
      ` - ${stray.join('\n - ')}`);
    const shouldReset = await cli.prompt(`Reset to ${branchName}?`);
    if (shouldReset) {
      await runAsync('git', ['reset', '--hard', branchName]);
      cli.ok(`Reset to ${branchName}`);
    }
  }

  warnForMissing() {
    const { upstream, branch, cli } = this;
    const missing = !upstream || !branch;
    if (!branch) {
      cli.warn('You have not told git-node what branch you are trying' +
               ' to land commits on.');
      cli.separator();
      cli.info(
        'For example, if your want to land commits on the ' +
        '`main` branch, you can run:\n\n' +
        '  $ ncu-config set branch main');
      cli.separator();
      cli.setExitCode(1);
    }
    if (!upstream) {
      cli.warn('You have not told git-node what remote name you are trying to land commits on.');
      cli.separator();
      const remoteName = runSync('git', ['remote', '-v'])
        .match(/^(\S+)\s+(?:https:\/\/github\.com\/|git@github\.com:)nodejs\/node\.git \(\S+\)\r?$/m)?.[1];
      cli.info(remoteName
        ? `You likely want to run the following:\n\n  $ ncu-config set upstream ${remoteName}`
        : 'The expected repository does not seem to appear in your local config.\n' +
        '1. First, add the Node.js core repository as a remote:\n' +
        '   $ git remote add upstream https://github.com/nodejs/node.git\n\n' +
        '2. Then, tell git-node to use this remote for syncing:\n' +
        '   $ ncu-config set upstream upstream\n\n' +
        'Note: Using "upstream" is recommended, but you can use any remote name.\n' +
        'For security reasons, you need to add the remote manually.');
      cli.separator();
      cli.setExitCode(1);
    }
    return missing;
  }

  warnForWrongBranch() {
    const { branch, cli } = this;
    const rev = this.getCurrentBranch();
    if (rev === 'HEAD') {
      cli.warn(
        'You are in detached HEAD state. Please run git-node on a valid ' +
        'branch');
      cli.setExitCode(1);
      return true;
    }
    if (rev === branch) {
      return false;
    }
    cli.warn(
      `You are running git-node-land on \`${rev}\`,\n   but you have` +
      ` configured \`${branch}\` to be the branch to land commits.`);
    cli.separator();
    cli.info(
      `You can switch to \`${branch}\` with \`git checkout ${branch}\`, or\n` +
      '   reconfigure the target branch with:\n\n' +
      `  $ ncu-config set branch ${rev}`);
    cli.separator();
    cli.setExitCode(1);
    return true;
  // TODO warn if backporting onto master branch
  }
}