Skip to content

Commit

Permalink
feat(cli): Implement --hook option for git hooks integration (#615)
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
olgn authored and jimthedev committed Apr 18, 2019
1 parent 515a57e commit 26533fc
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 38 deletions.
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion src/cli/strategies/git-cz.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -57,7 +61,8 @@ function gitCz (rawGitArgs, environment, adapterConfig) {
disableAppendPaths: true,
emitData: true,
quiet: false,
retryLastCommit
retryLastCommit,
hookMode
}, function (error) {
if (error) {
throw error;
Expand Down
97 changes: 68 additions & 29 deletions src/git/commit.js
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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 "[email protected]"
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 "[email protected]"
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);
}
});
}
}
39 changes: 39 additions & 0 deletions test/tests/commit.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { expect } from 'chai';
import os from 'os';
import fs from 'fs';
import path from 'path';

import inquirer from 'inquirer';
Expand Down Expand Up @@ -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 () {
Expand Down
25 changes: 17 additions & 8 deletions test/tests/parsers.js
Original file line number Diff line number Diff line change
@@ -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 })
});
});
});

0 comments on commit 26533fc

Please sign in to comment.