Repository: pre-commit/pre-commit
Pull request #1004 (2019-04-21 20:38:35)

Add support for prepare-commit-msg hooks
Adds a `prepare-commit-msg` hook stage which allows for hooks which add
dynamic suggested/placeholder text to commit messages that an author can
use as a starting point for writing a commit message.

An example being to pre-populate the first line of the commit message with some dynamic information such as the ticket ID and description (if branch name contains the ticket ID let's say). The benefit of this over a `commit-msg` hook in this scenario being that it doesn't modify an author's commit message 'behind their back'.

May help with #833 and relates to #884 


Patch:
diff --git a/pre_commit/commands/run.py b/pre_commit/commands/run.py
index cfa62ee..d060e18 100644
--- a/pre_commit/commands/run.py
+++ b/pre_commit/commands/run.py
@@ -190,7 +190,7 @@ def _compute_cols(hooks, verbose):
 def _all_filenames(args):
     if args.origin and args.source:
         return git.get_changed_files(args.origin, args.source)
-    elif args.hook_stage == 'commit-msg':
+    elif args.hook_stage in {'prepare-commit-msg', 'commit-msg'}:
         return (args.commit_msg_filename,)
     elif args.files:
         return args.files
diff --git a/pre_commit/constants.py b/pre_commit/constants.py
index 996480a..307b09a 100644
--- a/pre_commit/constants.py
+++ b/pre_commit/constants.py
@@ -21,6 +21,6 @@ LOCAL_REPO_VERSION = '1'
 VERSION = importlib_metadata.version('pre_commit')
 
 # `manual` is not invoked by any installed git hook.  See #719
-STAGES = ('commit', 'commit-msg', 'manual', 'push')
+STAGES = ('commit', 'prepare-commit-msg', 'commit-msg', 'manual', 'push')
 
 DEFAULT = 'default'
diff --git a/pre_commit/main.py b/pre_commit/main.py
index a935cf1..aa7ff2a 100644
--- a/pre_commit/main.py
+++ b/pre_commit/main.py
@@ -52,7 +52,9 @@ def _add_config_option(parser):
 
 def _add_hook_type_option(parser):
     parser.add_argument(
-        '-t', '--hook-type', choices=('pre-commit', 'pre-push', 'commit-msg'),
+        '-t', '--hook-type', choices=(
+            'pre-commit', 'pre-push', 'prepare-commit-msg', 'commit-msg',
+        ),
         default='pre-commit',
     )
 
diff --git a/pre_commit/resources/hook-tmpl b/pre_commit/resources/hook-tmpl
index 76123d3..19d0e72 100755
--- a/pre_commit/resources/hook-tmpl
+++ b/pre_commit/resources/hook-tmpl
@@ -161,6 +161,7 @@ def _pre_push(stdin):
 
 def _opts(stdin):
     fns = {
+        'prepare-commit-msg': lambda _: ('--commit-msg-filename', sys.argv[1]),
         'commit-msg': lambda _: ('--commit-msg-filename', sys.argv[1]),
         'pre-commit': lambda _: (),
         'pre-push': _pre_push,


Test patch:
diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py
index c19aaa4..e253dd4 100644
--- a/tests/commands/install_uninstall_test.py
+++ b/tests/commands/install_uninstall_test.py
@@ -655,7 +655,65 @@ def test_commit_msg_legacy(commit_msg_repo, tempdir_factory, store):
     assert second_line.startswith('Must have "Signed off by:"...')
 
 
-def test_install_disallow_mising_config(tempdir_factory, store):
+def test_prepare_commit_msg_integration_failing(
+        failing_prepare_commit_msg_repo, tempdir_factory, store,
+):
+    install(C.CONFIG_FILE, store, hook_type='prepare-commit-msg')
+    retc, out = _get_commit_output(tempdir_factory)
+    assert retc == 1
+    assert out.startswith('Add "Signed off by:"...')
+    assert out.strip().endswith('...Failed')
+
+
+def test_prepare_commit_msg_integration_passing(
+        prepare_commit_msg_repo, tempdir_factory, store,
+):
+    install(C.CONFIG_FILE, store, hook_type='prepare-commit-msg')
+    msg = 'Hi'
+    retc, out = _get_commit_output(tempdir_factory, msg=msg)
+    assert retc == 0
+    first_line = out.splitlines()[0]
+    assert first_line.startswith('Add "Signed off by:"...')
+    assert first_line.endswith('...Passed')
+    commit_msg_path = os.path.join(
+        prepare_commit_msg_repo, '.git/COMMIT_EDITMSG',
+    )
+    with io.open(commit_msg_path) as f:
+        assert 'Signed off by: ' in f.read()
+
+
+def test_prepare_commit_msg_legacy(
+    prepare_commit_msg_repo, tempdir_factory, store,
+):
+    hook_path = os.path.join(
+        prepare_commit_msg_repo, '.git/hooks/prepare-commit-msg',
+    )
+    mkdirp(os.path.dirname(hook_path))
+    with io.open(hook_path, 'w') as hook_file:
+        hook_file.write(
+            '#!/usr/bin/env bash\n'
+            'set -eu\n'
+            'test -e "$1"\n'
+            'echo legacy\n',
+        )
+    make_executable(hook_path)
+
+    install(C.CONFIG_FILE, store, hook_type='prepare-commit-msg')
+
+    msg = 'Hi'
+    retc, out = _get_commit_output(tempdir_factory, msg=msg)
+    assert retc == 0
+    first_line, second_line = out.splitlines()[:2]
+    assert first_line == 'legacy'
+    assert second_line.startswith('Add "Signed off by:"...')
+    commit_msg_path = os.path.join(
+        prepare_commit_msg_repo, '.git/COMMIT_EDITMSG',
+    )
+    with io.open(commit_msg_path) as f:
+        assert 'Signed off by: ' in f.read()
+
+
+def test_install_disallow_missing_config(tempdir_factory, store):
     path = make_consuming_repo(tempdir_factory, 'script_hooks_repo')
     with cwd(path):
         remove_config_from_repo(path)
@@ -668,7 +726,7 @@ def test_install_disallow_mising_config(tempdir_factory, store):
         assert ret == 1
 
 
-def test_install_allow_mising_config(tempdir_factory, store):
+def test_install_allow_missing_config(tempdir_factory, store):
     path = make_consuming_repo(tempdir_factory, 'script_hooks_repo')
     with cwd(path):
         remove_config_from_repo(path)
diff --git a/tests/commands/run_test.py b/tests/commands/run_test.py
index 11a8eea..b465cae 100644
--- a/tests/commands/run_test.py
+++ b/tests/commands/run_test.py
@@ -557,7 +557,12 @@ def test_stages(cap_out, store, repo_with_passing_hook):
                 'language': 'pygrep',
                 'stages': [stage],
             }
-            for i, stage in enumerate(('commit', 'push', 'manual'), 1)
+            for i, stage in enumerate(
+                (
+                    'commit', 'push', 'manual', 'prepare-commit-msg',
+                    'commit-msg',
+                ), 1,
+            )
         ],
     }
     add_config_to_repo(repo_with_passing_hook, config)
