Source: lib/git-status-checker.js

/**
 * @copyright Copyright 2016 Kevin Locke <kevin@kevinlocke.name>
 * @license MIT
 */

'use strict';

const Chalk = require('chalk').Instance;
const util = require('util');

const InvalidSlugError = require('./invalid-slug-error');
const Shortline = require('./shortline');
const SlugDetectionError = require('./slug-detection-error');
const git = require('./git');

const debug = util.debuglog('travis-status:git-status-checker');

/** Gets the right-trimmed stdout result of <code>execFileP</code>.
 * @param {!Array<string>} result Result Array from <code>execFileP</code>
 * @return {string} The first element of <code>result</code>, right-trimed.
 * @private
 */
function getTrimmedStdout(result) {
  return result[0].trimEnd();
}

/** Options for {@link GitStatusChecker}.
 *
 * @typedef {{
 *   err: stream.Writable|undefined,
 *   in: stream.Readable|undefined,
 *   interactive: boolean|undefined,
 *   out: stream.Writable|undefined
 * }} GitStatusCheckerOptions
 * @property {stream.Writable=} err Stream to which errors (and non-output
 * status messages) are written. (default: <code>process.stderr</code>)
 * @property {stream.Readable=} in Stream from which input is read. (default:
 * <code>process.stdin</code>)
 * @property {boolean=} interactive Be interactive and colorful
 * @property {stream.Writable=} out Stream to which output is written.
 * (default: <code>process.stdout</code>)
 */
// var GitStatusCheckerOptions;

/** Creates a status checker for the current git repository.
 *
 * @constructor
 * @param {GitStatusCheckerOptions=} options Options.
 */
function GitStatusChecker(options) {
  if (options && typeof options !== 'object') {
    throw new TypeError('options must be an object');
  }

  options = {
    in: process.stdin,
    out: process.stdout,
    err: process.stderr,
    ...options,
  };

  if (!options.in || typeof options.in.read !== 'function') {
    throw new TypeError('options.in must be a stream.Readable');
  }
  if (!options.out || typeof options.out.write !== 'function') {
    throw new TypeError('options.out must be a stream.Writable');
  }
  if (!options.err || typeof options.err.write !== 'function') {
    throw new TypeError('options.err must be a stream.Writable');
  }

  this._options = options;
  this._chalk = new Chalk({
    level:
      options.interactive === true ? 1
        : options.interactive === false ? 0
          : options.out.isTTY ? 1
            : 0,
  });
}

/** Message returned when the repository slug is invalid.
 * @const
 */
GitStatusChecker.SLUG_INVALID =
  'GitHub repo name is invalid, it should be on the form \'owner/repo\'';

/** RegExp used to test the validity of a repository slug.
 * Note:  travis.rb only checks for a '/' character, but GitHub currently
 * requires only ASCII letters, numbers, '.', and '-'.  Choose a middle ground
 * which catches likely erroneous names.
 * @const
 */
GitStatusChecker.SLUG_VALID_RE = /^[^\s/]+\/[^\s/]+$/;

/** Git configuration option name in which the Travis CI slug is stored.
 * @const
 */
GitStatusChecker.SLUG_CONFIG_NAME = 'travis.slug';

/** Checks that a repository slug has the correct format.
 * @param {string} slug Slug to check.
 * @return {string} <code>slug</code>.
 * @throws {InvalidSlugError} If <code>slug</code> does not have the correct
 * format.
 * @private
 */
GitStatusChecker.checkSlugFormat = function checkSlugFormat(slug) {
  if (!GitStatusChecker.SLUG_VALID_RE.test(slug)) {
    const err = new InvalidSlugError(GitStatusChecker.SLUG_INVALID);
    err.slug = slug;
    throw err;
  }
  return slug;
};

/** Resolves a named git commit to its hash id.
 * @param {string} commitName Name of the commit (branch, tag, etc.) for which
 * to get the hash.  Can be a hash, which resolves to itself.
 * @return {!Promise<string>} Hash for <code>commitName</code>, or Error if
 * <code>commitName</code> does not name a commit.
 * @private
 */
GitStatusChecker.prototype.resolveHash = function resolveHash(commitName) {
  return git('rev-parse', '--verify', commitName)
    .then(getTrimmedStdout);
};

/** Stores a repository slug as <code>travis.slug</code> in the local git
 * config.
 * @param {string} slug Slug to store.
 * @return {!Promise<string>} Promise with <code>slug</code>.
 * @private
 */
GitStatusChecker.prototype.storeSlug = function storeSlug(slug) {
  try {
    GitStatusChecker.checkSlugFormat(slug);
  } catch (err) {
    return Promise.reject(err);
  }

  return git('config', GitStatusChecker.SLUG_CONFIG_NAME, slug)
    .then(() => slug);
};

/** Tries to store a repository slug as <code>travis.slug</code> in the local
 * git config, prints an error on failure.
 * @param {string} slug Slug to store.
 * @return {!Promise<string>} Promise with <code>slug</code>, never
 * <code>Error</code>.
 * @private
 */
