Repository: pre-commit/pre-commit

Issue:
optional feature for auto installing the hooks on cloned repos
As pre-commit becomes more and more popular I often face myself the problem that I forget to install the hooks on newly cloned repositories.

What it would be cook is if (at clone time?) I could get a prompt that would ask me if I want to enable the hooks or not for that repository.

The idea is to find a way to detect that a repository has pre-commit but the hooks ar ere not installed and allow user to install them, with 1-2 key strokes.


Discussion:
I use this: https://github.com/asottile/scratch/blob/357638a6f88261ad3715dfe2e4bbdea653f756e3/.bashrc#L21

there isn't really a way to hook into `clone` or other things so I'm not sure what can be done here from a framework perspective beyond documenting something like this

though I guess maybe it could be provided as `eval "$(pre-commit autoinstall)"` or something and have support for several shells
@ssbarnea thought on that?
Actually, seems now that I've fixed up some things since the original implementation, `pre-commit` is compatible with `init.templatedir`

I think it might make sense to have a `pre-commit init-templatedir` which ~essentially does the following:

- `mkdir -p "$templatedir/hooks"`
- write out `$templatedir/hooks/pre-commit` using the template but with `--allow-missing-config`
- (maybe?) set `git config --global init.templatedir ...`

Resolving patch:
diff --git a/pre_commit/commands/init_templatedir.py b/pre_commit/commands/init_templatedir.py
new file mode 100644
--- /dev/null
+++ b/pre_commit/commands/init_templatedir.py
@@ -0,0 +1,21 @@
+import logging
+import os.path
+
+from pre_commit.commands.install_uninstall import install
+from pre_commit.util import cmd_output
+
+logger = logging.getLogger('pre_commit')
+
+
+def init_templatedir(config_file, store, directory, hook_type):
+    install(
+        config_file, store, overwrite=True, hook_type=hook_type,
+        skip_on_missing_config=True, git_dir=directory,
+    )
+    _, out, _ = cmd_output('git', 'config', 'init.templateDir', retcode=None)
+    dest = os.path.realpath(directory)
+    if os.path.realpath(out.strip()) != dest:
+        logger.warning('`init.templateDir` not set to the target directory')
+        logger.warning(
+            'maybe `git config --global init.templateDir {}`?'.format(dest),
+        )
diff --git a/pre_commit/commands/install_uninstall.py b/pre_commit/commands/install_uninstall.py
--- a/pre_commit/commands/install_uninstall.py
+++ b/pre_commit/commands/install_uninstall.py
@@ -34,8 +34,9 @@
 TEMPLATE_END = '# end templated\n'
 
 
-def _hook_paths(hook_type):
-    pth = os.path.join(git.get_git_dir(), 'hooks', hook_type)
+def _hook_paths(hook_type, git_dir=None):
+    git_dir = git_dir if git_dir is not None else git.get_git_dir()
+    pth = os.path.join(git_dir, 'hooks', hook_type)
     return pth, '{}.legacy'.format(pth)
 
 
@@ -69,7 +70,7 @@ def shebang():
 def install(
         config_file, store,
         overwrite=False, hooks=False, hook_type='pre-commit',
-        skip_on_missing_conf=False,
+        skip_on_missing_config=False, git_dir=None,
 ):
     """Install the pre-commit hooks."""
     if cmd_output('git', 'config', 'core.hooksPath', retcode=None)[1].strip():
@@ -79,7 +80,7 @@ def install(
         )
         return 1
 
-    hook_path, legacy_path = _hook_paths(hook_type)
+    hook_path, legacy_path = _hook_paths(hook_type, git_dir=git_dir)
 
     mkdirp(os.path.dirname(hook_path))
 
@@ -100,7 +101,7 @@ def install(
         'CONFIG': config_file,
         'HOOK_TYPE': hook_type,
         'INSTALL_PYTHON': sys.executable,
-        'SKIP_ON_MISSING_CONFIG': skip_on_missing_conf,
+        'SKIP_ON_MISSING_CONFIG': skip_on_missing_config,
     }
 
     with io.open(hook_path, 'w') as hook_file:
diff --git a/pre_commit/main.py b/pre_commit/main.py
--- a/pre_commit/main.py
+++ b/pre_commit/main.py
@@ -12,6 +12,7 @@
 from pre_commit.commands.autoupdate import autoupdate
 from pre_commit.commands.clean import clean
 from pre_commit.commands.gc import gc
+from pre_commit.commands.init_templatedir import init_templatedir
 from pre_commit.commands.install_uninstall import install
 from pre_commit.commands.install_uninstall import install_hooks
 from pre_commit.commands.install_uninstall import uninstall
@@ -131,6 +132,51 @@ def main(argv=None):
 
     subparsers = parser.add_subparsers(dest='command')
 
+    autoupdate_parser = subparsers.add_parser(
+        'autoupdate',
+        help="Auto-update pre-commit config to the latest repos' versions.",
+    )
+    _add_color_option(autoupdate_parser)
+    _add_config_option(autoupdate_parser)
+    autoupdate_parser.add_argument(
+        '--tags-only', action='store_true', help='LEGACY: for compatibility',
+    )
+    autoupdate_parser.add_argument(
+        '--bleeding-edge', action='store_true',
+        help=(
+            'Update to the bleeding edge of `master` instead of the latest '
+            'tagged version (the default behavior).'
+        ),
+    )
+    autoupdate_parser.add_argument(
+        '--repo', dest='repos', action='append', metavar='REPO',
+        help='Only update this repository -- may be specified multiple times.',
+    )
+
+    clean_parser = subparsers.add_parser(
+        'clean', help='Clean out pre-commit files.',
+    )
+    _add_color_option(clean_parser)
+    _add_config_option(clean_parser)
+
+    gc_parser = subparsers.add_parser('gc', help='Clean unused cached repos.')
+    _add_color_option(gc_parser)
+    _add_config_option(gc_parser)
+
+    init_templatedir_parser = subparsers.add_parser(
+        'init-templatedir',
+        help=(
+            'Install hook script in a directory intended for use with '
+            '`git config init.templateDir`.'
+        ),
+    )
+    _add_color_option(init_templatedir_parser)
+    _add_config_option(init_templatedir_parser)
+    init_templatedir_parser.add_argument(
+        'directory', help='The directory in which to write the hook script.',
+    )
+    _add_hook_type_option(init_templatedir_parser)
+
     install_parser = subparsers.add_parser(
         'install', help='Install the pre-commit script.',
     )
@@ -167,44 +213,6 @@ def main(argv=None):
     _add_color_option(install_hooks_parser)
     _add_config_option(install_hooks_parser)
 
-    uninstall_parser = subparsers.add_parser(
-        'uninstall', help='Uninstall the pre-commit script.',
-    )
-    _add_color_option(uninstall_parser)
-    _add_config_option(uninstall_parser)
-    _add_hook_type_option(uninstall_parser)
-
-    clean_parser = subparsers.add_parser(
-        'clean', help='Clean out pre-commit files.',
-    )
-    _add_color_option(clean_parser)
-    _add_config_option(clean_parser)
-
-    gc_parser = subparsers.add_parser('gc', help='Clean unused cached repos.')
-    _add_color_option(gc_parser)
-    _add_config_option(gc_parser)
-
-    autoupdate_parser = subparsers.add_parser(
-        'autoupdate',
-        help="Auto-update pre-commit config to the latest repos' versions.",
-    )
-    _add_color_option(autoupdate_parser)
-    _add_config_option(autoupdate_parser)
-    autoupdate_parser.add_argument(
-        '--tags-only', action='store_true', help='LEGACY: for compatibility',
-    )
-    autoupdate_parser.add_argument(
-        '--bleeding-edge', action='store_true',
-        help=(
-            'Update to the bleeding edge of `master` instead of the latest '
-            'tagged version (the default behavior).'
-        ),
-    )
-    autoupdate_parser.add_argument(
-        '--repo', dest='repos', action='append', metavar='REPO',
-        help='Only update this repository -- may be specified multiple times.',
-    )
-
     migrate_config_parser = subparsers.add_parser(
         'migrate-config',
         help='Migrate list configuration to new map configuration.',
@@ -241,6 +249,13 @@ def main(argv=None):
     )
     _add_run_options(try_repo_parser)
 