@@ -575,6 +580,8 @@ def test_stages(cap_out, store, repo_with_passing_hook):
     assert _run_for_stage('commit').startswith(b'hook 1...')
     assert _run_for_stage('push').startswith(b'hook 2...')
     assert _run_for_stage('manual').startswith(b'hook 3...')
+    assert _run_for_stage('prepare-commit-msg').startswith(b'hook 4...')
+    assert _run_for_stage('commit-msg').startswith(b'hook 5...')
 
 
 def test_commit_msg_hook(cap_out, store, commit_msg_repo):
@@ -593,6 +600,25 @@ def test_commit_msg_hook(cap_out, store, commit_msg_repo):
     )
 
 
+def test_prepare_commit_msg_hook(cap_out, store, prepare_commit_msg_repo):
+    filename = '.git/COMMIT_EDITMSG'
+    with io.open(filename, 'w') as f:
+        f.write('This is the commit message')
+
+    _test_run(
+        cap_out,
+        store,
+        prepare_commit_msg_repo,
+        {'hook_stage': 'prepare-commit-msg', 'commit_msg_filename': filename},
+        expected_outputs=[b'Add "Signed off by:"', b'Passed'],
+        expected_ret=0,
+        stage=False,
+    )
+
+    with io.open(filename) as f:
+        assert 'Signed off by: ' in f.read()
+
+
 def test_local_hook_passes(cap_out, store, repo_with_passing_hook):
     config = {
         'repo': 'local',
diff --git a/tests/conftest.py b/tests/conftest.py
index 50ad76e..23ff746 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -14,6 +14,7 @@ from pre_commit import output
 from pre_commit.logging_handler import logging_handler
 from pre_commit.store import Store
 from pre_commit.util import cmd_output
+from pre_commit.util import make_executable
 from testing.fixtures import git_dir
 from testing.fixtures import make_consuming_repo
 from testing.fixtures import write_config
@@ -134,6 +135,54 @@ def commit_msg_repo(tempdir_factory):
         yield path
 
 
+@pytest.fixture
+def prepare_commit_msg_repo(tempdir_factory):
+    path = git_dir(tempdir_factory)
+    script_name = 'add_sign_off.sh'
+    config = {
+        'repo': 'local',
+        'hooks': [{
+            'id': 'add-signoff',
+            'name': 'Add "Signed off by:"',
+            'entry': './{}'.format(script_name),
+            'language': 'script',
+            'stages': ['prepare-commit-msg'],
+        }],
+    }
+    write_config(path, config)
+    with cwd(path):
+        with io.open(script_name, 'w') as script_file:
+            script_file.write(
+                '#!/usr/bin/env bash\n'
+                'set -eu\n'
+                'echo "\nSigned off by: " >> "$1"\n',
+            )
+            make_executable(script_name)
+        cmd_output('git', 'add', '.')
+        git_commit(msg=prepare_commit_msg_repo.__name__)
+        yield path
+
+
+@pytest.fixture
+def failing_prepare_commit_msg_repo(tempdir_factory):
+    path = git_dir(tempdir_factory)
+    config = {
+        'repo': 'local',
+        'hooks': [{
+            'id': 'add-signoff',
+            'name': 'Add "Signed off by:"',
+            'entry': 'bash -c "exit 1"',
+            'language': 'system',
+            'stages': ['prepare-commit-msg'],
+        }],
+    }
+    write_config(path, config)
+    with cwd(path):
+        cmd_output('git', 'add', '.')
+        git_commit(msg=failing_prepare_commit_msg_repo.__name__)
+        yield path
+
+
 @pytest.fixture(autouse=True, scope='session')
 def dont_write_to_home_directory():
     """pre_commit.store.Store will by default write to the home directory
diff --git a/tests/repository_test.py b/tests/repository_test.py
index d8bfde3..a2a9bb5 100644
--- a/tests/repository_test.py
+++ b/tests/repository_test.py
@@ -824,7 +824,9 @@ def test_manifest_hooks(tempdir_factory, store):
         name='Bash hook',
         pass_filenames=True,
         require_serial=False,
-        stages=('commit', 'commit-msg', 'manual', 'push'),
+        stages=(
+            'commit', 'prepare-commit-msg', 'commit-msg', 'manual', 'push',
+        ),
         types=['file'],
         verbose=False,
     )