UNPKG

fedtools-utilities

Version:
718 lines (671 loc) 23.9 kB
/** * Provides the git-helper class. * * @class git-helper * @static * @module fedtools-utilities */ var _ = require('lodash'), async = require('async'), fs = require('fs'), log = require('fedtools-logs'), cmd = require('fedtools-commands'), _runGitCommand; // -- P R I V A T E M E T H O D S /** * Helper method to execute a git command and trap the result. * * @method _runGitCommand * @private * @async * @param {String} cmdline A git command line. * @param {Object} options * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {Object} done.res An object with 2 keys: stdout and stderr. * @param {String} res.stdout The resulting success output string without * the trailing \n. * @param {String} res.stderr The resulting error output string without * the trailing \n. * @param {String} res.elapsedTime The time it took to run the command in ms. */ exports.runGitCommand = _runGitCommand = function (cmdline, options, done) { var pwd = process.cwd(), status = false, verbose = false, startTime = new Date(); if (_.isFunction(options)) { done = options; throw new Error('options is a function, expected an object'); } else { pwd = options.cwd || process.cwd(); status = (_.isBoolean(options.silent)) ? !options.silent : false; verbose = (options.verbose) ? true : false; } async.waterfall([ function (callback) { if (options.cwd) { if (fs.existsSync(options.cwd)) { return callback(); } else { return callback(1); } } else { return callback(); } } ], function (err) { if (!err) { cmd.run(cmdline, { pwd: pwd, status: status, verbose: verbose }, function (error, stderr, stdout) { if (!error && stdout) { stdout = stdout.toString().replace(/\n$/, ''); } if (error && stderr) { stderr = stderr.toString().replace(/\n$/, ''); } return done(error, { stdout: stdout, stderr: stderr, elapsedTime: new Date() - startTime }); }); } else { return done(err, { elapsedTime: new Date() - startTime }); } }); }; /** * Helper method to extract commit messages between two tags. * * @method _getCommitBetweenTags * @private * @async * @param {Object} options * @param {String} options.tagA The starting tag. * @param {String} options.tagB The ending tag. * @param {String|Array} [options.ignore] An array (or a single string) of pattern to * match commit messages. If matched, the commit will be ignored. * @param {Boolean} [options.longSHA] If true, use the long SHA instead of short. * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * @param {String} [options.commitTemplate] Template for the commit message. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {Array} done.output An array of message (for each commit). */ function _getCommitBetweenTags(options, done) { var res, ignore, result, cmdline = 'git log ' + options.tagA + '..' + options.tagB; if (options.commitTemplate) { cmdline += ' --pretty=format:"' + options.commitTemplate + '"'; } else if (options.longSHA) { cmdline += ' --pretty=format:" %H [%an]: %s"'; } else { cmdline += ' --pretty=format:" %h [%an]: %s"'; } if (options.ignore) { ignore = (_.isArray(options.ignore)) ? new RegExp(options.ignore.join('|')) : new RegExp( options.ignore); } res = cmd.run(cmdline, { pwd: process.cwd(), status: false, verbose: false }); if (res.code === 0) { result = res.output.split('\n'); if (ignore) { result = _.filter(result, function (item) { if (item.match(ignore)) { return false; } else { return true; } }); } return done(null, result); } else { return done(res.code, res.output); } } /** * Finds all the tags of a git repository. Result can be narrowed by specifying * a starting tag or an ending tag (or both). * * @method getAllTags * @private * @async * @param {Object} options * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {String} [options.since] The first tag (non-included) to list. * @param {String} [options.until] The last tag (included) to list. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {Array} done.result An array of objects with 2 keys: `tag` and * the corresponding `timestamp`. * * @example * _getAllTags({ * since: '0.0.92', * until: '0.0.99' * }, function(err, tags) { * _.each(tags, function(item) { * console.log('tag: ', item.tag); * console.log('date: ', item.timestamp); * }); * }); */ function _getAllTags(options, done) { var SEPARATOR = '~', cmdline = 'git log --tags --simplify-by-decoration ' + '--pretty=format:"%ai' + SEPARATOR + '%d"', _mapTimeAndTag, _filterTags, semver = require('semver'); _mapTimeAndTag = function (a, done) { var timestamp, tag, b; a = a.replace(/\"/g, ''); b = a.split(SEPARATOR); timestamp = b[0]; tag = b[1].replace(/\(/g, '').replace(/\)/g, '').replace(/tag:/g, '').trim(); if (tag.match(/^HEAD/)) { tag = tag.split(',')[1].trim(); } if (tag && semver.valid(tag)) { return done(null, { tag: tag, timestamp: timestamp }); } else { return done(null, null); } }; _filterTags = function (tagList, since, until) { var res = tagList; if (since && semver.valid(since) && until && semver.valid(until)) { if (semver.gt(since, until)) { return res; } } if (since && semver.valid(since)) { res = _.filter(res, function (item) { if (semver.clean(item.tag) === since) { return true; } else { return semver.gt(item.tag, since); } }); } if (until && semver.valid(until)) { res = _.filter(res, function (item) { if (semver.clean(item.tag) === until) { return true; } else { return semver.lt(item.tag, until); } }); } return res; }; _runGitCommand(cmdline, options, function (err, res) { var data = res.stdout.split('\n'); log.debug('_runGitCommand err: ', err); async.mapSeries(data, _mapTimeAndTag, function (err, result) { if (!err) { return done(err, _filterTags(_.compact(result), options.since, options.until)); } else { return done(err, result); } }); }); } // -- P U B L I C M E T H O D S /** * Extract the most recent local SHA. * * @method getCurrentSHA * @async * @param {Object} options * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {String} [options.short=false] Flag to get the short or full SHA. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {String} done.stdOut The resulting output string without the trailing \n */ exports.getCurrentSHA = function (options, done) { var cmdline = (options && options.short) ? 'git log --pretty=format:%h -1' : 'git log --pretty=format:%H -1'; _runGitCommand(cmdline, options, done); }; /** * Checks out a branch (fetching before in case the branch is not known locally yet). * * Runs `git fetch && git checkout <branch>` * * @method checkoutBranch * @async * @param {Object} options * @param {String} options.branch The branch to checkout. * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {String} done.stdOut The resulting output string without the trailing \n */ exports.checkoutBranch = function (options, done) { _runGitCommand('git fetch', options, function () { _runGitCommand('git checkout ' + options.branch, options, done); }); }; /** * Hard reset a repository. * * Runs `git reset --hard` * * @method hardResetRepository * @async * @param {Object} options * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {String} done.stdOut The resulting output string without the trailing \n */ exports.hardResetRepository = function (options, done) { _runGitCommand('git reset --hard', options, done); }; /** * Clones a git repository. * * Runs `git clone [options]` * * @method cloneGitRepository * @async * @param {Object} options * @param {String} options.url The git repository URL. * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {String} [options.cloneArgs] Possible options to be passed to the clone command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {String} done.stdOut The resulting output string without the trailing \n */ exports.cloneGitRepository = function (options, done) { var cmdClone = 'git clone'; if (options && options.cloneArgs) { cmdClone = cmdClone.trim() + ' ' + options.cloneArgs.trim(); } _runGitCommand(cmdClone.trim() + ' ' + options.url.trim() + ' ' + (options.name.trim() || ''), options, done); }; /** * Runs `git fetch` * * @method gitFetchLatestFromOrigin * @async * @param {Object} options * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {String} done.stdOut The resulting output string without the trailing \n */ exports.gitFetchLatestFromOrigin = function (options, done) { _runGitCommand('git fetch', options, done); }; /** * Add the provided URL as the 'upstream' remote. * * Runs `git remote add upstream options.url` * * @method gitAddUpstreamRemote * @async * @param {Object} options * @param {String} options.url The upstream URL. * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {String} done.stdOut The resulting output string without the trailing \n */ exports.gitAddUpstreamRemote = function (options, done) { _runGitCommand('git remote add upstream ' + options.url, options, done); }; /** * Checks if the current path is a git repository. * * Runs `git symbolic-ref HEAD` * * @method isGitRepository * @async * @param {Object} options * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {String} done.stdOut The resulting output string without the trailing \n */ exports.isGitRepository = function (options, done) { _runGitCommand('git symbolic-ref HEAD', options, done); }; /** * Checks if the current git repository is dirty. * * Runs `git describe --dirty` * * @method isGitRepositoryDirty * @async * @param {Object} options * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {Boolean} done.dirty A flag indicating if the repo is dirty. */ exports.isGitRepositoryDirty = function (options, done) { _runGitCommand('git describe --dirty', options, function (err, data) { if (!err && data && data.stdout && data.stdout.match('-dirty')) { return done(null, true); } else { return done(); } }); }; /** * Find the current branch of a git repository. * * Runs `git rev-parse --abbrev-ref HEAD` * * @method getCurrentBranch * @async * @param {Object} options * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {String} done.stdOut The resulting output string without the trailing \n */ exports.getCurrentBranch = function (options, done) { _runGitCommand('git rev-parse --abbrev-ref HEAD', options, done); }; /** * Finds the root path of a git repository. * * Runs `git rev-parse --show-toplevel` * * @method findGitRootPath * @async * @param {Object} options * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {String} done.stdOut The resulting output string without the trailing \n */ exports.findGitRootPath = function (options, done) { _runGitCommand('git rev-parse --show-toplevel', options, done); }; /** * Finds the most recent tag of a given repository. * * Runs `git for-each-ref --count 1 --format="ref=%(refname)" --sort='-*authordate' refs/tags` * * @method getLatestTag * @async * @param {Object} options * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {Object} done.res An object with 2 keys: stdout and stderr. * @param {String} res.stdout The resulting success output string without * the trailing \n. * @param {String} res.stderr The resulting error output string without * the trailing \n. */ exports.getLatestTag = function (options, done) { var r, stdout, stderr; _runGitCommand('git for-each-ref --count 1 --format="ref=%(refname)" --sort=\'-*authordate\' refs/tags', options, function (err, res) { stderr = (res) ? res.stderr : null; if (!err && res.stdout) { r = res.stdout.replace(/"/g, '').split('/'); stdout = r.pop(); } return done(err, { stdout: stdout, stderr: stderr }); }); }; /** * Finds all the tags of a given repository. * * Runs `git for-each-ref --format="ref=%(refname), %(*authorname), %(taggerdate:short), %(*subject)" --sort='-*authordate' refs/tags` * * @method getAllTags * @async * @param {Object} options * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {Array} done.data An array of tags. */ exports.getAllTags = function (options, done) { var cmdline = []; // command to get the most recent commit SHA cmdline[0] = 'git rev-parse HEAD'; // command to get all the tags cmdline[1] = 'git tag -l --sort=-version:refname'; // command to get the oldest commit SHA cmdline[2] = 'git rev-list --max-parents=0 HEAD'; async.waterfall([ function (callback) { cmd.run(cmdline[0], { status: false }, function (err, stderr, stdout) { if (!err && stdout) { return callback(err, [stdout.replace('\n', '')]); } else { return callback(err); } }); }, function (data, callback) { cmd.run(cmdline[1], { status: false }, function (err, stderr, stdout) { if (!err && stdout) { data.push(stdout.split('\n')); return callback(err, data); } else { return callback(err, stderr); } }); }, function (data, callback) { cmd.run(cmdline[2], { status: false }, function (err, stderr, stdout) { if (!err && stdout) { data.push( stdout.replace('\n', '') ); } callback(null, data); }); } ], function (err, data) { done(err, _.compact(_.flatten(data))); }); }; /** * Generates a CHANGELOG type of output for a given repository. * The default templates are: * - tplHistoryDate: `\n__{{tag}} / {{timestamp}}__\n\n` * - tplHistoryCommit: ` {{commitMsg}}\n` (with 4 spaces to start with) * * @method getChangeLog * @async * @param {Object} options * @param {String} [options.tplHistoryDate] * A mustache template for the headers. Available keys are `tag` and `timestamp`. * @param {String} [options.tplHistoryCommit] * A mustache template for the commit messages. Available keys are `commitMsg`. * @param {String} [options.dateFormat='YYYY-MM-DD'] * A date format (see momentjs.com). * @param {String|Array} [options.ignore] An array (or a single string) of pattern to * match commit messages. If matched, the commit will be ignored. * @param {Boolean} [options.longSHA] If true, use the long SHA instead of short. * @param {String} [options.since] Start changelog listing after this tag. * @param {String} [options.until] End changelog listing at this tag. * @param {String} [options.cwd=process.cwd()] The path where to run the command. * @param {Boolean} [options.silent=false] If true, do no log the command line. * @param {Boolean} [options.verbose=false] If true, log stderr/stdout. * * @param {Function} done Callback to execute when done. It gets 2 arguments: * @param {String} done.error Not null if there is a problem with the command. * @param {String} done.stdOut The resulting output string without the trailing \n * */ exports.getChangeLog = function (options, done) { var mustache = require('mustache'), moment = require('moment'), async = require('async'), result = '', _extractCommitsBetweenTags, _updateTemplates, tplHistoryDate, tplHistoryCommit, dateFormat; tplHistoryDate = options.tplHistoryDate || '\n__{{tag}} / {{timestamp}}__\n\n'; tplHistoryCommit = options.tplHistoryCommit || '{{{commitMsg}}}\n'; dateFormat = options.dateFormat || 'YYYY-MM-DD'; _updateTemplates = function (cfg, callback) { var tagHeader, commitsList = ''; tagHeader = mustache.render(tplHistoryDate, { tag: cfg.tags[cfg.index].tag, timestamp: moment(new Date(cfg.tags[cfg.index].timestamp)).format(dateFormat) }); _.each(cfg.commits, function (commit) { commitsList += mustache.render(tplHistoryCommit, { commitMsg: commit }); }); if (commitsList !== '') { result += tagHeader; result += commitsList; } callback(); }; _extractCommitsBetweenTags = function (a, b, done) { _getCommitBetweenTags({ tagA: a.tag, tagB: b.tag, ignore: options.ignore, longSHA: options.longSHA || false, commitTemplate: options.commitTemplate }, function (err, commits) { log.debug('_getCommitBetweenTags err: ', err); done(null, commits); }); }; async.waterfall([ function (callback) { _getAllTags(options, function (err, tags) { callback(err, tags); }); }, function (tags, callback) { /*jshint loopfunc:true*/ // need to extract all commits between all tags... var i, jobs = [], len = tags.length; jobs[0] = function (cb) { _extractCommitsBetweenTags(tags[1], tags[0], function (err, commits) { log.debug('_extractCommitsBetweenTags err: ', err); _updateTemplates({ tags: tags, index: 0, commits: commits }, function () { cb(null, tags, 1); }); }); }; for (i = 1; i <= len; i++) { if (tags[i + 1]) { jobs.push( function (tagList, param, cb) { _extractCommitsBetweenTags(tags[param + 1], tags[param], function (err, commits) { _updateTemplates({ err: err, tags: tagList, index: param, commits: commits }, function () { cb(null, tagList, param + 1); }); }); } ); } } async.waterfall(jobs, function (err) { callback(err); }); }, function (callback) { callback(null, 'done'); } ], function (err) { done(err, result); }); };