Repository: pre-commit/pre-commit
Pull request #699 (2018-02-04 03:09:10)

Rewrite the hook template in python
This also fixes the `pre-push` part of #693


Patch:
diff --git a/pre_commit/commands/install_uninstall.py b/pre_commit/commands/install_uninstall.py
index 01aad52..83b97cb 100644
--- a/pre_commit/commands/install_uninstall.py
+++ b/pre_commit/commands/install_uninstall.py
@@ -3,7 +3,6 @@ from __future__ import unicode_literals
 
 import io
 import os.path
-import pipes
 import sys
 
 from pre_commit import output
@@ -21,6 +20,8 @@ PRIOR_HASHES = (
     'e358c9dae00eac5d06b38dfdb1e33a8c',
 )
 CURRENT_HASH = '138fd403232d2ddd5efb44317e38bf03'
+TEMPLATE_START = '# start templated\n'
+TEMPLATE_END = '# end templated\n'
 
 
 def is_our_script(filename):
@@ -50,32 +51,27 @@ def install(
     elif os.path.exists(legacy_path):
         output.write_line(
             'Running in migration mode with existing hooks at {}\n'
-            'Use -f to use only pre-commit.'.format(
-                legacy_path,
-            ),
+            'Use -f to use only pre-commit.'.format(legacy_path),
         )
 
-    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 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:
-            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=pipes.quote(sys.executable),
-            hook_type=hook_type,
-            hook_specific=hook_specific_contents,
-            config_file=runner.config_file,
-            skip_on_missing_conf=skip_on_missing_conf,
-        )
-        pre_commit_file_obj.write(contents)
+    params = {
+        'CONFIG': runner.config_file,
+        'HOOK_TYPE': hook_type,
+        'INSTALL_PYTHON': sys.executable,
+        'SKIP_ON_MISSING_CONFIG': skip_on_missing_conf,
+    }
+
+    with io.open(hook_path, 'w') as hook_file:
+        with io.open(resource_filename('hook-tmpl')) as f:
+            contents = f.read()
+        before, rest = contents.split(TEMPLATE_START)
+        to_template, after = rest.split(TEMPLATE_END)
+
+        hook_file.write(before + TEMPLATE_START)
+        for line in to_template.splitlines():
+            var = line.split()[0]
+            hook_file.write('{} = {!r}\n'.format(var, params[var]))
+        hook_file.write(TEMPLATE_END + after)
     make_executable(hook_path)
 
     output.write_line('pre-commit installed at {}'.format(hook_path))
