diff --git a/pre_commit/commands/install_uninstall.py b/pre_commit/commands/install_uninstall.py index 01aad52d..83b97cb1 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)) + params = { + 'CONFIG': runner.config_file, + 'HOOK_TYPE': hook_type, + 'INSTALL_PYTHON': sys.executable, + 'SKIP_ON_MISSING_CONFIG': skip_on_missing_conf, + } - 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) + 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 182f214a..00000000 --- a/pre_commit/resources/commit-msg-tmpl +++ /dev/null @@ -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 b7f16231..2a9657ed --- 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 +#!/usr/bin/env python +"""File generated by pre-commit: https://pre-commit.com""" +from __future__ import print_function -pushd "$(dirname "$0")" >& /dev/null -HERE="$(pwd)" -popd >& /dev/null +import distutils.spawn +import os +import subprocess +import sys -retv=0 -args="" +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 -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 +class EarlyExit(RuntimeError): + pass -# 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 +class FatalError(RuntimeError): + pass -{hook_specific} -# Run pre-commit -if ! "$exe" $run_args run $args --config {config_file}; then - retv=1 -fi +def _norm_exe(exe): + """Necessary for shebang support on windows. -exit $retv + 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 0a3dad57..00000000 --- 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 diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py index 1469a3ee..ea6727e4 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)