GitStatusChecker.prototype.tryStoreSlug = function tryStoreSlug(slug) {
  const { err } = this._options;
  return this.storeSlug(slug)
    .catch((errStore) => {
      err.write(`Error storing slug in git config: ${errStore}`);
      return slug;
    });
};

/** Prompts the user to confirm that a repository slug is correct.
 * @param {string} slug Slug to prompt about.
 * @return {!Promise<string>} Promise with correct <code>slug</code>.
 * @private
 */
GitStatusChecker.prototype.confirmSlug = function confirmSlug(slug) {
  const shortline = new Shortline({
    input: this._options.in,
    output: this._options.err,
  });
  const question = `Detected repository as ${this._chalk.yellow(slug)
  } is this correct? `;
  return shortline.agree(question)
    .then((correct) => {
      if (correct) {
        return slug;
      }

      // Note:  travis.rb accepts an invalid slug here then errors out after
      // storing it in .git/config.  We re-prompt until valid.
      return shortline.ask('Repository slug (owner/name): ', {
        default: slug,
        responses: {
          notValid: GitStatusChecker.SLUG_INVALID,
        },
        trim: true,
        validate: GitStatusChecker.SLUG_VALID_RE,
      });
    });
};

/** Detects the current branch the current repository.
 * @return {!Promise<string>} The name of the current branch in the current
 * repository, or Error.
 * @private
 */
GitStatusChecker.prototype.detectBranch = function detectBranch() {
  return git('symbolic-ref', '-q', '--short', 'HEAD')
    .then(
      getTrimmedStdout,
      (err) => {
        const errMsg = `Unable to determine current branch: ${
          err.stderr || 'detached HEAD'}`;
        return Promise.reject(new Error(errMsg));
      },
    );
};

/** Gets the pathname portion of a git URL.
 * @param {string} gitUrl Git URL to parse.
 * @return {string} Pathname portion of the URL.
 * @private
 */
function gitUrlPathname(gitUrl) {
  // Strip explicit transport from "foreign URL"
  // See https://git-scm.com/docs/git-remote-helpers
  // Matches parsing in transport_get in transport.c
  gitUrl = gitUrl.replace(/^([A-Za-z0-9][A-Za-z0-9+.-]*)::/, '');

  // Try parsing as a typical URL
  try {
    return new URL(gitUrl).pathname;
  } catch (err) {
    // Invalid URL, continue
  }

  // Try SCP-like syntax.  Host can be wrapped in [] to disambiguate path.
  // See parse_connect_url and host_end in connect.c
  const scpParts = /^([^@/]+)@(\[[^]\/]+\]|[^:/]+):(.*)$/.exec(gitUrl);
  if (scpParts) {
    return scpParts[3];
  }

  // Assume URL is a local path
  return gitUrl;
}

/** Detects the repository slug for the current repository.
 * @return {!Promise<string>} The slug value for the current repository.
 * @private
 */
GitStatusChecker.prototype.detectSlug = function detectSlug() {
  const self = this;
  const slugP = this.detectBranch()
    .then((branch) => {
      const configName = `branch.${branch}.remote`;
      return git('config', '--get', configName)
        .then(getTrimmedStdout);
    })
    .catch((err) => {
      debug('Unable to get remote for current branch', err);
      return 'origin';
    })
    .then((remoteName) => git('ls-remote', '--get-url', remoteName)
      .then(getTrimmedStdout)
      .then((remoteUrl) => {
        // ls-remote prints its argument when it doesn't have a URL
        if (remoteUrl === remoteName) {
          return Promise.reject(new SlugDetectionError(
            `No URL for '${remoteName}' remote`,
          ));
        }
        return remoteUrl;
      }))
    .then((remoteUrl) => {
      const path = gitUrlPathname(remoteUrl);
      const match = /([^/]+\/[^/]+?)(?:\/?\.git)?$/.exec(path);
      return match
        ? match[1]
        : Promise.reject(new SlugDetectionError(
          `Unable to extract slug from URL <${remoteUrl}>`,
        ));
    });

  return slugP.then((slug) => {
    if (self._options.interactive) {
      return self.confirmSlug(slug);
    }

    self._options.err.write(`detected repository as ${
      self._chalk.bold(slug)}\n`);
    return slug;
  });
};

/** Loads the repository slug from <code>travis.slug</code> in the git config.
 * @return {!Promise<?string>} The slug value for the current repository, or
 * <code>null</code> if not configured.
 * @private
 */
GitStatusChecker.prototype.loadSlug = function loadSlug() {
  return git('config', '--get', GitStatusChecker.SLUG_CONFIG_NAME)
    .then(
      getTrimmedStdout,
      // git config exits with code 1 if the configuration value is not set
      (err) => (err.code === 1 ? null : Promise.reject(err)),
    );
};

/** Finds the repository slug for the current repository by either loading it
 * from the stored configuration or detecting it from the current repository,
 * stores it if confirmed interactively.
 * @return {!Promise<string>} The slug value for the current repository.
 * @private
 */
GitStatusChecker.prototype.findSlug = function findSlug() {
  const self = this;
  return this.loadSlug().then((slug) => {
    if (slug) {
      return slug;
    }

    return self.detectSlug();
  });
};


module.exports = GitStatusChecker;