From 0597098988be025a0fd3b26bafa173b4326652d4 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Thu, 28 Jun 2018 11:53:15 +0100 Subject: [PATCH] feat: lint commit messages Will lint between remotes/origin/master and HEAD. To lint between different commits use: ```sh $ aegir lint-commits --from $COMMITISH --to $COMMITISH ``` This should be set up as a pre-push or post-commit hook as regular linting runs pre-commit so the messages we want to examine won't be in the history yet.. --- cmds/lint-commits.js | 23 +++++++++++ package.json | 4 ++ src/lint-commits.js | 64 ++++++++++++++++++++++++++++ test/lint-commits.spec.js | 87 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 cmds/lint-commits.js create mode 100644 src/lint-commits.js create mode 100644 test/lint-commits.spec.js diff --git a/cmds/lint-commits.js b/cmds/lint-commits.js new file mode 100644 index 000000000..c5487bf7e --- /dev/null +++ b/cmds/lint-commits.js @@ -0,0 +1,23 @@ +'use strict' + +module.exports = { + command: 'lint-commits', + desc: 'Lint commit messages', + builder: { + from: { + alias: 'f', + describe: 'The commit-ish to lint from', + default: 'remotes/origin/master' + }, + to: { + alias: 't', + describe: 'The commit-ish to lint to', + default: 'HEAD' + } + }, + handler (argv) { + const lintCommits = require('../src/lint-commits') + const onError = require('../src/error-handler') + lintCommits(argv).catch(onError) + } +} diff --git a/package.json b/package.json index 70f1e1e76..3920578e8 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,10 @@ ], "license": "MIT", "dependencies": { + "@commitlint/config-conventional": "^7.0.1", + "@commitlint/lint": "^7.0.0", + "@commitlint/load": "^7.0.0", + "@commitlint/read": "^7.0.0", "async": "^2.6.0", "browserify-zlib": "~0.2.0", "chalk": "^2.3.0", diff --git a/src/lint-commits.js b/src/lint-commits.js new file mode 100644 index 000000000..9b5dfa505 --- /dev/null +++ b/src/lint-commits.js @@ -0,0 +1,64 @@ +'use strict' + +const loadCommitLintConfig = require('@commitlint/load') +const readCommits = require('@commitlint/read') +const lintCommitMessage = require('@commitlint/lint') +const conventionalCommits = require('@commitlint/config-conventional') + +function lintCommitMessages (opts = {}) { + const from = opts.from || 'remotes/origin/master' + const to = opts.to || 'HEAD' + + return Promise.all([ + loadCommitLintConfig({ + rules: conventionalCommits.rules + }), + readCommits({ + from, + to + }) + ]) + .then(([ { rules, parserPreset }, commits ]) => { + return Promise.all( + commits.map(commit => { + return lintCommitMessage(commit, rules, parserPreset ? { parserOpts: parserPreset.parserOpts } : {}) + }) + ) + }) + .then(results => { + let valid = true + + results.forEach(report => { + if (valid === true) { + valid = report.valid + } + + const firstLine = `${report.input.trim().split('\n')[0]}`.trim() + + if (!report.valid) { + console.log(`Commit message '${firstLine}' failed validation:`) + console.log('') + + report.errors.forEach(error => { + console.log(' [ERROR]', `${error.name}:`, error.message) + }) + + report.warnings.forEach(warning => { + console.log(' [WARNING]', `${warning.name}:`, warning.message) + }) + + console.log('') + } + }) + + if (!valid) { + throw new Error(`Linting commits ${from}..${to} failed`) + } + }) +} + +function lintCommits (opts) { + return lintCommitMessages(opts) +} + +module.exports = lintCommits diff --git a/test/lint-commits.spec.js b/test/lint-commits.spec.js new file mode 100644 index 000000000..8f08a701d --- /dev/null +++ b/test/lint-commits.spec.js @@ -0,0 +1,87 @@ +/* eslint-env mocha */ +'use strict' + +const lintCommits = require('../src/lint-commits') +const expect = require('chai').expect +const path = require('path') +const os = require('os') +const fs = require('fs') +const series = require('async/series') +const child = require('child_process') + +function commitFile (directory, name, contents, message, callback) { + series([ + (cb) => fs.writeFile(path.join(directory, name), contents, cb), + (cb) => child.exec('git add -A', { + cwd: directory + }, cb), + (cb) => child.exec(`git commit -m "${message}"`, { + cwd: directory + }, cb) + ], callback) +} + +const setupProject = (commitMessage = 'chore: initial commit') => { + const tmpDir = path.join(os.tmpdir(), `test-${Math.random()}`) + + return new Promise((resolve, reject) => { + series([ + (cb) => fs.mkdir(tmpDir, cb), + (cb) => child.exec('git init', { + cwd: tmpDir + }, cb), + (cb) => child.exec('git config user.email "you@example.com"', { + cwd: tmpDir + }, cb), + (cb) => child.exec('git config user.name "test"', { + cwd: tmpDir + }, cb), + (cb) => commitFile(tmpDir, 'hello.txt', 'Amazing', 'chore: initial commit', cb), + (cb) => commitFile(tmpDir, 'goodbye.txt', 'Amazing', commitMessage, cb) + ], (error) => { + if (error) { + return reject(error) + } + + process.chdir(tmpDir) + + resolve() + }) + }) +} + +const commitMessageShouldPassLinting = (commitMessage) => { + return setupProject(commitMessage) + .then(() => lintCommits({ + from: 'HEAD~1' + })) +} + +const commitMessageShouldFailLinting = (commitMessage) => { + return setupProject(commitMessage) + .then(() => lintCommits({ + from: 'HEAD~1' + })) + .then(() => { + throw new Error('Should have failed!') + }) + .catch(error => { + expect(error.message).to.contain('Linting commits HEAD~1..HEAD failed') + }) +} + +describe('lint commit messages', () => { + const cwd = process.cwd() + + after(() => { + process.chdir(cwd) + }) + + it('passes with good commit messages', function () { + return commitMessageShouldPassLinting('chore: such a chore') + }) + + it('fails with bad commit messages', function () { + return commitMessageShouldFailLinting('Ahahallolol!!!11shift+1') + }) +})