diff --git a/pre_commit/resources/commit-msg-tmpl b/pre_commit/resources/commit-msg-tmpl
deleted file mode 100644
index 182f214..0000000
--- a/pre_commit/resources/commit-msg-tmpl
+++ /dev/null
@@ -1,1 +0,0 @@
-args="--hook-stage=commit-msg --commit-msg-filename=$1"
diff --git a/pre_commit/resources/hook-tmpl b/pre_commit/resources/hook-tmpl
old mode 100644
new mode 100755
index b7f1623..2a9657e
--- a/pre_commit/resources/hook-tmpl
+++ b/pre_commit/resources/hook-tmpl
@@ -1,55 +1,167 @@
-#!/usr/bin/env bash
-# This is a randomish md5 to identify this script
-# 138fd403232d2ddd5efb44317e38bf03
-
-pushd "$(dirname "$0")" >& /dev/null
-HERE="$(pwd)"
-popd >& /dev/null
-
-retv=0
-args=""
-
-ENV_PYTHON={sys_executable}
-SKIP_ON_MISSING_CONF={skip_on_missing_conf}
-
-if which pre-commit >& /dev/null; then
-    exe="pre-commit"
-    run_args=""
-elif "$ENV_PYTHON" -c 'import pre_commit.main' >& /dev/null; then
-    exe="$ENV_PYTHON"
-    run_args="-m pre_commit.main"
-elif python -c 'import pre_commit.main' >& /dev/null; then
-    exe="python"
-    run_args="-m pre_commit.main"
-else
-    echo '`pre-commit` not found.  Did you forget to activate your virtualenv?'
-    exit 1
-fi
-
-# Run the legacy pre-commit if it exists
-if [ -x "$HERE"/{hook_type}.legacy ] && ! "$HERE"/{hook_type}.legacy "$@"; then
-    retv=1
-fi
-
-CONF_FILE="$(git rev-parse --show-toplevel)/{config_file}"
-if [ ! -f "$CONF_FILE" ]; then
-    if [ "$SKIP_ON_MISSING_CONF" = true -o ! -z "$PRE_COMMIT_ALLOW_NO_CONFIG" ]; then
-        echo '`{config_file}` config file not found. Skipping `pre-commit`.'
-        exit $retv
-    else
-        echo 'No {config_file} file was found'
-        echo '- To temporarily silence this, run `PRE_COMMIT_ALLOW_NO_CONFIG=1 git ...`'
-        echo '- To permanently silence this, install pre-commit with the `--allow-missing-config` option'
-        echo '- To uninstall pre-commit run `pre-commit uninstall`'
-        exit 1
-	fi
-fi
-
-{hook_specific}
-
-# Run pre-commit
-if ! "$exe" $run_args run $args --config {config_file}; then
-    retv=1
-fi
-
-exit $retv
+#!/usr/bin/env python
+"""File generated by pre-commit: https://pre-commit.com"""
+from __future__ import print_function
+
+import distutils.spawn
+import os
+import subprocess
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+Z40 = '0' * 40
+ID_HASH = '138fd403232d2ddd5efb44317e38bf03'
+# start templated
+CONFIG = None
+HOOK_TYPE = None
+INSTALL_PYTHON = None
+SKIP_ON_MISSING_CONFIG = None
+# end templated
+
+
+class EarlyExit(RuntimeError):
+    pass
+
+
+class FatalError(RuntimeError):
+    pass
+
+
+def _norm_exe(exe):
+    """Necessary for shebang support on windows.
+
+    roughly lifted from `identify.identify.parse_shebang`
+    """
+    with open(exe, 'rb') as f:
+        if f.read(2) != b'#!':
+            return ()
+        try:
+            first_line = f.readline().decode('UTF-8')
+        except UnicodeDecodeError:
+            return ()
+
+        cmd = first_line.split()
+        if cmd[0] == '/usr/bin/env':
+            del cmd[0]
+        return tuple(cmd)
+
+
+def _run_legacy():
+    if HOOK_TYPE == 'pre-push':
+        stdin = getattr(sys.stdin, 'buffer', sys.stdin).read()
+    else:
+        stdin = None
+
+    legacy_hook = os.path.join(HERE, '{}.legacy'.format(HOOK_TYPE))
+    if os.access(legacy_hook, os.X_OK):
+        cmd = _norm_exe(legacy_hook) + (legacy_hook,) + tuple(sys.argv[1:])
+        proc = subprocess.Popen(cmd, stdin=subprocess.PIPE if stdin else None)
+        proc.communicate(stdin)
+        return proc.returncode, stdin
+    else:
+        return 0, stdin
+
+
+def _validate_config():
+    cmd = ('git', 'rev-parse', '--show-toplevel')
+    top_level = subprocess.check_output(cmd).decode('UTF-8').strip()
+    cfg = os.path.join(top_level, CONFIG)
+    if os.path.isfile(cfg):
+        pass
+    elif SKIP_ON_MISSING_CONFIG or os.getenv('PRE_COMMIT_ALLOW_NO_CONFIG'):
+        print(
+            '`{}` config file not found. '
+            'Skipping `pre-commit`.'.format(CONFIG),
+        )
+        raise EarlyExit()
+    else:
+        raise FatalError(
+            'No {} file was found\n'
+            '- To temporarily silence this, run '
+            '`PRE_COMMIT_ALLOW_NO_CONFIG=1 git ...`\n'
+            '- To permanently silence this, install pre-commit with the '
+            '--allow-missing-config option\n'
+            '- To uninstall pre-commit run '
+            '`pre-commit uninstall`'.format(CONFIG),
+        )
+
+
+def _exe():
+    with open(os.devnull, 'wb') as devnull:
+        for exe in (INSTALL_PYTHON, sys.executable):
+            try:
+                if not subprocess.call(
+                        (exe, '-c', 'import pre_commit.main'),
+                        stdout=devnull, stderr=devnull,
+                ):
+                    return (exe, '-m', 'pre_commit.main', 'run')
+            except OSError:
+                pass
+
+    if distutils.spawn.find_executable('pre-commit'):
+        return ('pre-commit', 'run')
+
+    raise FatalError(
+        '`pre-commit` not found.  Did you forget to activate your virtualenv?',
+    )
+
+
+def _pre_push(stdin):
+    remote = sys.argv[1]
+
+    opts = ()
+    for line in stdin.decode('UTF-8').splitlines():
+        _, local_sha, _, remote_sha = line.split()
+        if local_sha == Z40:
+            continue
+        elif remote_sha != Z40:
+            opts = ('--origin', local_sha, '--source', remote_sha)
+        else:
+            # First ancestor not found in remote
+            first_ancestor = subprocess.check_output((
+                'git', 'rev-list', '--max-count=1', '--topo-order',
+                '--reverse', local_sha, '--not', '--remotes={}'.format(remote),
+            )).decode().strip()
+            if not first_ancestor:
+                continue
+            else:
+                cmd = ('git', 'rev-list', '--max-parents=0', local_sha)
+                roots = set(subprocess.check_output(cmd).decode().splitlines())
+                if first_ancestor in roots:
+                    # pushing the whole tree including root commit
+                    opts = ('--all-files',)
+                else:
+                    cmd = ('git', 'rev-parse', '{}^'.format(first_ancestor))
+                    source = subprocess.check_output(cmd).decode().strip()
+                    opts = ('--origin', local_sha, '--source', source)
+
+    if opts:
+        return opts
+    else:
+        # An attempt to push an empty changeset
+        raise EarlyExit()
+
+
+def _opts(stdin):
+    fns = {
+        'commit-msg': lambda _: ('--commit-msg-filename', sys.argv[1]),
+        'pre-commit': lambda _: (),
+        'pre-push': _pre_push,
+    }
+    stage = HOOK_TYPE.replace('pre-', '')
+    return ('--config', CONFIG, '--hook-stage', stage) + fns[HOOK_TYPE](stdin)
+
+
+def main():
+    retv, stdin = _run_legacy()
+    try:
+        _validate_config()
+        return retv | subprocess.call(_exe() + _opts(stdin))
+    except EarlyExit:
+        return retv
+    except FatalError as e:
+        print(e.args[0])
+        return 1
+
+
+if __name__ == '__main__':
+    exit(main())
diff --git a/pre_commit/resources/pre-push-tmpl b/pre_commit/resources/pre-push-tmpl
deleted file mode 100644
index 0a3dad5..0000000
--- a/pre_commit/resources/pre-push-tmpl
+++ /dev/null
@@ -1,31 +0,0 @@
-z40=0000000000000000000000000000000000000000
-while read local_ref local_sha remote_ref remote_sha
-do
-    if [ "$local_sha" != $z40 ]; then
-        if [ "$remote_sha" = $z40 ]; then
-            # First ancestor not found in remote
-            first_ancestor=$(git rev-list --topo-order --reverse "$local_sha" --not --remotes="$1" | head -n 1)
-            if [ -n "$first_ancestor" ]; then
-                # Check that the ancestor has at least one parent
-                git rev-list --max-parents=0 "$local_sha" | grep "$first_ancestor" > /dev/null
-                if [ $? -eq 0 ]; then
-                    # Pushing the whole tree, including the root commit, so run on all files
-                    args="--all-files"
-                else
-                    source=$(git rev-parse "$first_ancestor"^)
-                    args="--origin $local_sha --source $source"
-                fi
-            fi
-        else
-            args="--origin $local_sha --source $remote_sha"
-        fi
-    fi
-done
-
-if [ "$args" != "" ]; then
-    args="$args --hook-stage push"
-else
-    # If args is empty, then an attempt to push on an empty
-    # changeset is being made. In this case, just exit cleanly
-    exit 0
-fi