+    uninstall_parser = subparsers.add_parser(
+        'uninstall', help='Uninstall the pre-commit script.',
+    )
+    _add_color_option(uninstall_parser)
+    _add_config_option(uninstall_parser)
+    _add_hook_type_option(uninstall_parser)
+
     help = subparsers.add_parser(
         'help', help='Show help for a specific command.',
     )
@@ -265,29 +280,32 @@ def main(argv=None):
         store = Store()
         store.mark_config_used(args.config)
 
-        if args.command == 'install':
-            return install(
+        if args.command == 'autoupdate':
+            if args.tags_only:
+                logger.warning('--tags-only is the default')
+            return autoupdate(
                 args.config, store,
-                overwrite=args.overwrite, hooks=args.install_hooks,
-                hook_type=args.hook_type,
-                skip_on_missing_conf=args.allow_missing_config,
+                tags_only=not args.bleeding_edge,
+                repos=args.repos,
             )
-        elif args.command == 'install-hooks':
-            return install_hooks(args.config, store)
-        elif args.command == 'uninstall':
-            return uninstall(hook_type=args.hook_type)
         elif args.command == 'clean':
             return clean(store)
         elif args.command == 'gc':
             return gc(store)
-        elif args.command == 'autoupdate':
-            if args.tags_only:
-                logger.warning('--tags-only is the default')
-            return autoupdate(
+        elif args.command == 'install':
+            return install(
                 args.config, store,
-                tags_only=not args.bleeding_edge,
-                repos=args.repos,
+                overwrite=args.overwrite, hooks=args.install_hooks,
+                hook_type=args.hook_type,
+                skip_on_missing_config=args.allow_missing_config,
             )
+        elif args.command == 'init-templatedir':
+            return init_templatedir(
+                args.config, store,
+                args.directory, hook_type=args.hook_type,
+            )
+        elif args.command == 'install-hooks':
+            return install_hooks(args.config, store)
         elif args.command == 'migrate-config':
             return migrate_config(args.config)
         elif args.command == 'run':
@@ -296,6 +314,8 @@ def main(argv=None):
             return sample_config()
         elif args.command == 'try-repo':
             return try_repo(args)
+        elif args.command == 'uninstall':
+            return uninstall(hook_type=args.hook_type)
         else:
             raise NotImplementedError(
                 'Command {} not implemented.'.format(args.command),


Test patch:
diff --git a/tests/commands/init_templatedir_test.py b/tests/commands/init_templatedir_test.py
new file mode 100644
--- /dev/null
+++ b/tests/commands/init_templatedir_test.py
@@ -0,0 +1,49 @@
+import subprocess
+
+import pre_commit.constants as C
+from pre_commit.commands.init_templatedir import init_templatedir
+from pre_commit.envcontext import envcontext
+from pre_commit.util import cmd_output
+from testing.fixtures import git_dir
+from testing.fixtures import make_consuming_repo
+from testing.util import cmd_output_mocked_pre_commit_home
+from testing.util import cwd
+from testing.util import git_commit
+
+
+def test_init_templatedir(tmpdir, tempdir_factory, store, cap_out):
+    target = str(tmpdir.join('tmpl'))
+    init_templatedir(C.CONFIG_FILE, store, target, hook_type='pre-commit')
+    lines = cap_out.get().splitlines()
+    assert lines[0].startswith('pre-commit installed at ')
+    assert lines[1] == (
+        '[WARNING] `init.templateDir` not set to the target directory'
+    )
+    assert lines[2].startswith(
+        '[WARNING] maybe `git config --global init.templateDir',
+    )
+
+    with envcontext([('GIT_TEMPLATE_DIR', target)]):
+        path = make_consuming_repo(tempdir_factory, 'script_hooks_repo')
+
+        with cwd(path):
+            retcode, output, _ = git_commit(
+                fn=cmd_output_mocked_pre_commit_home,
+                tempdir_factory=tempdir_factory,
+                # git commit puts pre-commit to stderr
+                stderr=subprocess.STDOUT,
+            )
+            assert retcode == 0
+            assert 'Bash hook....' in output
+
+
+def test_init_templatedir_already_set(tmpdir, tempdir_factory, store, cap_out):
+    target = str(tmpdir.join('tmpl'))
+    tmp_git_dir = git_dir(tempdir_factory)
+    with cwd(tmp_git_dir):
+        cmd_output('git', 'config', 'init.templateDir', target)
+        init_templatedir(C.CONFIG_FILE, store, target, hook_type='pre-commit')
+
+    lines = cap_out.get().splitlines()
+    assert len(lines) == 1
+    assert lines[0].startswith('pre-commit installed at')
diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py
--- a/tests/commands/install_uninstall_test.py
+++ b/tests/commands/install_uninstall_test.py
@@ -735,7 +735,7 @@ def test_install_disallow_missing_config(tempdir_factory, store):
     with cwd(path):
         remove_config_from_repo(path)
         ret = install(
-            C.CONFIG_FILE, store, overwrite=True, skip_on_missing_conf=False,
+            C.CONFIG_FILE, store, overwrite=True, skip_on_missing_config=False,
         )
         assert ret == 0
 
@@ -748,7 +748,7 @@ def test_install_allow_missing_config(tempdir_factory, store):
     with cwd(path):
         remove_config_from_repo(path)
         ret = install(
-            C.CONFIG_FILE, store, overwrite=True, skip_on_missing_conf=True,
+            C.CONFIG_FILE, store, overwrite=True, skip_on_missing_config=True,
         )
         assert ret == 0
 
@@ -766,7 +766,7 @@ def test_install_temporarily_allow_mising_config(tempdir_factory, store):
     with cwd(path):
         remove_config_from_repo(path)
         ret = install(
-            C.CONFIG_FILE, store, overwrite=True, skip_on_missing_conf=False,
+            C.CONFIG_FILE, store, overwrite=True, skip_on_missing_config=False,
         )
         assert ret == 0
 
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -11,6 +11,7 @@
 import six
 
 from pre_commit import output
+from pre_commit.envcontext import envcontext
 from pre_commit.logging_handler import logging_handler
 from pre_commit.store import Store
 from pre_commit.util import cmd_output
@@ -272,3 +273,10 @@ def fake_log_handler():
     logger.addHandler(handler)
     yield handler
     logger.removeHandler(handler)
+
+
+@pytest.fixture(scope='session', autouse=True)
+def set_git_templatedir(tmpdir_factory):
+    tdir = str(tmpdir_factory.mktemp('git_template_dir'))
+    with envcontext([('GIT_TEMPLATE_DIR', tdir)]):
+        yield
diff --git a/tests/main_test.py b/tests/main_test.py
--- a/tests/main_test.py
+++ b/tests/main_test.py
@@ -140,6 +140,12 @@ def test_try_repo(mock_store_dir):
     assert patch.call_count == 1
 
 
+def test_init_templatedir(mock_store_dir):
+    with mock.patch.object(main, 'init_templatedir') as patch:
+        main.main(('init-templatedir', 'tdir'))
+    assert patch.call_count == 1
+
+
 def test_help_cmd_in_empty_directory(
         in_tmpdir,
         mock_commands,