From 26533fcff90544a4152009ad38f4f15f96e7fefd Mon Sep 17 00:00:00 2001 From: Teal Hobson-Lowther Date: Thu, 18 Apr 2019 07:27:29 -0700 Subject: [PATCH] feat(cli): Implement --hook option for git hooks integration (#615) fixes #448 (re #462) This pr allows project maintainers to enforce Commitizen generated commit messages as part of the workflow triggered by the `git commit` command. * implements the `--hook` flag, which directs Commitizen to edit the `.git/COMMIT_EDITMSG` file directly. * documents the use of the `--hook` flag in the `README`, both through traditional `git hooks` and `husky`. --- README.md | 32 ++++++++++++ src/cli/strategies/git-cz.js | 7 ++- src/git/commit.js | 97 +++++++++++++++++++++++++----------- test/tests/commit.js | 39 +++++++++++++++ test/tests/parsers.js | 25 +++++++--- 5 files changed, 162 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index ccabce50..3b60a2ff 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,38 @@ This will be more convenient for your users because then if they want to do a co > **NOTE:** if you are using `precommit` hooks thanks to something like `husky`, you will need to name your script some thing other than "commit" (e.g. "cm": "git-cz"). The reason is because npm-scripts has a "feature" where it automatically runs scripts with the name *prexxx* where *xxx* is the name of another script. In essence, npm and husky will run "precommit" scripts twice if you name the script "commit," and the work around is to prevent the npm-triggered *precommit* script. +#### Optional: Running Commitizen on `git commit` + +This example shows how to incorporate Commitizen into the existing `git commit` workflow by using git hooks and the `--hook` command-line option. This is useful for project maintainers +who wish to ensure the proper commit format is enforced on contributions from those unfamiliar with Commitizen. + +Once either of these methods is implemented, users running `git commit` will be presented with an interactive Commitizen session that helps them write useful commit messages. + +> **NOTE:** This example assumes that the project has been set up to [use Commitizen locally](https://github.com/commitizen/cz-cli#optional-install-and-run-commitizen-locally). + +##### Traditional git hooks + +Update `.git/hooks/prepare-commit-msg` with the following code: + +``` +#!/bin/bash +exec < /dev/tty +node_modules/.bin/git-cz --hook +``` + +##### Husky +For `husky` users, add the following configuration to the project's `package.json`: + +``` +"husky": { + "hooks": { + "prepare-commit-msg": "exec < /dev/tty && git cz --hook", + } +} +``` + +> **Why `exec < /dev/tty`?** By default, git hooks are not interactive. This command allows the user to use their terminal to interact with Commitizen during the hook. + #### Congratulations your repo is Commitizen-friendly. Time to flaunt it! Add the Commitizen-friendly badge to your README using the following markdown: diff --git a/src/cli/strategies/git-cz.js b/src/cli/strategies/git-cz.js index f3938eb4..2eda9dde 100644 --- a/src/cli/strategies/git-cz.js +++ b/src/cli/strategies/git-cz.js @@ -35,6 +35,10 @@ function gitCz (rawGitArgs, environment, adapterConfig) { // normal commit. let retryLastCommit = rawGitArgs && rawGitArgs[0] === '--retry'; + // Determine if we need to process this commit using interactive hook mode + // for husky prepare-commit-message + let hookMode = !(typeof parsedCommitizenArgs.hook === 'undefined'); + let resolvedAdapterConfigPath = resolveAdapterPath(adapterConfig.path); let resolvedAdapterRootPath = findRoot(resolvedAdapterConfigPath); let prompter = getPrompter(adapterConfig.path); @@ -57,7 +61,8 @@ function gitCz (rawGitArgs, environment, adapterConfig) { disableAppendPaths: true, emitData: true, quiet: false, - retryLastCommit + retryLastCommit, + hookMode }, function (error) { if (error) { throw error; diff --git a/src/git/commit.js b/src/git/commit.js index 32cf6d5f..71d6eeb4 100644 --- a/src/git/commit.js +++ b/src/git/commit.js @@ -1,5 +1,9 @@ import { spawn } from 'child_process'; +import path from 'path'; + +import { writeFileSync, openSync, closeSync } from 'fs'; + import dedent from 'dedent'; export { commit }; @@ -9,35 +13,70 @@ export { commit }; */ function commit (sh, repoPath, message, options, done) { let called = false; - let args = ['commit', '-m', dedent(message), ...(options.args || [])]; - let child = spawn('git', args, { - cwd: repoPath, - stdio: options.quiet ? 'ignore' : 'inherit' - }); - - child.on('error', function (err) { - if (called) return; - called = true; - - done(err); - }); - - child.on('exit', function (code, signal) { - if (called) return; - called = true; - - if (code) { - if (code === 128) { - console.warn(` - Git exited with code 128. Did you forget to run: - - git config --global user.email "you@example.com" - git config --global user.name "Your Name" - `) + + // commit the file by spawning a git process, unless the --hook + // option was provided. in that case, write the commit message into + // the .git/COMMIT_EDITMSG file + if (!options.hookMode) { + let args = ['commit', '-m', dedent(message), ...(options.args || [])]; + let child = spawn('git', args, { + cwd: repoPath, + stdio: options.quiet ? 'ignore' : 'inherit' + }); + + child.on('error', function (err) { + if (called) return; + called = true; + + done(err); + }); + + child.on('exit', function (code, signal) { + if (called) return; + called = true; + + if (code) { + if (code === 128) { + console.warn(` + Git exited with code 128. Did you forget to run: + + git config --global user.email "you@example.com" + git config --global user.name "Your Name" + `) + } + done(Object.assign(new Error(`git exited with error code ${code}`), { code, signal })); + } else { + done(null); + } + }); + } else { + const commitFilePath = path.join(repoPath, '/.git/COMMIT_EDITMSG'); + try { + const fd = openSync(commitFilePath, 'w'); + try { + writeFileSync(fd, dedent(message)); + done(null); + } catch (e) { + done(e); + } finally { + closeSync(fd); + } + } catch (e) { + // windows doesn't allow opening existing hidden files + // in 'w' mode... but it does let you do 'r+'! + try { + const fd = openSync(commitFilePath, 'r+'); + try { + writeFileSync(fd, dedent(message)); + done(null); + } catch (e) { + done(e); + } finally { + closeSync(fd); + } + } catch (e) { + done(e); } - done(Object.assign(new Error(`git exited with error code ${code}`), { code, signal })); - } else { - done(null); } - }); + } } diff --git a/test/tests/commit.js b/test/tests/commit.js index 4f5adf04..e69a94b7 100644 --- a/test/tests/commit.js +++ b/test/tests/commit.js @@ -2,6 +2,7 @@ import { expect } from 'chai'; import os from 'os'; +import fs from 'fs'; import path from 'path'; import inquirer from 'inquirer'; @@ -274,6 +275,44 @@ ${(os.platform === 'win32') ? '' : ' '} }); + it('should save directly to .git/COMMIT_EDITMSG with --hook option', function (done) { + + this.timeout(config.maxTimeout); + + // SETUP + let dummyCommitMessage = `doggies!`; + + let repoConfig = { + path: config.paths.endUserRepo, + files: { + dummyfile: { + contents: 'arf arf!', + filename: 'woof.txt' + } + } + }; + + // Describe an adapter + let adapterConfig = { + path: path.join(repoConfig.path, '/node_modules/cz-jira-smart-commit'), + npmName: 'cz-jira-smart-commit' + } + + // Quick setup the repos, adapter, and grab a simple prompter + let prompter = quickPrompterSetup(sh, repoConfig, adapterConfig, dummyCommitMessage); + // TEST + + // This is a successful commit directly to .git/COMMIT_EDITMSG + commitizenCommit(sh, inquirer, repoConfig.path, prompter, { disableAppendPaths: true, quiet: true, emitData: true, hookMode: true }, function (err) { + const commitFilePath = path.join(repoConfig.path, '.git/COMMIT_EDITMSG') + const commitFile = fs.openSync(commitFilePath, 'r+') + let commitContents = fs.readFileSync(commitFile, { flags: 'r+' }).toString(); + fs.closeSync(commitFile); + expect(commitContents).to.have.string(dummyCommitMessage); + expect(err).to.be.a('null'); + done(); + }); + }); }); afterEach(function () { diff --git a/test/tests/parsers.js b/test/tests/parsers.js index fa6fbaed..31da04d5 100644 --- a/test/tests/parsers.js +++ b/test/tests/parsers.js @@ -1,36 +1,45 @@ /* eslint-env mocha */ import { expect } from 'chai'; -import { parse } from '../../src/cli/parsers/git-cz'; +import { gitCz as gitCzParser, commitizen as commitizenParser } from '../../src/cli/parsers'; describe('parsers', () => { describe('git-cz', () => { it('should parse --message "Hello, World!"', () => { - expect(parse(['--amend', '--message', 'Hello, World!'])).to.deep.equal(['--amend']); + expect(gitCzParser.parse(['--amend', '--message', 'Hello, World!'])).to.deep.equal(['--amend']); }); it('should parse --message="Hello, World!"', () => { - expect(parse(['--amend', '--message=Hello, World!'])).to.deep.equal(['--amend']); + expect(gitCzParser.parse(['--amend', '--message=Hello, World!'])).to.deep.equal(['--amend']); }); it('should parse -amwip', () => { - expect(parse(['-amwip'])).to.deep.equal(['-a']); + expect(gitCzParser.parse(['-amwip'])).to.deep.equal(['-a']); }); it('should parse -am=wip', () => { - expect(parse(['-am=wip'])).to.deep.equal(['-a']); + expect(gitCzParser.parse(['-am=wip'])).to.deep.equal(['-a']); }); it('should parse -am wip', () => { - expect(parse(['-am', 'wip'])).to.deep.equal(['-a']); + expect(gitCzParser.parse(['-am', 'wip'])).to.deep.equal(['-a']); }); it('should parse -a -m wip -n', () => { - expect(parse(['-a', '-m', 'wip', '-n'])).to.deep.equal(['-a', '-n']); + expect(gitCzParser.parse(['-a', '-m', 'wip', '-n'])).to.deep.equal(['-a', '-n']); }); it('should parse -a -m=wip -n', () => { - expect(parse(['-a', '-m=wip', '-n'])).to.deep.equal(['-a', '-n']); + expect(gitCzParser.parse(['-a', '-m=wip', '-n'])).to.deep.equal(['-a', '-n']); + }); + }); + + describe('commitizen', () => { + it('should parse out the --amend option', () => { + expect(commitizenParser.parse(['--amend'])).to.deep.equal({ _: [], amend: true }) + }); + it('should parse out the --hook option', () => { + expect(commitizenParser.parse(['--hook'])).to.deep.equal({ _: [], hook: true }) }); }); });