diff --git a/.npmignore b/.npmignore
new file mode 100644
index 0000000..64980ed
--- /dev/null
+++ b/.npmignore
@@ -0,0 +1,3 @@
+.*
+test
+Gruntfile.js
diff --git a/Gruntfile.js b/Gruntfile.js
index ca99b3d..f3edc3e 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -19,17 +19,26 @@ module.exports = function (grunt) {
tagName: 'v<%= version %>'
}
},
- changelog: {
+
+ simplemocha: {
options: {
- dest: 'CHANGELOG.md'
+ ui: 'bdd',
+ reporter: 'dot'
+ },
+ unit: {
+ src: [
+ 'test/**/*.coffee'
+ ]
}
}
});
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.loadNpmTasks('grunt-release');
+ grunt.loadNpmTasks('grunt-simple-mocha');
grunt.loadTasks('tasks');
- grunt.registerTask('default', ['jshint']);
+ grunt.registerTask('default', ['jshint', 'test']);
+ grunt.registerTask('test', ['simplemocha']);
};
diff --git a/lib/changelog.js b/lib/changelog.js
new file mode 100755
index 0000000..9d779ce
--- /dev/null
+++ b/lib/changelog.js
@@ -0,0 +1,232 @@
+// TODO(vojta): report errors, currently Q silence everything which really sucks
+// TODO(vojta): use grunt logger
+// TODO(vojta): nicer breaking changes (https://github.com/angular/angular.js/commit/07a58dd7669431d33b61f8c3213c31eff744d02a)
+// TODO(vojta): ignore "Merge pull request..." messages, or ignore them in git log ? (--no-merges)
+
+var child = require('child_process');
+var util = require('util');
+var q = require('qq');
+
+
+var GIT_LOG_CMD = 'git log --grep="%s" -E --format=%s %s..HEAD';
+var GIT_TAG_CMD = 'git describe --tags --abbrev=0';
+
+var EMPTY_COMPONENT = '$$';
+var MAX_SUBJECT_LENGTH = 80;
+
+var PATTERN = /^(\w*)(\(([\w\$\.\-\*]*)\))?\: (.*)$/;
+
+var warn = function() {
+ console.log('WARNING:', util.format.apply(null, arguments));
+};
+
+var log = function() {
+ console.log(util.format.apply(null, arguments));
+};
+
+
+var parseRawCommit = function(raw) {
+ if (!raw) {
+ return null;
+ }
+
+ var lines = raw.split('\n');
+ var msg = {}, match;
+
+ msg.hash = lines.shift();
+ msg.subject = lines.shift();
+ msg.closes = [];
+ msg.breaks = [];
+
+ lines.forEach(function(line) {
+ match = line.match(/(?:Closes|Fixes)\s#(\d+)/);
+ if (match) {
+ msg.closes.push(parseInt(match[1], 10));
+ }
+ });
+
+ match = raw.match(/BREAKING CHANGE:\s([\s\S]*)/);
+ if (match) {
+ msg.breaks.push(match[1]);
+ }
+
+
+ msg.body = lines.join('\n');
+ match = msg.subject.match(PATTERN);
+
+ if (!match || !match[1] || !match[4]) {
+ warn('Incorrect message: %s %s', msg.hash, msg.subject);
+ return null;
+ }
+
+ if (match[4].length > MAX_SUBJECT_LENGTH) {
+ warn('Too long subject: %s %s', msg.hash, msg.subject);
+ match[4] = match[4].substr(0, MAX_SUBJECT_LENGTH);
+ }
+
+ msg.type = match[1];
+ msg.component = match[3];
+ msg.subject = match[4];
+
+ return msg;
+};
+
+
+var currentDate = function() {
+ var now = new Date();
+ var pad = function(i) {
+ return ('0' + i).substr(-2);
+ };
+
+ return util.format('%d-%s-%s', now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate()));
+};
+
+
+var readGitLog = function(grep, from) {
+ log('Reading git log since', from);
+
+ var deffered = q.defer();
+
+ child.exec(util.format(GIT_LOG_CMD, grep, '%H%n%s%n%b%n==END==', from), function(code, stdout) {
+ var commits = [];
+
+ stdout.split('\n==END==\n').forEach(function(rawCommit) {
+ var commit = parseRawCommit(rawCommit);
+ if (commit) {
+ commits.push(commit);
+ }
+ });
+
+ log('Parsed %s commits', commits.length);
+
+ deffered.resolve(commits);
+ });
+
+ return deffered.promise;
+};
+
+
+var PATCH_HEADER_TPL = '\n### %s (%s)\n\n';
+var MINOR_HEADER_TPL = '\n## %s (%s)\n\n';
+var LINK_ISSUE = '[#%s](%s/issues/%s)';
+var LINK_COMMIT = '[%s](%s/commit/%s)';
+
+var Writer = function(stream, githubRepo) {
+
+ var linkToIssue = function(issue) {
+ return util.format(LINK_ISSUE, issue, githubRepo, issue);
+ };
+
+ var linkToCommit = function(hash) {
+ return util.format(LINK_COMMIT, hash.substr(0, 8), githubRepo, hash);
+ };
+
+ this.header = function(version) {
+ var header = version.split('.')[2] === '0' ? MINOR_HEADER_TPL : PATCH_HEADER_TPL;
+ stream.write(util.format(header, version, version, currentDate()));
+ };
+
+ this.section = function(title, section) {
+ var components = Object.getOwnPropertyNames(section).sort();
+
+ if (!components.length) {
+ return;
+ }
+
+ stream.write(util.format('\n#### %s\n\n', title));
+
+ components.forEach(function(name) {
+ var prefix = '*';
+ var nested = section[name].length > 1;
+
+ if (name !== EMPTY_COMPONENT) {
+ if (nested) {
+ stream.write(util.format('* **%s:**\n', name));
+ prefix = ' *';
+ } else {
+ prefix = util.format('* **%s:**', name);
+ }
+ }
+
+ section[name].forEach(function(commit) {
+ stream.write(util.format('%s %s (%s', prefix, commit.subject, linkToCommit(commit.hash)));
+ if (commit.closes.length) {
+ stream.write(', closes ' + commit.closes.map(linkToIssue).join(', '));
+ }
+ stream.write(')\n');
+ });
+ });
+
+ stream.write('\n');
+ };
+};
+
+var writeChangelog = function(writer, commits, version) {
+ var sections = {
+ fix: {},
+ feat: {},
+ breaks: {}
+ };
+
+ commits.forEach(function(commit) {
+ var section = sections[commit.type];
+ var component = commit.component || EMPTY_COMPONENT;
+
+ if (section) {
+ section[component] = section[component] || [];
+ section[component].push(commit);
+ }
+
+ commit.breaks.forEach(function(breakMsg) {
+ sections.breaks[EMPTY_COMPONENT] = sections.breaks[EMPTY_COMPONENT] || [];
+
+ sections.breaks[EMPTY_COMPONENT].push({
+ subject: breakMsg,
+ hash: commit.hash,
+ closes: []
+ });
+ });
+ });
+
+ writer.header(version);
+ writer.section('Bug Fixes', sections.fix);
+ writer.section('Features', sections.feat);
+ writer.section('Breaking Changes', sections.breaks);
+};
+
+
+var getPreviousTag = function() {
+ var deffered = q.defer();
+ child.exec(GIT_TAG_CMD, function(code, stdout) {
+ if (code) {
+ deffered.reject('Cannot get the previous tag.');
+ }
+ else {
+ deffered.resolve(stdout.replace('\n', ''));
+ }
+ });
+ return deffered.promise;
+};
+
+
+// PUBLIC API
+exports.generate = function(githubRepo, version) {
+ var buffer = {
+ data: '',
+ write: function(str) {
+ this.data += str;
+ }
+ };
+ var writer = new Writer(buffer, githubRepo);
+
+ return getPreviousTag().then(function(tag) {
+ return readGitLog('^fix|^feat|BREAKING', tag).then(function(commits) {
+ writeChangelog(writer, commits, version);
+ return buffer.data;
+ });
+ });
+};
+
+
+// publish for testing
+exports.parseRawCommit = parseRawCommit;
diff --git a/validate-commit-msg.js b/lib/validate-commit-msg.js
old mode 100644
new mode 100755
similarity index 78%
rename from validate-commit-msg.js
rename to lib/validate-commit-msg.js
index 2ab8962..2113841
--- a/validate-commit-msg.js
+++ b/lib/validate-commit-msg.js
@@ -10,11 +10,12 @@
*/
var fs = require('fs');
var util = require('util');
+var path = require('path');
var MAX_LENGTH = 70;
-var PATTERN = /^(?:fixup!\s*)?(\w*)(\(([\w\$\.\-\*/]*)\))?\: (.*)$/;
-var IGNORED = /^WIP\:/;
+var PATTERN = /^(\w*)(\(([\w\$\.\-\*]*)\))?\: (.*)$/;
+var IGNORED = /^(WIP\:|Merge pull request)/;
var TYPES = {
feat: true,
fix: true,
@@ -22,14 +23,12 @@ var TYPES = {
style: true,
refactor: true,
test: true,
- chore: true,
- revert: true
+ chore: true
};
var error = function() {
// gitx does not display it
- // http://gitx.lighthouseapp.com/projects/17830/tickets/294-feature-display-hook-error-message-when-hook-fails
// https://groups.google.com/group/gitx/browse_thread/thread/a03bcab60844b812
console.error('INVALID COMMIT MSG: ' + util.format.apply(null, arguments));
};
@@ -51,13 +50,11 @@ var validateMessage = function(message) {
var match = PATTERN.exec(message);
if (!match) {
- error('does not match "(): " ! was: ' + message);
+ error('does not match "(): " !');
return false;
}
var type = match[1];
- var scope = match[3];
- var subject = match[4];
if (!TYPES.hasOwnProperty(type)) {
error('"%s" is not allowed type !', type);
@@ -82,14 +79,13 @@ var firstLineFromBuffer = function(buffer) {
};
-
// publish for testing
exports.validateMessage = validateMessage;
-// hacky start if not run by jasmine :-D
-if (process.argv.join('').indexOf('jasmine-node') === -1) {
+// lame test if run by git (so that it does not trigger during testing)
+if (process.env.GIT_DIR) {
var commitMsgFile = process.argv[2];
- var incorrectLogFile = commitMsgFile.replace('COMMIT_EDITMSG', 'logs/incorrect-commit-msgs');
+ var incorrectLogFile = path.dirname(commitMsgFile) + '/logs/incorrect-commit-msgs';
fs.readFile(commitMsgFile, function(err, buffer) {
var msg = firstLineFromBuffer(buffer);
@@ -102,4 +98,4 @@ if (process.argv.join('').indexOf('jasmine-node') === -1) {
process.exit(0);
}
});
-}
\ No newline at end of file
+}
diff --git a/package.json b/package.json
index d4e2ce6..39545a5 100644
--- a/package.json
+++ b/package.json
@@ -17,11 +17,14 @@
"author": "Brian Ford",
"license": "BSD",
"dependencies": {
- "shelljs": "~0.1.4"
+ "qq": "~0.3.5"
},
"devDependencies": {
"grunt": "~0.4.1",
- "grunt-release": "~0.3.3"
"grunt-contrib-jshint": "~0.6",
+ "grunt-release": "~0.3.3",
+ "grunt-simple-mocha": "~0.4.0",
+ "chai": "~1.7.2",
+ "sinon": "~1.7.3"
}
}
diff --git a/tasks/changelog.js b/tasks/changelog.js
index 2e513ac..5fe114d 100644
--- a/tasks/changelog.js
+++ b/tasks/changelog.js
@@ -1,182 +1,73 @@
-'use strict';
-
-var fs = require('fs');
-var sh = require('shelljs');
-
-module.exports = function (grunt) {
-
- // based on: https://github.com/angular-ui/bootstrap/commit/9a683eebccaeb6bfc742e6768562eb19ddd2216c
- grunt.registerTask('changelog', 'generate a changelog from git metadata', function () {
-
- var done = grunt.task.current.async();
-
- var options = this.options({
- // dest: 'CHANGELOG.md',
- prepend: true, // false to append
- enforce: false
- });
-
- var githubRepo;
- var pkg = grunt.config('pkg');
- //If github repo isn't given, try to read it from the package file
- if (!options.github && pkg) {
- if (pkg.repository) {
- githubRepo = pkg.repository.url;
- } else if (pkg.homepage) {
- //If it's a github page, but not a *.github.(io|com) page
- if (pkg.homepage.indexOf('github.com') > -1 &&
- !pkg.homepage.match(/\.github\.(com|io)/)) {
- githubRepo = pkg.homepage;
- }
+var exec = require('child_process').exec;
+var changelog = require('../lib/changelog');
+
+
+// TODO: clean this up and write tests for it
+var figureOutGithubRepo = function(githubRepo, pkg) {
+
+ //If github repo isn't given, try to read it from the package file
+ if (!githubRepo && pkg) {
+ if (pkg.repository) {
+ githubRepo = pkg.repository.url;
+ } else if (pkg.homepage) {
+ //If it's a github page, but not a *.github.(io|com) page
+ if (pkg.homepage.indexOf('github.com') > -1 &&
+ !pkg.homepage.match(/\.github\.(com|io)/)) {
+ githubRepo = pkg.homepage;
}
- } else {
- githubRepo = options.github;
}
+ }
- // ensure commit convention, by using a commit hook
- var gitHookFile = '.git/hooks/commit-msg';
- if(options.enforce) {
- // if no commit hook is found, copy from template
- if(!grunt.file.exists(gitHookFile)) {
- grunt.file.copy(__dirname + '/../validate-commit-msg.js', gitHookFile);
- // need to ensure the hook is executable
- fs.chmodSync(gitHookFile, '0755');
- }
- }
+ //User could set option eg 'github: "btford/grunt-conventional-changelog'
+ if (githubRepo.indexOf('github.com') === -1) {
+ githubRepo = 'http://github.com/' + githubRepo;
+ }
- function fixGithubRepo(githubRepo) {
- //User could set option eg 'github: "btford/grunt-conventional-changelog'
- if (githubRepo.indexOf('github.com') === -1) {
- githubRepo = 'http://github.com/' + githubRepo;
- }
- return githubRepo
+ githubRepo = githubRepo
.replace(/^git:\/\//, 'http://') //get rid of git://
- .replace(/\/$/, '') //get rid of trailing slash
+ .replace(/\/$/, '') //get rid of trailing slash
.replace(/\.git$/, ''); //get rid of trailing .git
- }
- if (githubRepo) {
- githubRepo = fixGithubRepo(githubRepo);
- }
-
- var template;
- if (options.templateFile) {
- template = grunt.file.read(options.templateFile);
- } else {
- template = fs.readFileSync(__dirname + '/../template/changelog.md', 'utf8');
- }
- //If args are given, generate changelog from arg commits
- var gitArgs = [ 'log', '--format=%H%n%s%n%b%n==END==' ];
- if (this.args.length > 0) {
- var changeFrom = this.args[0], changeTo = this.args[1] || 'HEAD';
- gitArgs.push(changeFrom + '..' + changeTo);
-
- //Else, generate changelog from last tag to HEAD
- } else {
- //Based on: https://github.com/angular/angular.js/blob/master/changelog.js#L184
- //Use tags to find the last commit
- var tagResult = sh.exec('git describe --tags --abbrev=0');
- if (tagResult.code !== 0) {
- return done(true);
- }
- var lastTag = tagResult.output.trim();
- gitArgs.push(lastTag + '..HEAD');
- }
-
- //Run git to get the log we need
- var logResult = sh.exec('git ' + gitArgs.join(' '));
- if (logResult.code !== 0) {
- return done(false);
- }
- var gitlog = logResult.output.split('\n==END==\n').reverse();
- makeChangelog(gitlog);
-
- function makeChangelog(gitlog) {
-
- var changelog = {};
+ return githubRepo;
+};
- // based on: https://github.com/angular/angular.js/blob/master/changelog.js#L50
- // see also: https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/
- var COMMIT_MSG_REGEXP = /^(.*)\((.*)\)\:\s(.*)$/;
- var BREAKING_CHANGE_REGEXP = /BREAKING CHANGE:([\s\S]*)/;
- function addChange(changeType, changeScope, sha1, changeMessage) {
- if (!changelog[changeType]) {
- changelog[changeType] = {};
- }
- if (!changelog[changeType][changeScope]) {
- changelog[changeType][changeScope] = [];
- }
- changelog[changeType][changeScope].push({
- sha1: sha1,
- msg: changeMessage
- });
- }
+module.exports = function (grunt) {
- gitlog.forEach(function (logItem) {
- var lines = logItem.split('\n');
- var sha1 = lines.shift().substr(0,8); //Only need first 7 chars
- var subject = lines.shift();
+ var DESC = 'Generate a changelog from git metadata.';
+ grunt.registerTask('changelog', DESC, function () {
- if (!subject) {
- return; //this is a bad commit message
- }
+ var done = this.async();
+ var options = this.options({
+ dest: 'CHANGELOG.md',
+ prepend: true, // false to append
+ github: null, // default from package.json
+ version: null, // default value from package.json
+ editor: null // 'sublime -w'
+ });
- var changeMatch,
- changeType,
- changeScope,
- changeMessage;
- if ( (changeMatch = subject.match(COMMIT_MSG_REGEXP)) ) {
- //if it conforms to the changelog style
- changeType = changeMatch[1];
- changeScope = changeMatch[2];
- changeMessage = changeMatch[3];
- } else {
- //otherwise
- changeType = changeScope = 'other';
- changeMessage = subject;
- }
+ var pkg = grunt.config('pkg') || grunt.file.readJSON('package.json');
+ var githubRepo = figureOutGithubRepo(options.github, pkg);
+ var newVersion = options.version || pkg.version;
- addChange(changeType, changeScope, sha1, changeMessage);
- var breakingMatch = logItem.match(BREAKING_CHANGE_REGEXP);
- if (breakingMatch) {
- var breakingMessage = breakingMatch[1];
- addChange('breaking', changeScope, sha1, breakingMessage);
- }
- });
+ // generate changelog
+ changelog.generate(githubRepo, 'v' + newVersion).then(function(data) {
+ grunt.file.write(options.dest, data + grunt.file.read(options.dest));
- var newLog = grunt.template.process(template, {
- data: {
- changelog: changelog,
- today: grunt.template.today('yyyy-mm-dd'),
- version: options.version || grunt.config('pkg.version'),
- helpers: {
- //Generates a commit link if we have a repo, else it generates a plain text commit sha1
- commitLink: function(commit) {
- if (githubRepo) {
- return '[' + commit + '](' + githubRepo + '/commit/' + commit + ')';
- } else {
- return commit;
- }
- }
+ if (options.editor) {
+ exec(options.editor + ' ' + options.dest, function(err) {
+ if (err) {
+ return grunt.fatal('Can not generate changelog.');
}
- }
- });
- if (options.dest) {
- var log = grunt.file.exists(options.dest) ?
- grunt.file.read(options.dest) : '';
- if (options.prepend) {
- log = newLog + log;
- } else {
- log += newLog;
- }
- grunt.file.write(options.dest, log);
+ grunt.log.ok(options.dest + ' updated');
+ done();
+ });
} else {
- console.log(newLog);
+ grunt.log.ok(options.dest + ' updated');
+ done();
}
- done();
- }
+ });
});
};
diff --git a/template/changelog.md b/template/changelog.md
deleted file mode 100644
index 1688d9d..0000000
--- a/template/changelog.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# <%= version%> (<%= today%>)
-
-<% if (_(changelog.feat).size() > 0) { %>## Features
-<% _(changelog.feat).forEach(function(changes, scope) { %>### <%= scope%>
-<% changes.forEach(function(change) { %>
-* <%= change.msg%> (<%= helpers.commitLink(change.sha1) %>)
-<% }) %>
-<% }) %><% } %>
-
-<% if (_(changelog.fix).size() > 0) { %>## Bug fixes
-<% _(changelog.fix).forEach(function(changes, scope) { %>### <%= scope%>
-<% changes.forEach(function(change) { %>
-* <%= change.msg%> (<%= helpers.commitLink(change.sha1) %>)
-<% }) %>
-<% }) %><% } %>
-
-<% if (_(changelog.breaking).size() > 0) { %>## Breaking Changes
-<% _(changelog.breaking).forEach(function(changes, scope) { %>### <%= scope%>
-<% changes.forEach(function(change) { %>
- <%= change.msg%>
-<% }) %>
-<% }) %><% } %>
diff --git a/test/lib/changelog.spec.coffee b/test/lib/changelog.spec.coffee
new file mode 100644
index 0000000..3b433db
--- /dev/null
+++ b/test/lib/changelog.spec.coffee
@@ -0,0 +1,49 @@
+describe 'changelog', ->
+ ch = require '../../lib/changelog'
+
+ describe 'parseRawCommit', ->
+
+ it 'should parse raw commit', ->
+ msg = ch.parseRawCommit(
+ '9b1aff905b638aa274a5fc8f88662df446d374bd\n' +
+ 'feat(scope): broadcast $destroy event on scope destruction\n' +
+ 'perf testing shows that in chrome this change adds 5-15% overhead\n' +
+ 'when destroying 10k nested scopes where each scope has a $destroy listener\n')
+
+ expect(msg.type).to.equal 'feat'
+ expect(msg.component).to.equal 'scope'
+ expect(msg.hash).to.equal '9b1aff905b638aa274a5fc8f88662df446d374bd'
+ expect(msg.subject).to.equal 'broadcast $destroy event on scope destruction'
+ expect(msg.body).to.equal(
+ 'perf testing shows that in chrome this change adds 5-15% overhead\n' +
+ 'when destroying 10k nested scopes where each scope has a $destroy listener\n')
+
+
+ it 'should parse closed issues', ->
+ msg = ch.parseRawCommit(
+ '13f31602f396bc269076ab4d389cfd8ca94b20ba\n' +
+ 'feat(ng-list): Allow custom separator\n' +
+ 'bla bla bla\n\n' +
+ 'Closes #123\nCloses #25\nFixes #33\n')
+
+ expect(msg.closes).to.deep.equal [123, 25, 33]
+
+
+ it 'should parse breaking changes', ->
+ msg = ch.parseRawCommit(
+ '13f31602f396bc269076ab4d389cfd8ca94b20ba\n' +
+ 'feat(ng-list): Allow custom separator\n' +
+ 'bla bla bla\n\n' +
+ 'BREAKING CHANGE: some breaking change\n')
+
+ expect(msg.breaks).to.deep.equal ['some breaking change\n']
+
+
+ it 'should parse a msg without scope', ->
+ msg = ch.parseRawCommit(
+ '13f31602f396bc269076ab4d389cfd8ca94b20ba\n' +
+ 'chore: some chore bullshit\n' +
+ 'bla bla bla\n\n' +
+ 'BREAKING CHANGE: some breaking change\n')
+
+ expect(msg.type).to.equal 'chore'
diff --git a/test/lib/validate-commit-msg.spec.coffee b/test/lib/validate-commit-msg.spec.coffee
new file mode 100644
index 0000000..0e2a852
--- /dev/null
+++ b/test/lib/validate-commit-msg.spec.coffee
@@ -0,0 +1,65 @@
+describe 'validate-commit-msg', ->
+ m = require '../../lib/validate-commit-msg'
+ errors = []
+
+ VALID = true
+ INVALID = false
+
+
+ beforeEach ->
+ errors.length = 0
+ sinon.stub console, 'error', (msg) ->
+ errors.push(msg.replace /\x1B\[\d+m/g, '') # uncolor
+ sinon.stub console, 'log'
+
+
+ describe 'validateMessage', ->
+
+ it 'should be valid', ->
+ expect(m.validateMessage 'fix($compile): something').to.equal VALID
+ expect(m.validateMessage 'feat($location): something').to.equal VALID
+ expect(m.validateMessage 'docs($filter): something').to.equal VALID
+ expect(m.validateMessage 'style($http): something').to.equal VALID
+ expect(m.validateMessage 'refactor($httpBackend): something').to.equal VALID
+ expect(m.validateMessage 'test($resource): something').to.equal VALID
+ expect(m.validateMessage 'chore($controller): something').to.equal VALID
+ expect(m.validateMessage 'chore(foo-bar): something').to.equal VALID
+ expect(m.validateMessage 'chore(*): something').to.equal VALID
+ expect(errors).to.deep.equal []
+
+
+ it 'should validate 70 characters length', ->
+ msg = 'fix($compile): something super mega extra giga tera long, maybe even longer... ' +
+ 'way over 80 characters'
+
+ expect(m.validateMessage msg ).to.equal INVALID
+ expect(errors).to.deep.equal ['INVALID COMMIT MSG: is longer than 70 characters !']
+
+
+ it 'should validate "(): " format', ->
+ msg = 'not correct format'
+
+ expect(m.validateMessage msg).to.equal INVALID
+ expect(errors).to.deep.equal [
+ 'INVALID COMMIT MSG: does not match "(): " !'
+ ]
+
+
+ it 'should validate type', ->
+ expect(m.validateMessage 'weird($filter): something').to.equal INVALID
+ expect(errors).to.deep.equal ['INVALID COMMIT MSG: "weird" is not allowed type !']
+
+
+ it 'should allow empty scope', ->
+ expect(m.validateMessage 'fix: blablabla').to.equal VALID
+
+
+ it 'should allow dot in scope', ->
+ expect(m.validateMessage 'chore(mocks.$httpBackend): something').to.equal VALID
+
+
+ it 'should ignore msg prefixed with "WIP: "', ->
+ expect(m.validateMessage 'WIP: bullshit').to.equal VALID
+
+ it 'should ignore "Merging PR" messages', ->
+ expect(m.validateMessage 'Merge pull request #333 from ahaurw01/feature').to.equal VALID
diff --git a/test/mocha-globals.coffee b/test/mocha-globals.coffee
new file mode 100644
index 0000000..2823e28
--- /dev/null
+++ b/test/mocha-globals.coffee
@@ -0,0 +1,12 @@
+sinon = require 'sinon'
+chai = require 'chai'
+
+global.expect = chai.expect
+global.sinon = null
+
+
+beforeEach ->
+ global.sinon = sinon.sandbox.create()
+
+afterEach ->
+ global.sinon.restore()