mirror of
https://github.com/pre-commit/pre-commit.git
synced 2026-01-14 04:50:20 -06:00
Merge pull request #65 from pre-commit/stash_before_check_30
Pre-commit stashes unstaged changes on run. Closes #30.
This commit is contained in:
@@ -3,6 +3,8 @@ import sys
|
||||
|
||||
RED = '\033[41m'
|
||||
GREEN = '\033[42m'
|
||||
YELLOW = '\033[43;30m'
|
||||
TURQUOISE = '\033[46;30m'
|
||||
NORMAL = '\033[0m'
|
||||
|
||||
|
||||
|
||||
32
pre_commit/logging_handler.py
Normal file
32
pre_commit/logging_handler.py
Normal file
@@ -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(),
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
57
pre_commit/staged_files_only.py
Normal file
57
pre_commit/staged_files_only.py
Normal file
@@ -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
|
||||
BIN
testing/resources/img1.jpg
Normal file
BIN
testing/resources/img1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 843 B |
BIN
testing/resources/img2.jpg
Normal file
BIN
testing/resources/img2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 891 B |
BIN
testing/resources/img3.jpg
Normal file
BIN
testing/resources/img3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 859 B |
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
218
tests/staged_files_only_test.py
Normal file
218
tests/staged_files_only_test.py
Normal file
@@ -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')
|
||||
Reference in New Issue
Block a user