Test patch:
diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py
index 1469a3e..ea6727e 100644
--- a/tests/commands/install_uninstall_test.py
+++ b/tests/commands/install_uninstall_test.py
@@ -4,7 +4,6 @@ from __future__ import unicode_literals
 
 import io
 import os.path
-import pipes
 import re
 import shutil
 import subprocess
@@ -49,35 +48,11 @@ def test_is_previous_pre_commit(tmpdir):
 def test_install_pre_commit(tempdir_factory):
     path = git_dir(tempdir_factory)
     runner = Runner(path, C.CONFIG_FILE)
-    ret = install(runner)
-    assert ret == 0
-    assert os.path.exists(runner.pre_commit_path)
-    pre_commit_contents = io.open(runner.pre_commit_path).read()
-    pre_commit_script = resource_filename('hook-tmpl')
-    expected_contents = io.open(pre_commit_script).read().format(
-        sys_executable=pipes.quote(sys.executable),
-        hook_type='pre-commit',
-        hook_specific='',
-        config_file=runner.config_file,
-        skip_on_missing_conf='false',
-    )
-    assert pre_commit_contents == expected_contents
+    assert not install(runner)
     assert os.access(runner.pre_commit_path, os.X_OK)
 
-    ret = install(runner, hook_type='pre-push')
-    assert ret == 0
-    assert os.path.exists(runner.pre_push_path)
-    pre_push_contents = io.open(runner.pre_push_path).read()
-    pre_push_tmpl = resource_filename('pre-push-tmpl')
-    pre_push_template_contents = io.open(pre_push_tmpl).read()
-    expected_contents = io.open(pre_commit_script).read().format(
-        sys_executable=pipes.quote(sys.executable),
-        hook_type='pre-push',
-        hook_specific=pre_push_template_contents,
-        config_file=runner.config_file,
-        skip_on_missing_conf='false',
-    )
-    assert pre_push_contents == expected_contents
+    assert not install(runner, hook_type='pre-push')
+    assert os.access(runner.pre_push_path, os.X_OK)
 
 
 def test_install_hooks_directory_not_present(tempdir_factory):
