diff --git a/pre_commit/commands/install_uninstall.py b/pre_commit/commands/install_uninstall.py
index 36b0d7d..6e09dab 100644
--- a/pre_commit/commands/install_uninstall.py
+++ b/pre_commit/commands/install_uninstall.py
@@ -56,16 +56,21 @@ def install(
 
     with io.open(hook_path, 'w') as pre_commit_file_obj:
         if hook_type == 'pre-push':
-            with io.open(resource_filename('pre-push-tmpl')) as fp:
-                pre_push_contents = fp.read()
+            with io.open(resource_filename('pre-push-tmpl')) as f:
+                hook_specific_contents = f.read()
+        elif hook_type == 'commit-msg':
+            with io.open(resource_filename('commit-msg-tmpl')) as f:
+                hook_specific_contents = f.read()
+        elif hook_type == 'pre-commit':
+            hook_specific_contents = ''
         else:
-            pre_push_contents = ''
+            raise AssertionError('Unknown hook type: {}'.format(hook_type))
 
         skip_on_missing_conf = 'true' if skip_on_missing_conf else 'false'
         contents = io.open(resource_filename('hook-tmpl')).read().format(
             sys_executable=sys.executable,
             hook_type=hook_type,
-            pre_push=pre_push_contents,
+            hook_specific=hook_specific_contents,
             skip_on_missing_conf=skip_on_missing_conf,
         )
         pre_commit_file_obj.write(contents)
diff --git a/pre_commit/commands/run.py b/pre_commit/commands/run.py
index 676c504..c18f2aa 100644
--- a/pre_commit/commands/run.py
+++ b/pre_commit/commands/run.py
@@ -58,6 +58,9 @@ def get_filenames(args, include_expr, exclude_expr):
         getter = git.get_files_matching(
             lambda: get_changed_files(args.origin, args.source),
         )
+    elif args.hook_stage == 'commit-msg':
+        def getter(*_):
+            return (args.commit_msg_filename,)
     elif args.files:
         getter = git.get_files_matching(lambda: args.files)
     elif args.all_files:
diff --git a/pre_commit/main.py b/pre_commit/main.py
index 02eed40..37fb264 100644
--- a/pre_commit/main.py
+++ b/pre_commit/main.py
@@ -76,7 +76,7 @@ def main(argv=None):
         ),
     )
     install_parser.add_argument(
-        '-t', '--hook-type', choices=('pre-commit', 'pre-push'),
+        '-t', '--hook-type', choices=('pre-commit', 'pre-push', 'commit-msg'),
         default='pre-commit',
     )
     install_parser.add_argument(
@@ -149,6 +149,10 @@ def main(argv=None):
         '--source', '-s',
         help="The remote branch's commit_id when using `git push`.",
     )
