diff --git a/index.js b/index.js index d2a0ddb..85fc96e 100644 --- a/index.js +++ b/index.js @@ -1,30 +1,58 @@ -'use strict'; -var exec = require('child_process').exec; -var semverValid = require('semver').valid; -var regex = /tag:\s*(.+?)[,\)]/gi; -var cmd = 'git log --decorate --no-color'; - -module.exports = function(callback) { - exec(cmd, { - maxBuffer: Infinity - }, function(err, data) { - if (err) { - callback(err); - return; - } +var semver = require('semver'); + +var findIndexByHash = require('./lib/find-index-by-hash'); +var getCommits = require('./lib/get-commits'); +var getSemverTags = require('./lib/get-semver-tags'); +var loadRepository = require('./lib/load-repository'); - var tags = []; +/** + * Get semantic version git tags of repository at process.cwd() + * + * @param {MainCallback} callback function to execute after information retrieval + */ +function gitSemverTags(callback) { + // Initialize repository + loadRepository(function(error, repository) { + if (error) { + return callback(error); + } - data.split('\n').forEach(function(decorations) { - var match; - while (match = regex.exec(decorations)) { - var tag = match[1]; - if (semverValid(tag)) { - tags.push(tag); - } + // Get a list of commits + getCommits(repository, function(error, commits) { + /* istanbul ignore if */ + if (error) { + return callback(error); } - }); - callback(null, tags); + // Get a list of tags matching semver pattern + var tagNames = getSemverTags(repository) + .sort(function(aTag, bTag) { + // if tags reference same hash sort descending by semantic version + if (aTag.hash === bTag.hash) { + return semver.compare(bTag.name, aTag.name); + } + + // sort tags descending by occurence in commits list + var aCommit = findIndexByHash(commits, aTag.hash); + var bCommit = findIndexByHash(commits, bTag.hash); + return bCommit - aCommit; + }) + // map out the name + .map(function(tag) { + return tag.name; + }); + + return callback(null, tagNames); + }); }); }; + +module.exports = gitSemverTags; + +/** + * Main callback executed after all information has been collected + * + * @typedef {function} MainCallback + * @param {(Error|null)} error - encountered error, if any + * @param {array} [tags] - semantic git tags of repository at process.cwd() + */ diff --git a/lib/find-index-by-hash.js b/lib/find-index-by-hash.js new file mode 100644 index 0000000..c85c19c --- /dev/null +++ b/lib/find-index-by-hash.js @@ -0,0 +1,23 @@ +/** + * Find index of item with item.hash === hash + * + * @param {array} commits haystack of commits to search in + * @param {string} hash hash to match commits against + * @private + */ +function findIndexByHash(commits, hash) { + // Get first commit matching hash + var match = commits.filter(function(commit) { + return commit.hash === hash; + })[0]; + + // No index to find if no match was found + if (!match) { + return -1; + } + + // Return the index of match in haystack + return commits.indexOf(match); +} + +module.exports = findIndexByHash; diff --git a/lib/get-commit-human.js b/lib/get-commit-human.js new file mode 100644 index 0000000..d08f2f0 --- /dev/null +++ b/lib/get-commit-human.js @@ -0,0 +1,28 @@ +var human = require('git-parse-human'); + +/** + * Get human object for commit tagger, author or committer + * + * @param {GitCommit} commit + * @returns {GitHuman} + * @private + */ +function getCommitHuman(commit) { + // Use getter for raw information about author of commit, where author is + // the tagger, author or committer in said precedence + var getter = commit.tagger || commit.author || commit.committer; + var bound = getter.bind(commit); + // Parse raw author information into commit "human" object + return human(bound()); +} + +module.exports = getCommitHuman; + +/** + * @typedef GitHuman + * @property {string} name + * @property {string} email + * @property {number} time unix epoch commit timestamp in milliseconds + * @property {number} tzoff time zone offset in milliseconds + * @see https://github.com/chrisdickinson/git-parse-human + */ diff --git a/lib/get-commit-timestamp.js b/lib/get-commit-timestamp.js new file mode 100644 index 0000000..9a19f01 --- /dev/null +++ b/lib/get-commit-timestamp.js @@ -0,0 +1,17 @@ +var getCommitHuman = require('./get-commit-human'); + +/** + * Get timestamp for commit + * + * @param {GitCommit} commit + * @returns {number} commit unix epoch timestamp in milliseconds with time zone offset + * @private + */ +function getCommitTimeStamp(commit) { + // get a commiter object from commit object + var committer = getCommitHuman(commit); + // calculate the time zone offset in + return committer.time + committer.tzoff; +} + +module.exports = getCommitTimeStamp; diff --git a/lib/get-commits.js b/lib/get-commits.js new file mode 100644 index 0000000..c3c7cfb --- /dev/null +++ b/lib/get-commits.js @@ -0,0 +1,50 @@ +var walk = require('git-walk-refs'); + +/** + * Get commits for repository in reverse chronological order + * + * @param {!GitRepository} repository git repository + * @param {!CommitsCallback} callback function to execute after information retrieval + * @private + */ +function getCommits(repository, callback) { + var commits = []; // results array + + // Get an array of refs + var head = repository.refs().map(function(ref) { + return ref.hash; + }); + + // Walk the git history for each ref + walk(repository.find, head) + .on('error', callback) + .on('end', function() { + // return list in inverse chronological order + return callback(null, commits.reverse()); + }) + .on('data', function(data) { + // add commit entry to results array + commits.push({ + hash: data.hash, + message: data.message() + }); + }); +} + +module.exports = getCommits; +/** + * @callback CommitsCallback + * @param {?Error} error error encountered if any + * @param {GitCommit[]} [commits] commits in inverse chronological order + * @private + */ + +/** + * @typedef GitCommit + * @property {function?} author + * @property {function?} committer + * @property {function?} tagger + * @property {!string} hash + * @see https://github.com/chrisdickinson/git-walk-refs + * @private + */ diff --git a/lib/get-semver-tags.js b/lib/get-semver-tags.js new file mode 100644 index 0000000..44c6e36 --- /dev/null +++ b/lib/get-semver-tags.js @@ -0,0 +1,21 @@ +var semver = require('semver'); +var getTags = require('./get-tags'); + +/** + * Get git tags of repository matching semantic version pattern + * + * @param {GitRepository} repository + * @returns {GitTag[]} + * @see https://github.com/npm/node-semver#functions + * @private + */ +function getSemverTags(repository) { + // Get all tags + return getTags(repository) + // Filter for valid semver tags + .filter(function(tag) { + return semver.valid(tag.name); + }); +} + +module.exports = getSemverTags; diff --git a/lib/get-tags.js b/lib/get-tags.js new file mode 100644 index 0000000..83bb26f --- /dev/null +++ b/lib/get-tags.js @@ -0,0 +1,31 @@ +/** + * Get tags for repository + * + * @param {!GitRepository} repository + * @returns {GitTag[]} + * @private + */ +function getTags(repository) { + // Get all references + return repository.refs(false) + // Filter for references matching git tag ref paths + .filter(function(ref){ + return ref.name.indexOf('refs/tags/') === 0; + }) + // Pick relevant information + .map(function(tag){ + return { + name: tag.name.replace(/^refs\/tags\//, ''), + hash: tag.hash + }; + }); +} + +module.exports = getTags; + +/** + * @typedef GitTag + * @property name git tag name + * @property hash git hash referenced by this tag + * @private + */ diff --git a/lib/load-repository.js b/lib/load-repository.js new file mode 100644 index 0000000..5a4a909 --- /dev/null +++ b/lib/load-repository.js @@ -0,0 +1,51 @@ +var path = require('path'); +var gitFsRepo = require('git-fs-repo'); + +/** + * Initialize a git repository + * + * @param {RepositoryCallback} callback + * @private + */ +function loadRepository(callback) { + // Use $cwd/.git as git database directory + var gitDirectory = path.join(process.cwd(), '.git'); + + // Initialize a js-backed representation of the local git repository + gitFsRepo(gitDirectory, function(error, gitRepository) { + /* istanbul ignore if */ + if (error) { + return callback(error); + } + + // get the current head object + var head = gitRepository.ref('HEAD'); + + // if there is no head object this repository most likely has no commits + if (!head) { + var headErrors = new Error('Git repository has no HEAD, are there any commits?'); + return callback(headErrors); + } + + callback(null, gitRepository); + }); +} + +module.exports = loadRepository; + +/** + * @typedef {Object} GitRepository + * @property {function} ref + * @property {function} refs + * @see https://github.com/chrisdickinson/git-fs-repo + * @private + */ + +/** + * Main callback executed after all information has been collected + * + * @callback RepositoryCallback + * @param {(Error|null)} error - encountered error, if any + * @param {GitRepositpry} [gitRepository] + * @private + */ diff --git a/package.json b/package.json index 3000d47..c775521 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "repository": "stevemao/git-semver-tags", "license": "MIT", "files": [ + "lib", "index.js", "cli.js" ], @@ -23,6 +24,10 @@ "git" ], "dependencies": { + "async": "^2.0.0-rc.5", + "git-fs-repo": "0.0.5", + "git-parse-human": "0.0.1", + "git-walk-refs": "0.0.2", "meow": "^3.3.0", "semver": "^5.0.1" }, @@ -37,7 +42,7 @@ }, "scripts": { "coverage": "istanbul cover _mocha -- -R spec && rm -rf ./coverage", - "lint": "jshint *.js --exclude node_modules && jscs *.js", + "lint": "jshint **/*.js --exclude node_modules && jscs *.js", "test": "npm run-script lint && mocha --timeout 10000" }, "bin": { diff --git a/test.js b/test.js index 8aa2d63..acfb18a 100644 --- a/test.js +++ b/test.js @@ -1,6 +1,7 @@ 'use strict'; var assert = require('assert'); var equal = assert.deepEqual; +var findIndexByHash = require('./lib/find-index-by-hash'); var gitSemverTags = require('./'); var shell = require('shelljs'); var writeFileSync = require('fs').writeFileSync; @@ -12,104 +13,125 @@ shell.mkdir('tmp'); shell.cd('tmp'); shell.exec('git init'); -it('should error if no commits found', function(done) { - gitSemverTags(function(err) { - assert(err); +describe('find-index-by-hash', function() { + it('should return -1 for an empty array', function() { + var actual = findIndexByHash([], null); + var expected = -1; - done(); + equal(actual, expected); + }); + + it('should return the first match index', function() { + var actual = findIndexByHash([ + {hash: 1}, + {hash: 1} + ], 1); + var expected = 0; + + equal(actual, expected); }); }); -it('should get no semver tags', function(done) { - writeFileSync('test1', ''); - shell.exec('git add --all && git commit -m"First commit"'); - shell.exec('git tag foo'); +describe('git-semver-tags', function() { + it('should error if no commits found', function(done) { + gitSemverTags(function(err) { + assert(err); - gitSemverTags(function(err, tags) { - equal(tags, []); + done(); + }); + }); - done(); + it('should get no semver tags', function(done) { + writeFileSync('test1', ''); + shell.exec('git add --all && git commit -m"First commit"'); + shell.exec('git tag foo'); + + gitSemverTags(function(err, tags) { + equal(tags, []); + + done(); + }); }); -}); -it('should get the semver tag', function(done) { - writeFileSync('test2', ''); - shell.exec('git add --all && git commit -m"Second commit"'); - shell.exec('git tag v2.0.0'); - writeFileSync('test3', ''); - shell.exec('git add --all && git commit -m"Third commit"'); - shell.exec('git tag va.b.c'); + it('should get the semver tag', function(done) { + writeFileSync('test2', ''); + shell.exec('git add --all && git commit -m"Second commit"'); + shell.exec('git tag v2.0.0'); + writeFileSync('test3', ''); + shell.exec('git add --all && git commit -m"Third commit"'); + shell.exec('git tag va.b.c'); - gitSemverTags(function(err, tags) { - equal(tags, ['v2.0.0']); + gitSemverTags(function(err, tags) { + equal(tags, ['v2.0.0']); - done(); + done(); + }); }); -}); -it('should get both semver tags', function(done) { - shell.exec('git tag v3.0.0'); + it('should get both semver tags', function(done) { + shell.exec('git tag v3.0.0'); - gitSemverTags(function(err, tags) { - equal(tags, ['v3.0.0', 'v2.0.0']); + gitSemverTags(function(err, tags) { + equal(tags, ['v3.0.0', 'v2.0.0']); - done(); + done(); + }); }); -}); -it('should get all semver tags if two tags on the same commit', function(done) { - shell.exec('git tag v4.0.0'); + it('should get all semver tags if two tags on the same commit', function(done) { + shell.exec('git tag v4.0.0'); - gitSemverTags(function(err, tags) { - equal(tags, ['v4.0.0', 'v3.0.0', 'v2.0.0']); + gitSemverTags(function(err, tags) { + equal(tags, ['v4.0.0', 'v3.0.0', 'v2.0.0']); - done(); + done(); + }); }); -}); -it('should still work if I run it again', function(done) { - gitSemverTags(function(err, tags) { - equal(tags, ['v4.0.0', 'v3.0.0', 'v2.0.0']); + it('should still work if I run it again', function(done) { + gitSemverTags(function(err, tags) { + equal(tags, ['v4.0.0', 'v3.0.0', 'v2.0.0']); - done(); + done(); + }); }); -}); -it('should be in reverse chronological order', function(done) { - writeFileSync('test4', ''); - shell.exec('git add --all && git commit -m"Fourth commit"'); - shell.exec('git tag v1.0.0'); + it('should be in reverse chronological order', function(done) { + writeFileSync('test4', ''); + shell.exec('git add --all && git commit -m"Fourth commit"'); + shell.exec('git tag v1.0.0'); - gitSemverTags(function(err, tags) { - equal(tags, ['v1.0.0', 'v4.0.0', 'v3.0.0', 'v2.0.0']); + gitSemverTags(function(err, tags) { + equal(tags, ['v1.0.0', 'v4.0.0', 'v3.0.0', 'v2.0.0']); - done(); + done(); + }); }); -}); -it('should work with prerelease', function(done) { - writeFileSync('test5', ''); - shell.exec('git add --all && git commit -m"Fifth commit"'); - shell.exec('git tag 5.0.0-pre'); + it('should work with prerelease', function(done) { + writeFileSync('test5', ''); + shell.exec('git add --all && git commit -m"Fifth commit"'); + shell.exec('git tag 5.0.0-pre'); - gitSemverTags(function(err, tags) { - equal(tags, ['5.0.0-pre', 'v1.0.0', 'v4.0.0', 'v3.0.0', 'v2.0.0']); + gitSemverTags(function(err, tags) { + equal(tags, ['5.0.0-pre', 'v1.0.0', 'v4.0.0', 'v3.0.0', 'v2.0.0']); - done(); + done(); + }); }); -}); -it('should work with empty commit', function(done) { - shell.rm('-rf', '.git'); - shell.exec('git init'); - gitDummyCommit('empty commit'); - shell.exec('git tag v1.1.0'); - gitDummyCommit('empty commit2'); - gitDummyCommit('empty commit3'); + it('should work with empty commit', function(done) { + shell.rm('-rf', '.git'); + shell.exec('git init'); + gitDummyCommit('empty commit'); + shell.exec('git tag v1.1.0'); + gitDummyCommit('empty commit2'); + gitDummyCommit('empty commit3'); - gitSemverTags(function(err, tags) { - equal(tags, ['v1.1.0']); + gitSemverTags(function(err, tags) { + equal(tags, ['v1.1.0']); - done(); + done(); + }); }); });