diff --git a/pre_commit/color.py b/pre_commit/color.py index 641e719e..b9808ad4 100644 --- a/pre_commit/color.py +++ b/pre_commit/color.py @@ -3,6 +3,8 @@ import sys RED = '\033[41m' GREEN = '\033[42m' +YELLOW = '\033[43;30m' +TURQUOISE = '\033[46;30m' NORMAL = '\033[0m' diff --git a/pre_commit/logging_handler.py b/pre_commit/logging_handler.py new file mode 100644 index 00000000..11736d1b --- /dev/null +++ b/pre_commit/logging_handler.py @@ -0,0 +1,32 @@ + +from __future__ import print_function + +import logging + +from pre_commit import color + + +LOG_LEVEL_COLORS = { + 'DEBUG': '', + 'INFO': '', + 'WARNING': color.YELLOW, + 'ERROR': color.RED, +} + + +class LoggingHandler(logging.Handler): + def __init__(self, use_color): + super(LoggingHandler, self).__init__() + self.use_color = use_color + + def emit(self, record): + print( + u'{0}{1}'.format( + color.format_color( + '[{0}]'.format(record.levelname), + LOG_LEVEL_COLORS[record.levelname], + self.use_color, + ) + ' ' if record.levelno >= logging.WARNING else '', + record.getMessage(), + ) + ) diff --git a/pre_commit/prefixed_command_runner.py b/pre_commit/prefixed_command_runner.py index bee77016..ed88f219 100644 --- a/pre_commit/prefixed_command_runner.py +++ b/pre_commit/prefixed_command_runner.py @@ -6,6 +6,9 @@ import subprocess class CalledProcessError(RuntimeError): def __init__(self, returncode, cmd, expected_returncode, output=None): + super(CalledProcessError, self).__init__( + returncode, cmd, expected_returncode, output, + ) self.returncode = returncode self.cmd = cmd self.expected_returncode = expected_returncode @@ -15,13 +18,13 @@ class CalledProcessError(RuntimeError): return ( 'Command: {0!r}\n' 'Return code: {1}\n' - 'Expected return code {2}\n', + 'Expected return code: {2}\n' 'Output: {3!r}\n'.format( self.cmd, self.returncode, self.expected_returncode, self.output, - ), + ) ) @@ -48,15 +51,15 @@ class PrefixedCommandRunner(object): self.__makedirs(self.prefix_dir) def run(self, cmd, retcode=0, stdin=None, **kwargs): + popen_kwargs = { + 'stdin': subprocess.PIPE, + 'stdout': subprocess.PIPE, + 'stderr': subprocess.PIPE, + } + popen_kwargs.update(kwargs) self._create_path_if_not_exists() replaced_cmd = _replace_cmd(cmd, prefix=self.prefix_dir) - proc = self.__popen( - replaced_cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - **kwargs - ) + proc = self.__popen(replaced_cmd, **popen_kwargs) stdout, stderr = proc.communicate(stdin) returncode = proc.returncode diff --git a/pre_commit/repository.py b/pre_commit/repository.py index 46764957..a8611f79 100644 --- a/pre_commit/repository.py +++ b/pre_commit/repository.py @@ -1,7 +1,6 @@ -from __future__ import print_function - import contextlib +import logging from plumbum import local import pre_commit.constants as C @@ -14,6 +13,9 @@ from pre_commit.util import cached_property from pre_commit.util import clean_path_on_failure +logger = logging.getLogger('pre_commit') + + class Repository(object): def __init__(self, repo_config): self.repo_config = repo_config @@ -66,9 +68,9 @@ class Repository(object): return # Checking out environment for the first time - print('Installing environment for {0}.'.format(self.repo_url)) - print('Once installed this environment will be reused.') - print('This may take a few minutes...') + logger.info('Installing environment for {0}.'.format(self.repo_url)) + logger.info('Once installed this environment will be reused.') + logger.info('This may take a few minutes...') with clean_path_on_failure(unicode(local.path(self.sha))): local['git']['clone', '--no-checkout', self.repo_url, self.sha]() with self.in_checkout(): diff --git a/pre_commit/run.py b/pre_commit/run.py index 7f1cb6b5..17aa2aa9 100644 --- a/pre_commit/run.py +++ b/pre_commit/run.py @@ -2,16 +2,21 @@ from __future__ import print_function import argparse +import logging import subprocess import sys from pre_commit import color from pre_commit import commands from pre_commit import git +from pre_commit.logging_handler import LoggingHandler from pre_commit.runner import Runner +from pre_commit.staged_files_only import staged_files_only from pre_commit.util import entry +logger = logging.getLogger('pre_commit') + COLS = int(subprocess.Popen(['tput', 'cols'], stdout=subprocess.PIPE).communicate()[0]) PASS_FAIL_LENGTH = 6 @@ -81,10 +86,15 @@ def run_single_hook(runner, hook_id, args): def _run(runner, args): - if args.hook: - return run_single_hook(runner, args.hook, args) - else: - return run_hooks(runner, args) + # Set up our logging handler + logger.addHandler(LoggingHandler(args.color)) + logger.setLevel(logging.INFO) + + with staged_files_only(runner.cmd_runner): + if args.hook: + return run_single_hook(runner, args.hook, args) + else: + return run_hooks(runner, args) @entry diff --git a/pre_commit/staged_files_only.py b/pre_commit/staged_files_only.py new file mode 100644 index 00000000..8a38ae11 --- /dev/null +++ b/pre_commit/staged_files_only.py @@ -0,0 +1,57 @@ + +import contextlib +import logging +import time + +from pre_commit.prefixed_command_runner import CalledProcessError + + +logger = logging.getLogger('pre_commit') + + +@contextlib.contextmanager +def staged_files_only(cmd_runner): + """Clear any unstaged changes from the git working directory inside this + context. + + Args: + cmd_runner - PrefixedCommandRunner + """ + # Determine if there are unstaged files + retcode, _, _ = cmd_runner.run( + ['git', 'diff-files', '--quiet'], + retcode=None, + ) + if retcode: + patch_filename = cmd_runner.path('patch{0}'.format(int(time.time()))) + logger.warning('Unstaged files detected.') + logger.info( + 'Stashing unstaged files to {0}.'.format(patch_filename), + ) + # Save the current unstaged changes as a patch + with open(patch_filename, 'w') as patch_file: + cmd_runner.run(['git', 'diff', '--binary'], stdout=patch_file) + + # Clear the working directory of unstaged changes + cmd_runner.run(['git', 'checkout', '--', '.']) + try: + yield + finally: + # Try to apply the patch we saved + try: + cmd_runner.run(['git', 'apply', patch_filename]) + except CalledProcessError: + logger.warning( + 'Stashed changes conflicted with hook auto-fixes... ' + 'Rolling back fixes...' + ) + # We failed to apply the patch, presumably due to fixes made + # by hooks. + # Roll back the changes made by hooks. + cmd_runner.run(['git', 'checkout', '--', '.']) + cmd_runner.run(['git', 'apply', patch_filename]) + logger.info('Restored changes from {0}.'.format(patch_filename)) + else: + # There weren't any staged files so we don't need to do anything + # special + yield diff --git a/testing/resources/img1.jpg b/testing/resources/img1.jpg new file mode 100644 index 00000000..dea42627 Binary files /dev/null and b/testing/resources/img1.jpg differ diff --git a/testing/resources/img2.jpg b/testing/resources/img2.jpg new file mode 100644 index 00000000..68568e5b Binary files /dev/null and b/testing/resources/img2.jpg differ diff --git a/testing/resources/img3.jpg b/testing/resources/img3.jpg new file mode 100644 index 00000000..392d2cf2 Binary files /dev/null and b/testing/resources/img3.jpg differ diff --git a/tests/prefixed_command_runner_test.py b/tests/prefixed_command_runner_test.py index d5266022..a6cee09d 100644 --- a/tests/prefixed_command_runner_test.py +++ b/tests/prefixed_command_runner_test.py @@ -9,6 +9,16 @@ from pre_commit.prefixed_command_runner import CalledProcessError from pre_commit.prefixed_command_runner import PrefixedCommandRunner +def test_CalledProcessError_str(): + error = CalledProcessError(1, ['git', 'status'], 0, ('stdout', 'stderr')) + assert str(error) == ( + "Command: ['git', 'status']\n" + "Return code: 1\n" + "Expected return code: 0\n" + "Output: ('stdout', 'stderr')\n" + ) + + @pytest.fixture def popen_mock(): popen = mock.Mock(spec=subprocess.Popen) diff --git a/tests/repository_test.py b/tests/repository_test.py index 925dbbdd..eb840c2c 100644 --- a/tests/repository_test.py +++ b/tests/repository_test.py @@ -1,10 +1,10 @@ -import __builtin__ import mock import os import pytest import pre_commit.constants as C from pre_commit import git +from pre_commit import repository from pre_commit.clientlib.validate_config import CONFIG_JSON_SCHEMA from pre_commit.clientlib.validate_config import validate_config_extra from pre_commit.jsonschema_extensions import apply_defaults @@ -135,23 +135,25 @@ def test_languages(config_for_python_hooks_repo): @pytest.yield_fixture -def print_mock(): - with mock.patch.object(__builtin__, 'print', autospec=True) as print_mock: - yield print_mock +def logger_mock(): + with mock.patch.object( + repository.logger, 'info', autospec=True, + ) as info_mock: + yield info_mock -def test_prints_while_creating(config_for_python_hooks_repo, print_mock): +def test_prints_while_creating(config_for_python_hooks_repo, logger_mock): repo = Repository(config_for_python_hooks_repo) repo.require_created() - print_mock.assert_called_with('This may take a few minutes...') - print_mock.reset_mock() + logger_mock.assert_called_with('This may take a few minutes...') + logger_mock.reset_mock() # Reinstall with same repo should not trigger another install repo.require_created() - assert print_mock.call_count == 0 + assert logger_mock.call_count == 0 # Reinstall on another run should not trigger another install repo = Repository(config_for_python_hooks_repo) repo.require_created() - assert print_mock.call_count == 0 + assert logger_mock.call_count == 0 def test_reinstall(config_for_python_hooks_repo): diff --git a/tests/staged_files_only_test.py b/tests/staged_files_only_test.py new file mode 100644 index 00000000..ebada7f1 --- /dev/null +++ b/tests/staged_files_only_test.py @@ -0,0 +1,218 @@ + +import os.path +import pytest +import shutil +from plumbum import local + +import pre_commit.constants as C +from pre_commit.prefixed_command_runner import PrefixedCommandRunner +from pre_commit.staged_files_only import staged_files_only +from testing.auto_namedtuple import auto_namedtuple +from testing.util import get_resource_path + + +FOO_CONTENTS = '\n'.join(('1', '2', '3', '4', '5', '6', '7', '8', '')) + + +def get_short_git_status(): + git_status = local['git']['status', '-s']() + return dict(reversed(line.split()) for line in git_status.splitlines()) + + +@pytest.yield_fixture +def foo_staged(empty_git_dir): + with open('.gitignore', 'w') as gitignore_file: + gitignore_file.write(C.HOOKS_WORKSPACE + '\n') + local['git']['add', '.']() + local['git']['commit', '-m', 'add gitignore']() + + with open('foo', 'w') as foo_file: + foo_file.write(FOO_CONTENTS) + local['git']['add', 'foo']() + foo_filename = os.path.join(empty_git_dir, 'foo') + yield auto_namedtuple(path=empty_git_dir, foo_filename=foo_filename) + + +@pytest.fixture +def cmd_runner(): + return PrefixedCommandRunner(C.HOOKS_WORKSPACE) + + +def _test_foo_state(path, foo_contents=FOO_CONTENTS, status='A'): + assert os.path.exists(path.foo_filename) + assert open(path.foo_filename).read() == foo_contents + actual_status = get_short_git_status()['foo'] + assert status == actual_status + + +def test_foo_staged(foo_staged): + _test_foo_state(foo_staged) + + +def test_foo_nothing_unstaged(foo_staged, cmd_runner): + with staged_files_only(cmd_runner): + _test_foo_state(foo_staged) + _test_foo_state(foo_staged) + + +def test_foo_something_unstaged(foo_staged, cmd_runner): + with open(foo_staged.foo_filename, 'w') as foo_file: + foo_file.write('herp\nderp\n') + + _test_foo_state(foo_staged, 'herp\nderp\n', 'AM') + + with staged_files_only(cmd_runner): + _test_foo_state(foo_staged) + + _test_foo_state(foo_staged, 'herp\nderp\n', 'AM') + + +def test_foo_both_modify_non_conflicting(foo_staged, cmd_runner): + with open(foo_staged.foo_filename, 'w') as foo_file: + foo_file.write(FOO_CONTENTS + '9\n') + + _test_foo_state(foo_staged, FOO_CONTENTS + '9\n', 'AM') + + with staged_files_only(cmd_runner): + _test_foo_state(foo_staged) + + # Modify the file as part of the "pre-commit" + with open(foo_staged.foo_filename, 'w') as foo_file: + foo_file.write(FOO_CONTENTS.replace('1', 'a')) + + _test_foo_state(foo_staged, FOO_CONTENTS.replace('1', 'a'), 'AM') + + _test_foo_state(foo_staged, FOO_CONTENTS.replace('1', 'a') + '9\n', 'AM') + + +def test_foo_both_modify_conflicting(foo_staged, cmd_runner): + with open(foo_staged.foo_filename, 'w') as foo_file: + foo_file.write(FOO_CONTENTS.replace('1', 'a')) + + _test_foo_state(foo_staged, FOO_CONTENTS.replace('1', 'a'), 'AM') + + with staged_files_only(cmd_runner): + _test_foo_state(foo_staged) + + # Modify in the same place as the stashed diff + with open(foo_staged.foo_filename, 'w') as foo_file: + foo_file.write(FOO_CONTENTS.replace('1', 'b')) + + _test_foo_state(foo_staged, FOO_CONTENTS.replace('1', 'b'), 'AM') + + _test_foo_state(foo_staged, FOO_CONTENTS.replace('1', 'a'), 'AM') + + +@pytest.yield_fixture +def img_staged(empty_git_dir): + with open('.gitignore', 'w') as gitignore_file: + gitignore_file.write(C.HOOKS_WORKSPACE + '\n') + local['git']['add', '.']() + local['git']['commit', '-m', 'add gitignore']() + + img_filename = os.path.join(empty_git_dir, 'img.jpg') + shutil.copy(get_resource_path('img1.jpg'), img_filename) + local['git']['add', 'img.jpg']() + yield auto_namedtuple(path=empty_git_dir, img_filename=img_filename) + + +def _test_img_state(path, expected_file='img1.jpg', status='A'): + assert os.path.exists(path.img_filename) + assert ( + open(path.img_filename).read() == + open(get_resource_path(expected_file)).read() + ) + actual_status = get_short_git_status()['img.jpg'] + assert status == actual_status + + +def test_img_staged(img_staged): + _test_img_state(img_staged) + + +def test_img_nothing_unstaged(img_staged, cmd_runner): + with staged_files_only(cmd_runner): + _test_img_state(img_staged) + _test_img_state(img_staged) + + +def test_img_something_unstaged(img_staged, cmd_runner): + shutil.copy(get_resource_path('img2.jpg'), img_staged.img_filename) + + _test_img_state(img_staged, 'img2.jpg', 'AM') + + with staged_files_only(cmd_runner): + _test_img_state(img_staged) + + _test_img_state(img_staged, 'img2.jpg', 'AM') + + +def test_img_conflict(img_staged, cmd_runner): + """Admittedly, this shouldn't happen, but just in case.""" + shutil.copy(get_resource_path('img2.jpg'), img_staged.img_filename) + + _test_img_state(img_staged, 'img2.jpg', 'AM') + + with staged_files_only(cmd_runner): + _test_img_state(img_staged) + shutil.copy(get_resource_path('img3.jpg'), img_staged.img_filename) + _test_img_state(img_staged, 'img3.jpg', 'AM') + + _test_img_state(img_staged, 'img2.jpg', 'AM') + + +@pytest.yield_fixture +def submodule_with_commits(empty_git_dir): + local['git']['commit', '--allow-empty', '-m', 'foo']() + sha1 = local['git']['rev-parse', 'HEAD']().strip() + local['git']['commit', '--allow-empty', '-m', 'bar']() + sha2 = local['git']['rev-parse', 'HEAD']().strip() + yield auto_namedtuple(path=empty_git_dir, sha1=sha1, sha2=sha2) + + +def checkout_submodule(sha): + with local.cwd('sub'): + local['git']['checkout', sha]() + + +@pytest.yield_fixture +def sub_staged(submodule_with_commits, empty_git_dir): + local['git']['submodule', 'add', submodule_with_commits.path, 'sub']() + checkout_submodule(submodule_with_commits.sha1) + local['git']['add', 'sub']() + yield auto_namedtuple( + path=empty_git_dir, + sub_path=os.path.join(empty_git_dir, 'sub'), + submodule=submodule_with_commits, + ) + + +def _test_sub_state(path, sha='sha1', status='A'): + assert os.path.exists(path.sub_path) + with local.cwd(path.sub_path): + actual_sha = local['git']['rev-parse', 'HEAD']().strip() + assert actual_sha == getattr(path.submodule, sha) + actual_status = get_short_git_status()['sub'] + assert actual_status == status + + +def test_sub_staged(sub_staged): + _test_sub_state(sub_staged) + + +def test_sub_nothing_unstaged(sub_staged, cmd_runner): + with staged_files_only(cmd_runner): + _test_sub_state(sub_staged) + _test_sub_state(sub_staged) + + +def test_sub_something_unstaged(sub_staged, cmd_runner): + checkout_submodule(sub_staged.submodule.sha2) + + _test_sub_state(sub_staged, 'sha2', 'AM') + + with staged_files_only(cmd_runner): + # This is different from others, we don't want to touch subs + _test_sub_state(sub_staged, 'sha2', 'AM') + + _test_sub_state(sub_staged, 'sha2', 'AM')