+    run_parser.add_argument(
+        '--commit-msg-filename',
+        help='Filename to check when running during `commit-msg`',
+    )
     run_parser.add_argument(
         '--allow-unstaged-config', default=False, action='store_true',
         help=(
@@ -157,7 +161,8 @@ def main(argv=None):
         ),
     )
     run_parser.add_argument(
-        '--hook-stage', choices=('commit', 'push'), default='commit',
+        '--hook-stage', choices=('commit', 'push', 'commit-msg'),
+        default='commit',
         help='The stage during which the hook is fired e.g. commit or push.',
     )
     run_parser.add_argument(
diff --git a/pre_commit/resources/commit-msg-tmpl b/pre_commit/resources/commit-msg-tmpl
new file mode 100644
index 0000000..b11521b
--- /dev/null
+++ b/pre_commit/resources/commit-msg-tmpl
@@ -0,0 +1,1 @@
+args="run --hook-stage=commit-msg --commit-msg-filename=$1"
diff --git a/pre_commit/resources/hook-tmpl b/pre_commit/resources/hook-tmpl
index da939ff..3bfce5c 100644
--- a/pre_commit/resources/hook-tmpl
+++ b/pre_commit/resources/hook-tmpl
@@ -52,7 +52,7 @@ if [ ! -f $CONF_FILE  ]; then
 	fi
 fi
 
-{pre_push}
+{hook_specific}
 
 # Run pre-commit
 if ((WHICH_RETV == 0)); then

Support for commit-msg
Totally not trying to be snarky here-- the project is called _pre-commit_, after all-- but is there a reason commit-msg hooks are not supported? 

Being able to validate commit messages for appropriate issue-tracking ids, length, etc is important for a lot of us and having to put together another mechanism to deal with it or utilize another framework alongside pre-commit seems non-DRY-y.

I would put this as a "pro" for overcommit (see #384) but I'd really like to see it in pre-commit, as overcommit's performance in Windows is... subpar. (More of a ruby issue than overcommit.)

diff --git a/testing/fixtures.py b/testing/fixtures.py
index be43421..eda2e09 100644
--- a/testing/fixtures.py
+++ b/testing/fixtures.py
@@ -23,8 +23,7 @@ from testing.util import get_resource_path
 
 def git_dir(tempdir_factory):
     path = tempdir_factory.get()
-    with cwd(path):
-        cmd_output('git', 'init')
+    cmd_output('git', 'init', path)
     return path
 
 
diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py
index 1fb0f8f..94d396a 100644
--- a/tests/commands/install_uninstall_test.py
+++ b/tests/commands/install_uninstall_test.py
@@ -56,7 +56,7 @@ def test_install_pre_commit(tempdir_factory):
     expected_contents = io.open(pre_commit_script).read().format(
         sys_executable=sys.executable,
         hook_type='pre-commit',
-        pre_push='',
+        hook_specific='',
         skip_on_missing_conf='false',
     )
     assert pre_commit_contents == expected_contents
@@ -71,7 +71,7 @@ def test_install_pre_commit(tempdir_factory):
     expected_contents = io.open(pre_commit_script).read().format(
         sys_executable=sys.executable,
         hook_type='pre-push',
-        pre_push=pre_push_template_contents,
+        hook_specific=pre_push_template_contents,
         skip_on_missing_conf='false',
     )
     assert pre_push_contents == expected_contents
@@ -118,10 +118,11 @@ def test_uninstall(tempdir_factory):
 
 
 def _get_commit_output(tempdir_factory, touch_file='foo', **kwargs):
+    commit_msg = kwargs.pop('commit_msg', 'Commit!')
     open(touch_file, 'a').close()
     cmd_output('git', 'add', touch_file)
     return cmd_output_mocked_pre_commit_home(
-        'git', 'commit', '-am', 'Commit!', '--allow-empty',
+        'git', 'commit', '-am', commit_msg, '--allow-empty',
         # git commit puts pre-commit to stderr
         stderr=subprocess.STDOUT,
         retcode=None,
@@ -560,6 +561,24 @@ def test_pre_push_integration_empty_push(tempdir_factory):
         assert retc == 0
 
 
+def test_commit_msg_integration_failing(commit_msg_repo, tempdir_factory):
+    install(Runner(commit_msg_repo, C.CONFIG_FILE), hook_type='commit-msg')
+    retc, out = _get_commit_output(tempdir_factory)
+    assert retc == 1
+    assert out.startswith('Must have "Signed off by:"...')
+    assert out.strip().endswith('...Failed')
+
+
+def test_commit_msg_integration_passing(commit_msg_repo, tempdir_factory):
+    install(Runner(commit_msg_repo, C.CONFIG_FILE), hook_type='commit-msg')
+    msg = 'Hi\nSigned off by: me, lol'
+    retc, out = _get_commit_output(tempdir_factory, commit_msg=msg)
+    assert retc == 0
+    first_line = out.splitlines()[0]
+    assert first_line.startswith('Must have "Signed off by:"...')
+    assert first_line.endswith('...Passed')
+
+
 def test_install_disallow_mising_config(tempdir_factory):
     path = make_consuming_repo(tempdir_factory, 'script_hooks_repo')
     with cwd(path):
diff --git a/tests/commands/run_test.py b/tests/commands/run_test.py
index 59f2ec9..c360fde 100644
--- a/tests/commands/run_test.py
+++ b/tests/commands/run_test.py
@@ -60,6 +60,7 @@ def _get_opts(
         allow_unstaged_config=False,
         hook_stage='commit',
         show_diff_on_failure=False,
+        commit_msg_filename='',
 ):
     # These are mutually exclusive
     assert not (all_files and files)
@@ -75,6 +76,7 @@ def _get_opts(
         allow_unstaged_config=allow_unstaged_config,
         hook_stage=hook_stage,
         show_diff_on_failure=show_diff_on_failure,
+        commit_msg_filename=commit_msg_filename,
     )
 
 
@@ -572,40 +574,7 @@ def test_lots_of_files(mock_out_store_directory, tempdir_factory):
         )
 
 
-@pytest.mark.parametrize(
-    (
-        'hook_stage', 'stage_for_first_hook', 'stage_for_second_hook',
-        'expected_output',
-    ),
-    (
-        ('push', ['commit'], ['commit'], [b'', b'']),
-        (
-            'push', ['commit', 'push'], ['commit', 'push'],
-            [b'hook 1', b'hook 2'],
-        ),
-        ('push', [], [], [b'hook 1', b'hook 2']),
-        ('push', [], ['commit'], [b'hook 1', b'']),
-        ('push', ['push'], ['commit'], [b'hook 1', b'']),
-        ('push', ['commit'], ['push'], [b'', b'hook 2']),
-        (
-            'commit', ['commit', 'push'], ['commit', 'push'],
-            [b'hook 1', b'hook 2'],
-        ),
-        ('commit', ['commit'], ['commit'], [b'hook 1', b'hook 2']),
-        ('commit', [], [], [b'hook 1', b'hook 2']),
-        ('commit', [], ['commit'], [b'hook 1', b'hook 2']),
-        ('commit', ['push'], ['commit'], [b'', b'hook 2']),
-        ('commit', ['commit'], ['push'], [b'hook 1', b'']),
-    ),
-)
-def test_local_hook_for_stages(
-        cap_out,
-        repo_with_passing_hook, mock_out_store_directory,
-        stage_for_first_hook,
-        stage_for_second_hook,
-        hook_stage,
-        expected_output,
-):
+def test_push_hook(cap_out, repo_with_passing_hook, mock_out_store_directory):
     config = OrderedDict((
         ('repo', 'local'),
         (
@@ -613,37 +582,61 @@ def test_local_hook_for_stages(
                 OrderedDict((
                     ('id', 'flake8'),
                     ('name', 'hook 1'),
-                    ('entry', 'python -m flake8.__main__'),
+                    ('entry', "'{}' -m flake8".format(sys.executable)),
                     ('language', 'system'),
-                    ('files', r'\.py$'),
-                    ('stages', stage_for_first_hook),
-                )), OrderedDict((
+                    ('types', ['python']),
+                    ('stages', ['commit']),
+                )),
+                OrderedDict((
                     ('id', 'do_not_commit'),
                     ('name', 'hook 2'),
                     ('entry', 'DO NOT COMMIT'),
                     ('language', 'pcre'),
-                    ('files', '^(.*)$'),
-                    ('stages', stage_for_second_hook),
+                    ('types', ['text']),
+                    ('stages', ['push']),
                 )),
             ),
         ),
     ))
     add_config_to_repo(repo_with_passing_hook, config)
 
-    with io.open('dummy.py', 'w') as staged_file:
-        staged_file.write('"""TODO: something"""\n')
+    open('dummy.py', 'a').close()
     cmd_output('git', 'add', 'dummy.py')
 
     _test_run(
         cap_out,
         repo_with_passing_hook,
-        {'hook_stage': hook_stage},
-        expected_outputs=expected_output,
+        {'hook_stage': 'commit'},
+        expected_outputs=[b'hook 1'],
+        expected_ret=0,
+        stage=False,
+    )
+
+    _test_run(
+        cap_out,
+        repo_with_passing_hook,
+        {'hook_stage': 'push'},
+        expected_outputs=[b'hook 2'],
         expected_ret=0,
         stage=False,
     )
 
 
+def test_commit_msg_hook(cap_out, commit_msg_repo, mock_out_store_directory):
+    filename = '.git/COMMIT_EDITMSG'
+    with io.open(filename, 'w') as f:
+        f.write('This is the commit message')
+
+    _test_run(
+        cap_out,
+        commit_msg_repo,
+        {'hook_stage': 'commit-msg', 'commit_msg_filename': filename},
+        expected_outputs=[b'Must have "Signed off by:"', b'Failed'],
+        expected_ret=1,
+        stage=False,
+    )
+
+
 def test_local_hook_passes(
         cap_out, repo_with_passing_hook, mock_out_store_directory,
 ):
@@ -654,7 +647,7 @@ def test_local_hook_passes(
                 OrderedDict((
                     ('id', 'flake8'),
                     ('name', 'flake8'),
-                    ('entry', 'python -m flake8.__main__'),
+                    ('entry', "'{}' -m flake8".format(sys.executable)),
                     ('language', 'system'),
                     ('files', r'\.py$'),
                 )), OrderedDict((
diff --git a/tests/conftest.py b/tests/conftest.py
index 9813e9a..36743d8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,6 +1,7 @@
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
+import collections
 import functools
 import io
 import logging
@@ -20,6 +21,7 @@ from pre_commit.util import cmd_output
 from pre_commit.util import cwd
 from testing.fixtures import git_dir
 from testing.fixtures import make_consuming_repo
+from testing.fixtures import write_config
 
 
 @pytest.yield_fixture
@@ -92,6 +94,29 @@ def in_conflicting_submodule(tempdir_factory):
         yield
 
 
+@pytest.fixture
+def commit_msg_repo(tempdir_factory):
+    path = git_dir(tempdir_factory)
+    config = collections.OrderedDict((
+        ('repo', 'local'),
+        (
+            'hooks',
+            [collections.OrderedDict((
+                ('id', 'must-have-signoff'),
+                ('name', 'Must have "Signed off by:"'),
+                ('entry', 'grep -q "Signed off by:"'),
+                ('language', 'system'),
+                ('stages', ['commit-msg']),
+            ))],
+        ),
+    ))
+    write_config(path, config)
+    with cwd(path):
+        cmd_output('git', 'add', '.')
+        cmd_output('git', 'commit', '-m', 'add hooks')
+        yield path
+
+
 @pytest.yield_fixture(autouse=True, scope='session')
 def dont_write_to_home_directory():
     """pre_commit.store.Store will by default write to the home directory
@@ -170,7 +195,7 @@ class Fixture(object):
     def get_bytes(self):
         """Get the output as-if no encoding occurred"""
         data = self._stream.data.getvalue()
-        self._stream = io.BytesIO()
+        self._stream.data.truncate(0)
         return data
 
     def get(self):

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest
apt-get update apt-get install -y gcc
3.9
requirements-dev.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
aspy.yaml==1.3.0
cached-property==2.0.1
coverage==7.8.0
distlib==0.3.9
exceptiongroup==1.2.2
filelock==3.18.0
flake8==7.2.0
identify==2.6.9
iniconfig==2.1.0
mccabe==0.7.0
mock==5.2.0
nodeenv==1.9.1
packaging==24.2
platformdirs==4.3.7
pluggy==1.5.0
-e git+https://github.com/pre-commit/pre-commit.git@dd182fb42e0820d25ca865249d8c86fe98a0c8ec#egg=pre_commit
pycodestyle==2.13.0
pyflakes==3.3.1
pytest==8.3.5
pytest-env==1.1.5
PyYAML==6.0.2
six==1.17.0
tomli==2.2.1
virtualenv==20.29.3

name: pre-commit
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - aspy-yaml==1.3.0
      - cached-property==2.0.1
      - coverage==7.8.0
      - distlib==0.3.9
      - exceptiongroup==1.2.2
      - filelock==3.18.0
      - flake8==7.2.0
      - identify==2.6.9
      - iniconfig==2.1.0
      - mccabe==0.7.0
      - mock==5.2.0
      - nodeenv==1.9.1
      - packaging==24.2
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - pycodestyle==2.13.0
      - pyflakes==3.3.1
      - pytest==8.3.5
      - pytest-env==1.1.5
      - pyyaml==6.0.2
      - six==1.17.0
      - tomli==2.2.1
      - virtualenv==20.29.3
prefix: /opt/conda/envs/pre-commit

tests/commands/install_uninstall_test.py::test_install_pre_commit tests/commands/install_uninstall_test.py::test_commit_msg_integration_failing tests/commands/install_uninstall_test.py::test_commit_msg_integration_passing tests/commands/run_test.py::test_commit_msg_hook
tests/commands/install_uninstall_test.py::test_install_in_submodule_and_run tests/commands/run_test.py::test_arbitrary_bytes_hook tests/commands/run_test.py::test_hook_install_failure
tests/commands/install_uninstall_test.py::test_is_not_script tests/commands/install_uninstall_test.py::test_is_script tests/commands/install_uninstall_test.py::test_is_previous_pre_commit tests/commands/install_uninstall_test.py::test_install_hooks_directory_not_present tests/commands/install_uninstall_test.py::test_install_hooks_dead_symlink tests/commands/install_uninstall_test.py::test_uninstall_does_not_blow_up_when_not_there tests/commands/install_uninstall_test.py::test_uninstall tests/commands/install_uninstall_test.py::test_install_pre_commit_and_run tests/commands/install_uninstall_test.py::test_commit_am tests/commands/install_uninstall_test.py::test_unicode_merge_commit_message tests/commands/install_uninstall_test.py::test_install_idempotent tests/commands/install_uninstall_test.py::test_environment_not_sourced tests/commands/install_uninstall_test.py::test_failing_hooks_returns_nonzero tests/commands/install_uninstall_test.py::test_install_existing_hooks_no_overwrite tests/commands/install_uninstall_test.py::test_install_existing_hook_no_overwrite_idempotent tests/commands/install_uninstall_test.py::test_failing_existing_hook_returns_1 tests/commands/install_uninstall_test.py::test_install_overwrite_no_existing_hooks tests/commands/install_uninstall_test.py::test_install_overwrite tests/commands/install_uninstall_test.py::test_uninstall_restores_legacy_hooks tests/commands/install_uninstall_test.py::test_replace_old_commit_script tests/commands/install_uninstall_test.py::test_uninstall_doesnt_remove_not_our_hooks tests/commands/install_uninstall_test.py::test_installs_hooks_with_hooks_True tests/commands/install_uninstall_test.py::test_install_hooks_command tests/commands/install_uninstall_test.py::test_installed_from_venv tests/commands/install_uninstall_test.py::test_pre_push_integration_failing tests/commands/install_uninstall_test.py::test_pre_push_integration_accepted tests/commands/install_uninstall_test.py::test_pre_push_integration_empty_push tests/commands/install_uninstall_test.py::test_install_disallow_mising_config tests/commands/install_uninstall_test.py::test_install_allow_mising_config tests/commands/install_uninstall_test.py::test_install_temporarily_allow_mising_config tests/commands/run_test.py::test_run_all_hooks_failing tests/commands/run_test.py::test_hook_that_modifies_but_returns_zero tests/commands/run_test.py::test_types_hook_repository tests/commands/run_test.py::test_exclude_types_hook_repository tests/commands/run_test.py::test_show_diff_on_failure tests/commands/run_test.py::test_run[options0-outputs0-0-True] tests/commands/run_test.py::test_run[options1-outputs1-0-True] tests/commands/run_test.py::test_run[options2-outputs2-0-True] tests/commands/run_test.py::test_run[options3-outputs3-1-True] tests/commands/run_test.py::test_run[options4-outputs4-0-True] tests/commands/run_test.py::test_run[options5-outputs5-0-True] tests/commands/run_test.py::test_run[options6-outputs6-0-False] tests/commands/run_test.py::test_run_output_logfile tests/commands/run_test.py::test_always_run tests/commands/run_test.py::test_always_run_alt_config tests/commands/run_test.py::test_origin_source_error_msg[master-master-False] tests/commands/run_test.py::test_origin_source_error_msg[master--True] tests/commands/run_test.py::test_origin_source_error_msg[-master-True] tests/commands/run_test.py::test_no_stash[True-True-False] tests/commands/run_test.py::test_no_stash[True-False-False] tests/commands/run_test.py::test_no_stash[False-True-False] tests/commands/run_test.py::test_no_stash[False-False-True] tests/commands/run_test.py::test_has_unmerged_paths[some-True] tests/commands/run_test.py::test_has_unmerged_paths[-False] tests/commands/run_test.py::test_merge_conflict tests/commands/run_test.py::test_merge_conflict_modified tests/commands/run_test.py::test_merge_conflict_resolved tests/commands/run_test.py::test_compute_cols[hooks0-True-80] tests/commands/run_test.py::test_compute_cols[hooks1-False-81] tests/commands/run_test.py::test_compute_cols[hooks2-True-85] tests/commands/run_test.py::test_compute_cols[hooks3-False-82] tests/commands/run_test.py::test_get_skips[environ0-expected_output0] tests/commands/run_test.py::test_get_skips[environ1-expected_output1] tests/commands/run_test.py::test_get_skips[environ2-expected_output2] tests/commands/run_test.py::test_get_skips[environ3-expected_output3] tests/commands/run_test.py::test_get_skips[environ4-expected_output4] tests/commands/run_test.py::test_get_skips[environ5-expected_output5] tests/commands/run_test.py::test_get_skips[environ6-expected_output6] tests/commands/run_test.py::test_skip_hook tests/commands/run_test.py::test_hook_id_not_in_non_verbose_output tests/commands/run_test.py::test_hook_id_in_verbose_output tests/commands/run_test.py::test_multiple_hooks_same_id tests/commands/run_test.py::test_non_ascii_hook_id tests/commands/run_test.py::test_stdout_write_bug_py26 tests/commands/run_test.py::test_get_changed_files tests/commands/run_test.py::test_lots_of_files tests/commands/run_test.py::test_push_hook tests/commands/run_test.py::test_local_hook_passes tests/commands/run_test.py::test_local_hook_fails tests/commands/run_test.py::test_allow_unstaged_config_option tests/commands/run_test.py::test_no_allow_unstaged_config_option tests/commands/run_test.py::test_unstaged_message_suppressed[opts0] tests/commands/run_test.py::test_unstaged_message_suppressed[opts1] tests/commands/run_test.py::test_unstaged_message_suppressed[opts2] tests/commands/run_test.py::test_files_running_subdir tests/commands/run_test.py::test_pass_filenames[True-hook_args0-foo.py] tests/commands/run_test.py::test_pass_filenames[False-hook_args1-] tests/commands/run_test.py::test_pass_filenames[True-hook_args2-some tests/commands/run_test.py::test_pass_filenames[False-hook_args3-some
pre_commit/resources/commit-msg-tmpl pre_commit/resources/hook-tmpl pre_commit/commands/install_uninstall.py pre_commit/commands/run.py pre_commit/main.py
pre_commit/resources/commit-msg-tmpl pre_commit/resources/hook-tmpl pre_commit/commands/install_uninstall.py pre_commit/commands/run.py pre_commit/main.py