@@ -242,7 +217,7 @@ def test_environment_not_sourced(tempdir_factory):
     path = make_consuming_repo(tempdir_factory, 'script_hooks_repo')
     with cwd(path):
         # Patch the executable to simulate rming virtualenv
-        with mock.patch.object(sys, 'executable', '/bin/false'):
+        with mock.patch.object(sys, 'executable', '/does-not-exist'):
             assert install(Runner(path, C.CONFIG_FILE)) == 0
 
         # Use a specific homedir to ignore --user installs
@@ -262,7 +237,7 @@ def test_environment_not_sourced(tempdir_factory):
         )
         assert ret == 1
         assert stdout == ''
-        assert stderr == (
+        assert stderr.replace('\r\n', '\n') == (
             '`pre-commit` not found.  '
             'Did you forget to activate your virtualenv?\n'
         )
@@ -593,6 +568,36 @@ def test_pre_push_integration_empty_push(tempdir_factory):
         assert retc == 0
 
 
+def test_pre_push_legacy(tempdir_factory):
+    upstream = make_consuming_repo(tempdir_factory, 'script_hooks_repo')
+    path = tempdir_factory.get()
+    cmd_output('git', 'clone', upstream, path)
+    with cwd(path):
+        runner = Runner(path, C.CONFIG_FILE)
+
+        hook_path = runner.get_hook_path('pre-push')
+        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'
+                'read lr ls rr rs\n'
+                'test -n "$lr" -a -n "$ls" -a -n "$rr" -a -n "$rs"\n'
+                'echo legacy\n',
+            )
+        make_executable(hook_path)
+
+        install(runner, hook_type='pre-push')
+        assert _get_commit_output(tempdir_factory)[0] == 0
+
+        retc, output = _get_push_output(tempdir_factory)
+        assert retc == 0
+        first_line, _, third_line = output.splitlines()[:3]
+        assert first_line == 'legacy'
+        assert third_line.startswith('Bash hook')
+        assert third_line.endswith('Passed')
+
+
 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)