From 4ed9120ae9322af57706cdf8801b3a140a89dde6 Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sat, 5 Apr 2014 18:41:49 -0700 Subject: [PATCH] Add staged_files_only context manager. --- pre_commit/prefixed_command_runner.py | 21 +-- pre_commit/staged_files_only.py | 47 ++++++ testing/resources/img1.jpg | Bin 0 -> 843 bytes testing/resources/img2.jpg | Bin 0 -> 891 bytes testing/resources/img3.jpg | Bin 0 -> 859 bytes tests/prefixed_command_runner_test.py | 10 ++ tests/staged_files_only_test.py | 218 ++++++++++++++++++++++++++ 7 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 pre_commit/staged_files_only.py create mode 100644 testing/resources/img1.jpg create mode 100644 testing/resources/img2.jpg create mode 100644 testing/resources/img3.jpg create mode 100644 tests/staged_files_only_test.py 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/staged_files_only.py b/pre_commit/staged_files_only.py new file mode 100644 index 00000000..859dd25b --- /dev/null +++ b/pre_commit/staged_files_only.py @@ -0,0 +1,47 @@ + +import contextlib +import time + +from pre_commit.prefixed_command_runner import CalledProcessError + + +@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: + # TODO: print a warning message that unstaged things are being stashed + # Save the current unstaged changes as a patch + # TODO: use a more unique patch filename + patch_filename = cmd_runner.path('patch{0}'.format(time.time())) + 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: + # TOOD: print a warning about rolling back changes made by hooks + # 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]) + 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 0000000000000000000000000000000000000000..dea42627ad9f5eb8061bf5068117faa3ba22591c GIT binary patch literal 843 zcmex=HEAm;@P_1sVSzVUTBFU}R+k0|qEyWMXDv zWn<^yn#iR?;+B1Vl97jh^& zZ9FI%bn%0VaZ*teCzqJGgrtbvx}>nyN9P&a7buactm7Wa!P7i zdPZheaY<=ec|~Pab4zPmdq-#2q{&mJPMbbs=B!1Fmn>bje8tLDn>KIRx^4T8ox2Vl zK63Qf@e?OcUAlbb>b2`PZr*zM=<$=M&z`?{`Re1R&tJZN`~KtSFOa_&8JNMo0ud08 zq4`UYfr*icg@u`got1@!fsv`4fsu(>kcCyzkWI)jkUgfTZlNKjz zSD*Wn`jOlDgLq5+o3*;LV#}kVYvZm*aP)nc6tX4j`Lq7)C5ttiiWtvnSNs21wa@aR z+v3tfn_D|BCT`j_>EWiBH5(aj9ysSvf0%ij|FeiY=D)4xHEAm;@P_1sVSzVUTBFU}R+k0|qEyWMXDv zWn<^yn#iR?;+B1Vl97jh^& zZ9FI%bn%0VaZ*teCzqJGgrtbvx}>nyN9P&a7buactm7Wa!P7i zdPZheaY<=ec|~Pab4zPmdq-#2q{&mJPMbbs=B!1Fmn>bje8tLDn>KIRx^4T8ox2Vl zK63Qf@e?OcUAlbb>b2`PZr*zM=<$=M&z`?{`Re1R&tJZN`~KtSFOa_&8JNMo0ud08 zq4`UYfr*icg@u`got1@!fsv`4fsu(>kcCyzkWI)jkUg=tL_Uok|j_9tAn7U`n73B%aHtSxfPGBfESt4=n5#!&R>dN1meuzI> z-zI1N(QWrbdA?V63YTo0HLs_9WSzYD)hy9lr_8(4KB-LacrxSo^_g@1uEv$@ zxc-QK*DwE$t+wh%_VIpjo%*IutL3%0_qW@3UGHpOv*JuzhryvOF>YoX1AiZ4VE=y; E07W=G@&Et; literal 0 HcmV?d00001 diff --git a/testing/resources/img3.jpg b/testing/resources/img3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..392d2cf22882b17530452c023ea6f12e885a895b GIT binary patch literal 859 zcmex=HEAm;@P_1sVSzVUTBFU}R+k0|qEyWMXDv zWn<^yn#iR?;+B1Vl97jh^& zZ9FI%bn%0VaZ*teCzqJGgrtbvx}>nyN9P&a7buactm7Wa!P7i zdPZheaY<=ec|~Pab4zPmdq-#2q{&mJPMbbs=B!1Fmn>bje8tLDn>KIRx^4T8ox2Vl zK63Qf@e?OcUAlbb>b2`PZr*zM=<$=M&z`?{`Re1R&tJZN`~KtSFOa_&8JNMo0ud08 zq4`UYfr*icg@u`got1@!fsv`4fsu(>kcCyzkWI)jkUg9$5 zc5KFwBb7RL2dFQ?YYcHu1&sSlk`YZWm{W0+sk7&CP=b*JU^AhU{horK2b8H VPxatl$@@Vf%Nv7xO5Ok81OQCrCF%eG literal 0 HcmV?d00001 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/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')