Skip to content

feat(cli): Implement --hook option for git hooks integration #615

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 18, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 })
});
});
});