From c86df75ab11643fa4649cfe6ed5c4692d4ee342b Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Mon, 15 Apr 2024 20:05:20 +0200 Subject: Adding upstream version 3.6.2. Signed-off-by: Daniel Baumann --- tests/commands/__init__.py | 0 tests/commands/autoupdate_test.py | 532 +++++++++++++ tests/commands/clean_test.py | 35 + tests/commands/gc_test.py | 164 ++++ tests/commands/hook_impl_test.py | 381 ++++++++++ tests/commands/init_templatedir_test.py | 142 ++++ tests/commands/install_uninstall_test.py | 1104 +++++++++++++++++++++++++++ tests/commands/migrate_config_test.py | 177 +++++ tests/commands/run_test.py | 1219 ++++++++++++++++++++++++++++++ tests/commands/sample_config_test.py | 21 + tests/commands/try_repo_test.py | 155 ++++ tests/commands/validate_config_test.py | 64 ++ tests/commands/validate_manifest_test.py | 18 + 13 files changed, 4012 insertions(+) create mode 100644 tests/commands/__init__.py create mode 100644 tests/commands/autoupdate_test.py create mode 100644 tests/commands/clean_test.py create mode 100644 tests/commands/gc_test.py create mode 100644 tests/commands/hook_impl_test.py create mode 100644 tests/commands/init_templatedir_test.py create mode 100644 tests/commands/install_uninstall_test.py create mode 100644 tests/commands/migrate_config_test.py create mode 100644 tests/commands/run_test.py create mode 100644 tests/commands/sample_config_test.py create mode 100644 tests/commands/try_repo_test.py create mode 100644 tests/commands/validate_config_test.py create mode 100644 tests/commands/validate_manifest_test.py (limited to 'tests/commands') diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/commands/autoupdate_test.py b/tests/commands/autoupdate_test.py new file mode 100644 index 0000000..71bd044 --- /dev/null +++ b/tests/commands/autoupdate_test.py @@ -0,0 +1,532 @@ +from __future__ import annotations + +import shlex +from unittest import mock + +import pytest + +import pre_commit.constants as C +from pre_commit import envcontext +from pre_commit import git +from pre_commit import yaml +from pre_commit.commands.autoupdate import _check_hooks_still_exist_at_rev +from pre_commit.commands.autoupdate import autoupdate +from pre_commit.commands.autoupdate import RepositoryCannotBeUpdatedError +from pre_commit.commands.autoupdate import RevInfo +from pre_commit.util import cmd_output +from testing.auto_namedtuple import auto_namedtuple +from testing.fixtures import add_config_to_repo +from testing.fixtures import make_config_from_repo +from testing.fixtures import make_repo +from testing.fixtures import modify_manifest +from testing.fixtures import read_config +from testing.fixtures import sample_local_config +from testing.fixtures import write_config +from testing.util import git_commit + + +@pytest.fixture +def up_to_date(tempdir_factory): + yield make_repo(tempdir_factory, 'python_hooks_repo') + + +@pytest.fixture +def out_of_date(tempdir_factory): + path = make_repo(tempdir_factory, 'python_hooks_repo') + original_rev = git.head_rev(path) + + git_commit(cwd=path) + head_rev = git.head_rev(path) + + yield auto_namedtuple( + path=path, original_rev=original_rev, head_rev=head_rev, + ) + + +@pytest.fixture +def tagged(out_of_date): + cmd_output('git', 'tag', 'v1.2.3', cwd=out_of_date.path) + yield out_of_date + + +@pytest.fixture +def hook_disappearing(tempdir_factory): + path = make_repo(tempdir_factory, 'python_hooks_repo') + original_rev = git.head_rev(path) + + with modify_manifest(path) as manifest: + manifest[0]['id'] = 'bar' + + yield auto_namedtuple(path=path, original_rev=original_rev) + + +def test_rev_info_from_config(): + info = RevInfo.from_config({'repo': 'repo/path', 'rev': 'v1.2.3'}) + assert info == RevInfo('repo/path', 'v1.2.3', None) + + +def test_rev_info_update_up_to_date_repo(up_to_date): + config = make_config_from_repo(up_to_date) + info = RevInfo.from_config(config)._replace(hook_ids=frozenset(('foo',))) + new_info = info.update(tags_only=False, freeze=False) + assert info == new_info + + +def test_rev_info_update_out_of_date_repo(out_of_date): + config = make_config_from_repo( + out_of_date.path, rev=out_of_date.original_rev, + ) + info = RevInfo.from_config(config) + new_info = info.update(tags_only=False, freeze=False) + assert new_info.rev == out_of_date.head_rev + + +def test_rev_info_update_non_master_default_branch(out_of_date): + # change the default branch to be not-master + cmd_output('git', '-C', out_of_date.path, 'branch', '-m', 'dev') + test_rev_info_update_out_of_date_repo(out_of_date) + + +def test_rev_info_update_tags_even_if_not_tags_only(tagged): + config = make_config_from_repo(tagged.path, rev=tagged.original_rev) + info = RevInfo.from_config(config) + new_info = info.update(tags_only=False, freeze=False) + assert new_info.rev == 'v1.2.3' + + +def test_rev_info_update_tags_only_does_not_pick_tip(tagged): + git_commit(cwd=tagged.path) + config = make_config_from_repo(tagged.path, rev=tagged.original_rev) + info = RevInfo.from_config(config) + new_info = info.update(tags_only=True, freeze=False) + assert new_info.rev == 'v1.2.3' + + +def test_rev_info_update_tags_prefers_version_tag(tagged, out_of_date): + cmd_output('git', 'tag', 'latest', cwd=out_of_date.path) + config = make_config_from_repo(tagged.path, rev=tagged.original_rev) + info = RevInfo.from_config(config) + new_info = info.update(tags_only=True, freeze=False) + assert new_info.rev == 'v1.2.3' + + +def test_rev_info_update_tags_non_version_tag(out_of_date): + cmd_output('git', 'tag', 'latest', cwd=out_of_date.path) + config = make_config_from_repo( + out_of_date.path, rev=out_of_date.original_rev, + ) + info = RevInfo.from_config(config) + new_info = info.update(tags_only=True, freeze=False) + assert new_info.rev == 'latest' + + +def test_rev_info_update_freeze_tag(tagged): + git_commit(cwd=tagged.path) + config = make_config_from_repo(tagged.path, rev=tagged.original_rev) + info = RevInfo.from_config(config) + new_info = info.update(tags_only=True, freeze=True) + assert new_info.rev == tagged.head_rev + assert new_info.frozen == 'v1.2.3' + + +def test_rev_info_update_does_not_freeze_if_already_sha(out_of_date): + config = make_config_from_repo( + out_of_date.path, rev=out_of_date.original_rev, + ) + info = RevInfo.from_config(config) + new_info = info.update(tags_only=True, freeze=True) + assert new_info.rev == out_of_date.head_rev + assert new_info.frozen is None + + +def test_autoupdate_up_to_date_repo(up_to_date, tmpdir): + contents = ( + f'repos:\n' + f'- repo: {up_to_date}\n' + f' rev: {git.head_rev(up_to_date)}\n' + f' hooks:\n' + f' - id: foo\n' + ) + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write(contents) + + assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0 + assert cfg.read() == contents + + +def test_autoupdate_old_revision_broken(tempdir_factory, in_tmpdir): + """In $FUTURE_VERSION, hooks.yaml will no longer be supported. This + asserts that when that day comes, pre-commit will be able to autoupdate + despite not being able to read hooks.yaml in that repository. + """ + path = make_repo(tempdir_factory, 'python_hooks_repo') + config = make_config_from_repo(path, check=False) + + cmd_output('git', 'mv', C.MANIFEST_FILE, 'nope.yaml', cwd=path) + git_commit(cwd=path) + # Assume this is the revision the user's old repository was at + rev = git.head_rev(path) + cmd_output('git', 'mv', 'nope.yaml', C.MANIFEST_FILE, cwd=path) + git_commit(cwd=path) + update_rev = git.head_rev(path) + + config['rev'] = rev + write_config('.', config) + with open(C.CONFIG_FILE) as f: + before = f.read() + assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) == 0 + with open(C.CONFIG_FILE) as f: + after = f.read() + assert before != after + assert update_rev in after + + +def test_autoupdate_out_of_date_repo(out_of_date, tmpdir): + fmt = ( + 'repos:\n' + '- repo: {}\n' + ' rev: {}\n' + ' hooks:\n' + ' - id: foo\n' + ) + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write(fmt.format(out_of_date.path, out_of_date.original_rev)) + + assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0 + assert cfg.read() == fmt.format(out_of_date.path, out_of_date.head_rev) + + +def test_autoupdate_with_core_useBuiltinFSMonitor(out_of_date, tmpdir): + # force the setting on "globally" for git + home = tmpdir.join('fakehome').ensure_dir() + home.join('.gitconfig').write('[core]\nuseBuiltinFSMonitor = true\n') + with envcontext.envcontext((('HOME', str(home)),)): + test_autoupdate_out_of_date_repo(out_of_date, tmpdir) + + +def test_autoupdate_pure_yaml(out_of_date, tmpdir): + with mock.patch.object(yaml, 'Dumper', yaml.yaml.SafeDumper): + test_autoupdate_out_of_date_repo(out_of_date, tmpdir) + + +def test_autoupdate_only_one_to_update(up_to_date, out_of_date, tmpdir): + fmt = ( + 'repos:\n' + '- repo: {}\n' + ' rev: {}\n' + ' hooks:\n' + ' - id: foo\n' + '- repo: {}\n' + ' rev: {}\n' + ' hooks:\n' + ' - id: foo\n' + ) + cfg = tmpdir.join(C.CONFIG_FILE) + before = fmt.format( + up_to_date, git.head_rev(up_to_date), + out_of_date.path, out_of_date.original_rev, + ) + cfg.write(before) + + assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0 + assert cfg.read() == fmt.format( + up_to_date, git.head_rev(up_to_date), + out_of_date.path, out_of_date.head_rev, + ) + + +def test_autoupdate_out_of_date_repo_with_correct_repo_name( + out_of_date, in_tmpdir, +): + stale_config = make_config_from_repo( + out_of_date.path, rev=out_of_date.original_rev, check=False, + ) + local_config = sample_local_config() + config = {'repos': [stale_config, local_config]} + write_config('.', config) + + with open(C.CONFIG_FILE) as f: + before = f.read() + repo_name = f'file://{out_of_date.path}' + ret = autoupdate( + C.CONFIG_FILE, freeze=False, tags_only=False, + repos=(repo_name,), + ) + with open(C.CONFIG_FILE) as f: + after = f.read() + assert ret == 0 + assert before != after + assert out_of_date.head_rev in after + assert 'local' in after + + +def test_autoupdate_out_of_date_repo_with_wrong_repo_name( + out_of_date, in_tmpdir, +): + config = make_config_from_repo( + out_of_date.path, rev=out_of_date.original_rev, check=False, + ) + write_config('.', config) + + with open(C.CONFIG_FILE) as f: + before = f.read() + # It will not update it, because the name doesn't match + ret = autoupdate( + C.CONFIG_FILE, freeze=False, tags_only=False, + repos=('dne',), + ) + with open(C.CONFIG_FILE) as f: + after = f.read() + assert ret == 0 + assert before == after + + +def test_does_not_reformat(tmpdir, out_of_date): + fmt = ( + 'repos:\n' + '- repo: {}\n' + ' rev: {} # definitely the version I want!\n' + ' hooks:\n' + ' - id: foo\n' + ' # These args are because reasons!\n' + ' args: [foo, bar, baz]\n' + ) + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write(fmt.format(out_of_date.path, out_of_date.original_rev)) + + assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0 + expected = fmt.format(out_of_date.path, out_of_date.head_rev) + assert cfg.read() == expected + + +def test_does_not_change_mixed_endlines_read(up_to_date, tmpdir): + fmt = ( + 'repos:\n' + '- repo: {}\n' + ' rev: {} # definitely the version I want!\r\n' + ' hooks:\r\n' + ' - id: foo\n' + ' # These args are because reasons!\r\n' + ' args: [foo, bar, baz]\r\n' + ) + cfg = tmpdir.join(C.CONFIG_FILE) + + expected = fmt.format(up_to_date, git.head_rev(up_to_date)).encode() + cfg.write_binary(expected) + + assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0 + assert cfg.read_binary() == expected + + +def test_does_not_change_mixed_endlines_write(tmpdir, out_of_date): + fmt = ( + 'repos:\n' + '- repo: {}\n' + ' rev: {} # definitely the version I want!\r\n' + ' hooks:\r\n' + ' - id: foo\n' + ' # These args are because reasons!\r\n' + ' args: [foo, bar, baz]\r\n' + ) + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write_binary( + fmt.format(out_of_date.path, out_of_date.original_rev).encode(), + ) + + assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0 + expected = fmt.format(out_of_date.path, out_of_date.head_rev).encode() + assert cfg.read_binary() == expected + + +def test_loses_formatting_when_not_detectable(out_of_date, tmpdir): + """A best-effort attempt is made at updating rev without rewriting + formatting. When the original formatting cannot be detected, this + is abandoned. + """ + config = ( + 'repos: [\n' + ' {{\n' + ' repo: {}, rev: {},\n' + ' hooks: [\n' + ' # A comment!\n' + ' {{id: foo}},\n' + ' ],\n' + ' }}\n' + ']\n'.format( + shlex.quote(out_of_date.path), out_of_date.original_rev, + ) + ) + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write(config) + + assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0 + expected = ( + f'repos:\n' + f'- repo: {out_of_date.path}\n' + f' rev: {out_of_date.head_rev}\n' + f' hooks:\n' + f' - id: foo\n' + ) + assert cfg.read() == expected + + +def test_autoupdate_tagged_repo(tagged, in_tmpdir): + config = make_config_from_repo(tagged.path, rev=tagged.original_rev) + write_config('.', config) + + assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) == 0 + with open(C.CONFIG_FILE) as f: + assert 'v1.2.3' in f.read() + + +def test_autoupdate_freeze(tagged, in_tmpdir): + config = make_config_from_repo(tagged.path, rev=tagged.original_rev) + write_config('.', config) + + assert autoupdate(C.CONFIG_FILE, freeze=True, tags_only=False) == 0 + with open(C.CONFIG_FILE) as f: + expected = f'rev: {tagged.head_rev} # frozen: v1.2.3' + assert expected in f.read() + + # if we un-freeze it should remove the frozen comment + assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) == 0 + with open(C.CONFIG_FILE) as f: + assert 'rev: v1.2.3\n' in f.read() + + +def test_autoupdate_tags_only(tagged, in_tmpdir): + # add some commits after the tag + git_commit(cwd=tagged.path) + + config = make_config_from_repo(tagged.path, rev=tagged.original_rev) + write_config('.', config) + + assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=True) == 0 + with open(C.CONFIG_FILE) as f: + assert 'v1.2.3' in f.read() + + +def test_autoupdate_latest_no_config(out_of_date, in_tmpdir): + config = make_config_from_repo( + out_of_date.path, rev=out_of_date.original_rev, + ) + write_config('.', config) + + cmd_output('git', 'rm', '-r', ':/', cwd=out_of_date.path) + git_commit(cwd=out_of_date.path) + + assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) == 1 + with open(C.CONFIG_FILE) as f: + assert out_of_date.original_rev in f.read() + + +def test_hook_disppearing_repo_raises(hook_disappearing): + config = make_config_from_repo( + hook_disappearing.path, + rev=hook_disappearing.original_rev, + hooks=[{'id': 'foo'}], + ) + info = RevInfo.from_config(config).update(tags_only=False, freeze=False) + with pytest.raises(RepositoryCannotBeUpdatedError): + _check_hooks_still_exist_at_rev(config, info) + + +def test_autoupdate_hook_disappearing_repo(hook_disappearing, tmpdir): + contents = ( + f'repos:\n' + f'- repo: {hook_disappearing.path}\n' + f' rev: {hook_disappearing.original_rev}\n' + f' hooks:\n' + f' - id: foo\n' + ) + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write(contents) + + assert autoupdate(str(cfg), freeze=False, tags_only=False) == 1 + assert cfg.read() == contents + + +def test_autoupdate_local_hooks(in_git_dir): + config = sample_local_config() + add_config_to_repo('.', config) + assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) == 0 + new_config_written = read_config('.') + assert len(new_config_written['repos']) == 1 + assert new_config_written['repos'][0] == config + + +def test_autoupdate_local_hooks_with_out_of_date_repo( + out_of_date, in_tmpdir, +): + stale_config = make_config_from_repo( + out_of_date.path, rev=out_of_date.original_rev, check=False, + ) + local_config = sample_local_config() + config = {'repos': [local_config, stale_config]} + write_config('.', config) + assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) == 0 + new_config_written = read_config('.') + assert len(new_config_written['repos']) == 2 + assert new_config_written['repos'][0] == local_config + + +def test_autoupdate_meta_hooks(tmpdir): + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write( + 'repos:\n' + '- repo: meta\n' + ' hooks:\n' + ' - id: check-useless-excludes\n', + ) + assert autoupdate(str(cfg), freeze=False, tags_only=True) == 0 + assert cfg.read() == ( + 'repos:\n' + '- repo: meta\n' + ' hooks:\n' + ' - id: check-useless-excludes\n' + ) + + +def test_updates_old_format_to_new_format(tmpdir, capsys): + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write( + '- repo: local\n' + ' hooks:\n' + ' - id: foo\n' + ' name: foo\n' + ' entry: ./bin/foo.sh\n' + ' language: script\n', + ) + assert autoupdate(str(cfg), freeze=False, tags_only=True) == 0 + contents = cfg.read() + assert contents == ( + 'repos:\n' + '- repo: local\n' + ' hooks:\n' + ' - id: foo\n' + ' name: foo\n' + ' entry: ./bin/foo.sh\n' + ' language: script\n' + ) + out, _ = capsys.readouterr() + assert out == 'Configuration has been migrated.\n' + + +def test_maintains_rev_quoting_style(tmpdir, out_of_date): + fmt = ( + 'repos:\n' + '- repo: {path}\n' + ' rev: "{rev}"\n' + ' hooks:\n' + ' - id: foo\n' + '- repo: {path}\n' + " rev: '{rev}'\n" + ' hooks:\n' + ' - id: foo\n' + ) + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write(fmt.format(path=out_of_date.path, rev=out_of_date.original_rev)) + + assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0 + expected = fmt.format(path=out_of_date.path, rev=out_of_date.head_rev) + assert cfg.read() == expected diff --git a/tests/commands/clean_test.py b/tests/commands/clean_test.py new file mode 100644 index 0000000..dd8e4a5 --- /dev/null +++ b/tests/commands/clean_test.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import os.path +from unittest import mock + +import pytest + +from pre_commit.commands.clean import clean + + +@pytest.fixture(autouse=True) +def fake_old_dir(tempdir_factory): + fake_old_dir = tempdir_factory.get() + + def _expanduser(path, *args, **kwargs): + assert path == '~/.pre-commit' + return fake_old_dir + + with mock.patch.object(os.path, 'expanduser', side_effect=_expanduser): + yield fake_old_dir + + +def test_clean(store, fake_old_dir): + assert os.path.exists(fake_old_dir) + assert os.path.exists(store.directory) + clean(store) + assert not os.path.exists(fake_old_dir) + assert not os.path.exists(store.directory) + + +def test_clean_idempotent(store): + clean(store) + assert not os.path.exists(store.directory) + clean(store) + assert not os.path.exists(store.directory) diff --git a/tests/commands/gc_test.py b/tests/commands/gc_test.py new file mode 100644 index 0000000..95113ed --- /dev/null +++ b/tests/commands/gc_test.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import os + +import pre_commit.constants as C +from pre_commit import git +from pre_commit.clientlib import load_config +from pre_commit.commands.autoupdate import autoupdate +from pre_commit.commands.gc import gc +from pre_commit.commands.install_uninstall import install_hooks +from pre_commit.repository import all_hooks +from testing.fixtures import make_config_from_repo +from testing.fixtures import make_repo +from testing.fixtures import modify_config +from testing.fixtures import sample_local_config +from testing.fixtures import sample_meta_config +from testing.fixtures import write_config +from testing.util import git_commit + + +def _repo_count(store): + return len(store.select_all_repos()) + + +def _config_count(store): + return len(store.select_all_configs()) + + +def _remove_config_assert_cleared(store, cap_out): + os.remove(C.CONFIG_FILE) + assert not gc(store) + assert _config_count(store) == 0 + assert _repo_count(store) == 0 + assert cap_out.get().splitlines()[-1] == '1 repo(s) removed.' + + +def test_gc(tempdir_factory, store, in_git_dir, cap_out): + path = make_repo(tempdir_factory, 'script_hooks_repo') + old_rev = git.head_rev(path) + git_commit(cwd=path) + + write_config('.', make_config_from_repo(path, rev=old_rev)) + store.mark_config_used(C.CONFIG_FILE) + + # update will clone both the old and new repo, making the old one gc-able + assert not install_hooks(C.CONFIG_FILE, store) + assert not autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) + assert not install_hooks(C.CONFIG_FILE, store) + + assert _config_count(store) == 1 + assert _repo_count(store) == 2 + assert not gc(store) + assert _config_count(store) == 1 + assert _repo_count(store) == 1 + assert cap_out.get().splitlines()[-1] == '1 repo(s) removed.' + + _remove_config_assert_cleared(store, cap_out) + + +def test_gc_repo_not_cloned(tempdir_factory, store, in_git_dir, cap_out): + path = make_repo(tempdir_factory, 'script_hooks_repo') + write_config('.', make_config_from_repo(path)) + store.mark_config_used(C.CONFIG_FILE) + + assert _config_count(store) == 1 + assert _repo_count(store) == 0 + assert not gc(store) + assert _config_count(store) == 1 + assert _repo_count(store) == 0 + assert cap_out.get().splitlines()[-1] == '0 repo(s) removed.' + + +def test_gc_meta_repo_does_not_crash(store, in_git_dir, cap_out): + write_config('.', sample_meta_config()) + store.mark_config_used(C.CONFIG_FILE) + assert not gc(store) + assert cap_out.get().splitlines()[-1] == '0 repo(s) removed.' + + +def test_gc_local_repo_does_not_crash(store, in_git_dir, cap_out): + write_config('.', sample_local_config()) + store.mark_config_used(C.CONFIG_FILE) + assert not gc(store) + assert cap_out.get().splitlines()[-1] == '0 repo(s) removed.' + + +def test_gc_unused_local_repo_with_env(store, in_git_dir, cap_out): + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'flake8', 'name': 'flake8', 'entry': 'flake8', + # a `language: python` local hook will create an environment + 'types': ['python'], 'language': 'python', + }], + } + write_config('.', config) + store.mark_config_used(C.CONFIG_FILE) + + # this causes the repositories to be created + all_hooks(load_config(C.CONFIG_FILE), store) + + assert _config_count(store) == 1 + assert _repo_count(store) == 1 + assert not gc(store) + assert _config_count(store) == 1 + assert _repo_count(store) == 1 + assert cap_out.get().splitlines()[-1] == '0 repo(s) removed.' + + _remove_config_assert_cleared(store, cap_out) + + +def test_gc_config_with_missing_hook( + tempdir_factory, store, in_git_dir, cap_out, +): + path = make_repo(tempdir_factory, 'script_hooks_repo') + write_config('.', make_config_from_repo(path)) + store.mark_config_used(C.CONFIG_FILE) + # to trigger a clone + all_hooks(load_config(C.CONFIG_FILE), store) + + with modify_config() as config: + # add a hook which does not exist, make sure we don't crash + config['repos'][0]['hooks'].append({'id': 'does-not-exist'}) + + assert _config_count(store) == 1 + assert _repo_count(store) == 1 + assert not gc(store) + assert _config_count(store) == 1 + assert _repo_count(store) == 1 + assert cap_out.get().splitlines()[-1] == '0 repo(s) removed.' + + _remove_config_assert_cleared(store, cap_out) + + +def test_gc_deletes_invalid_configs(store, in_git_dir, cap_out): + config = {'i am': 'invalid'} + write_config('.', config) + store.mark_config_used(C.CONFIG_FILE) + + assert _config_count(store) == 1 + assert not gc(store) + assert _config_count(store) == 0 + assert cap_out.get().splitlines()[-1] == '0 repo(s) removed.' + + +def test_invalid_manifest_gcd(tempdir_factory, store, in_git_dir, cap_out): + # clean up repos from old pre-commit versions + path = make_repo(tempdir_factory, 'script_hooks_repo') + write_config('.', make_config_from_repo(path)) + store.mark_config_used(C.CONFIG_FILE) + + # trigger a clone + install_hooks(C.CONFIG_FILE, store) + + # we'll "break" the manifest to simulate an old version clone + (_, _, path), = store.select_all_repos() + os.remove(os.path.join(path, C.MANIFEST_FILE)) + + assert _config_count(store) == 1 + assert _repo_count(store) == 1 + assert not gc(store) + assert _config_count(store) == 1 + assert _repo_count(store) == 0 + assert cap_out.get().splitlines()[-1] == '1 repo(s) removed.' diff --git a/tests/commands/hook_impl_test.py b/tests/commands/hook_impl_test.py new file mode 100644 index 0000000..d757e85 --- /dev/null +++ b/tests/commands/hook_impl_test.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +import subprocess +import sys +from unittest import mock + +import pytest + +import pre_commit.constants as C +from pre_commit import git +from pre_commit.commands import hook_impl +from pre_commit.envcontext import envcontext +from pre_commit.util import cmd_output +from pre_commit.util import make_executable +from testing.fixtures import git_dir +from testing.fixtures import sample_local_config +from testing.fixtures import write_config +from testing.util import cwd +from testing.util import git_commit + + +def test_validate_config_file_exists(tmpdir): + cfg = tmpdir.join(C.CONFIG_FILE).ensure() + hook_impl._validate_config(0, cfg, True) + + +def test_validate_config_missing(capsys): + with pytest.raises(SystemExit) as excinfo: + hook_impl._validate_config(123, 'DNE.yaml', False) + ret, = excinfo.value.args + assert ret == 1 + assert capsys.readouterr().out == ( + 'No DNE.yaml 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`\n' + ) + + +def test_validate_config_skip_missing_config(capsys): + with pytest.raises(SystemExit) as excinfo: + hook_impl._validate_config(123, 'DNE.yaml', True) + ret, = excinfo.value.args + assert ret == 123 + expected = '`DNE.yaml` config file not found. Skipping `pre-commit`.\n' + assert capsys.readouterr().out == expected + + +def test_validate_config_skip_via_env_variable(capsys): + with pytest.raises(SystemExit) as excinfo: + with envcontext((('PRE_COMMIT_ALLOW_NO_CONFIG', '1'),)): + hook_impl._validate_config(0, 'DNE.yaml', False) + ret, = excinfo.value.args + assert ret == 0 + expected = '`DNE.yaml` config file not found. Skipping `pre-commit`.\n' + assert capsys.readouterr().out == expected + + +def test_run_legacy_does_not_exist(tmpdir): + retv, stdin = hook_impl._run_legacy('pre-commit', tmpdir, ()) + assert (retv, stdin) == (0, b'') + + +def test_run_legacy_executes_legacy_script(tmpdir, capfd): + hook = tmpdir.join('pre-commit.legacy') + hook.write('#!/usr/bin/env bash\necho hi "$@"\nexit 1\n') + make_executable(hook) + retv, stdin = hook_impl._run_legacy('pre-commit', tmpdir, ('arg1', 'arg2')) + assert capfd.readouterr().out.strip() == 'hi arg1 arg2' + assert (retv, stdin) == (1, b'') + + +def test_run_legacy_pre_push_returns_stdin(tmpdir): + with mock.patch.object(sys.stdin.buffer, 'read', return_value=b'stdin'): + retv, stdin = hook_impl._run_legacy('pre-push', tmpdir, ()) + assert (retv, stdin) == (0, b'stdin') + + +def test_run_legacy_recursive(tmpdir): + hook = tmpdir.join('pre-commit.legacy').ensure() + make_executable(hook) + + # simulate a call being recursive + def call(*_, **__): + return hook_impl._run_legacy('pre-commit', tmpdir, ()) + + with mock.patch.object(subprocess, 'run', call): + with pytest.raises(SystemExit): + call() + + +@pytest.mark.parametrize( + ('hook_type', 'args'), + ( + ('pre-commit', []), + ('pre-merge-commit', []), + ('pre-push', ['branch_name', 'remote_name']), + ('commit-msg', ['.git/COMMIT_EDITMSG']), + ('post-commit', []), + ('post-merge', ['1']), + ('pre-rebase', ['main', 'topic']), + ('pre-rebase', ['main']), + ('post-checkout', ['old_head', 'new_head', '1']), + ('post-rewrite', ['amend']), + # multiple choices for commit-editmsg + ('prepare-commit-msg', ['.git/COMMIT_EDITMSG']), + ('prepare-commit-msg', ['.git/COMMIT_EDITMSG', 'message']), + ('prepare-commit-msg', ['.git/COMMIT_EDITMSG', 'commit', 'deadbeef']), + ), +) +def test_check_args_length_ok(hook_type, args): + hook_impl._check_args_length(hook_type, args) + + +def test_check_args_length_error_too_many_plural(): + with pytest.raises(SystemExit) as excinfo: + hook_impl._check_args_length('pre-commit', ['run', '--all-files']) + msg, = excinfo.value.args + assert msg == ( + 'hook-impl for pre-commit expected 0 arguments but got 2: ' + "['run', '--all-files']" + ) + + +def test_check_args_length_error_too_many_singular(): + with pytest.raises(SystemExit) as excinfo: + hook_impl._check_args_length('commit-msg', []) + msg, = excinfo.value.args + assert msg == 'hook-impl for commit-msg expected 1 argument but got 0: []' + + +def test_check_args_length_prepare_commit_msg_error(): + with pytest.raises(SystemExit) as excinfo: + hook_impl._check_args_length('prepare-commit-msg', []) + msg, = excinfo.value.args + assert msg == ( + 'hook-impl for prepare-commit-msg expected 1, 2, or 3 arguments ' + 'but got 0: []' + ) + + +def test_check_args_length_pre_rebase_error(): + with pytest.raises(SystemExit) as excinfo: + hook_impl._check_args_length('pre-rebase', []) + msg, = excinfo.value.args + assert msg == 'hook-impl for pre-rebase expected 1 or 2 arguments but got 0: []' # noqa: E501 + + +def test_run_ns_pre_commit(): + ns = hook_impl._run_ns('pre-commit', True, (), b'') + assert ns is not None + assert ns.hook_stage == 'pre-commit' + assert ns.color is True + + +def test_run_ns_pre_rebase(): + ns = hook_impl._run_ns('pre-rebase', True, ('main', 'topic'), b'') + assert ns is not None + assert ns.hook_stage == 'pre-rebase' + assert ns.color is True + assert ns.pre_rebase_upstream == 'main' + assert ns.pre_rebase_branch == 'topic' + + ns = hook_impl._run_ns('pre-rebase', True, ('main',), b'') + assert ns is not None + assert ns.hook_stage == 'pre-rebase' + assert ns.color is True + assert ns.pre_rebase_upstream == 'main' + assert ns.pre_rebase_branch is None + + +def test_run_ns_commit_msg(): + ns = hook_impl._run_ns('commit-msg', False, ('.git/COMMIT_MSG',), b'') + assert ns is not None + assert ns.hook_stage == 'commit-msg' + assert ns.color is False + assert ns.commit_msg_filename == '.git/COMMIT_MSG' + + +def test_run_ns_prepare_commit_msg_one_arg(): + ns = hook_impl._run_ns( + 'prepare-commit-msg', False, + ('.git/COMMIT_MSG',), b'', + ) + assert ns is not None + assert ns.hook_stage == 'prepare-commit-msg' + assert ns.color is False + assert ns.commit_msg_filename == '.git/COMMIT_MSG' + + +def test_run_ns_prepare_commit_msg_two_arg(): + ns = hook_impl._run_ns( + 'prepare-commit-msg', False, + ('.git/COMMIT_MSG', 'message'), b'', + ) + assert ns is not None + assert ns.hook_stage == 'prepare-commit-msg' + assert ns.color is False + assert ns.commit_msg_filename == '.git/COMMIT_MSG' + assert ns.prepare_commit_message_source == 'message' + + +def test_run_ns_prepare_commit_msg_three_arg(): + ns = hook_impl._run_ns( + 'prepare-commit-msg', False, + ('.git/COMMIT_MSG', 'message', 'HEAD'), b'', + ) + assert ns is not None + assert ns.hook_stage == 'prepare-commit-msg' + assert ns.color is False + assert ns.commit_msg_filename == '.git/COMMIT_MSG' + assert ns.prepare_commit_message_source == 'message' + assert ns.commit_object_name == 'HEAD' + + +def test_run_ns_post_commit(): + ns = hook_impl._run_ns('post-commit', True, (), b'') + assert ns is not None + assert ns.hook_stage == 'post-commit' + assert ns.color is True + + +def test_run_ns_post_merge(): + ns = hook_impl._run_ns('post-merge', True, ('1',), b'') + assert ns is not None + assert ns.hook_stage == 'post-merge' + assert ns.color is True + assert ns.is_squash_merge == '1' + + +def test_run_ns_post_rewrite(): + ns = hook_impl._run_ns('post-rewrite', True, ('amend',), b'') + assert ns is not None + assert ns.hook_stage == 'post-rewrite' + assert ns.color is True + assert ns.rewrite_command == 'amend' + + +def test_run_ns_post_checkout(): + ns = hook_impl._run_ns('post-checkout', True, ('a', 'b', 'c'), b'') + assert ns is not None + assert ns.hook_stage == 'post-checkout' + assert ns.color is True + assert ns.from_ref == 'a' + assert ns.to_ref == 'b' + assert ns.checkout_type == 'c' + + +@pytest.fixture +def push_example(tempdir_factory): + src = git_dir(tempdir_factory) + git_commit(cwd=src) + src_head = git.head_rev(src) + + clone = tempdir_factory.get() + cmd_output('git', 'clone', src, clone) + git_commit(cwd=clone) + clone_head = git.head_rev(clone) + return (src, src_head, clone, clone_head) + + +def test_run_ns_pre_push_updating_branch(push_example): + src, src_head, clone, clone_head = push_example + + with cwd(clone): + args = ('origin', src) + stdin = f'HEAD {clone_head} refs/heads/b {src_head}\n'.encode() + ns = hook_impl._run_ns('pre-push', False, args, stdin) + + assert ns is not None + assert ns.hook_stage == 'pre-push' + assert ns.color is False + assert ns.remote_name == 'origin' + assert ns.remote_url == src + assert ns.from_ref == src_head + assert ns.to_ref == clone_head + assert ns.all_files is False + + +def test_run_ns_pre_push_new_branch(push_example): + src, src_head, clone, clone_head = push_example + + with cwd(clone): + args = ('origin', src) + stdin = f'HEAD {clone_head} refs/heads/b {hook_impl.Z40}\n'.encode() + ns = hook_impl._run_ns('pre-push', False, args, stdin) + + assert ns is not None + assert ns.from_ref == src_head + assert ns.to_ref == clone_head + + +def test_run_ns_pre_push_new_branch_existing_rev(push_example): + src, src_head, clone, _ = push_example + + with cwd(clone): + args = ('origin', src) + stdin = f'HEAD {src_head} refs/heads/b2 {hook_impl.Z40}\n'.encode() + ns = hook_impl._run_ns('pre-push', False, args, stdin) + + assert ns is None + + +def test_run_ns_pre_push_ref_with_whitespace(push_example): + src, src_head, clone, _ = push_example + + with cwd(clone): + args = ('origin', src) + line = f'HEAD^{{/ }} {src_head} refs/heads/b2 {hook_impl.Z40}\n' + stdin = line.encode() + ns = hook_impl._run_ns('pre-push', False, args, stdin) + + assert ns is None + + +def test_pushing_orphan_branch(push_example): + src, src_head, clone, _ = push_example + + cmd_output('git', 'checkout', '--orphan', 'b2', cwd=clone) + git_commit(cwd=clone, msg='something else to get unique hash') + clone_rev = git.head_rev(clone) + + with cwd(clone): + args = ('origin', src) + stdin = f'HEAD {clone_rev} refs/heads/b2 {hook_impl.Z40}\n'.encode() + ns = hook_impl._run_ns('pre-push', False, args, stdin) + + assert ns is not None + assert ns.all_files is True + + +def test_run_ns_pre_push_deleting_branch(push_example): + src, src_head, clone, _ = push_example + + with cwd(clone): + args = ('origin', src) + stdin = f'(delete) {hook_impl.Z40} refs/heads/b {src_head}'.encode() + ns = hook_impl._run_ns('pre-push', False, args, stdin) + + assert ns is None + + +def test_hook_impl_main_noop_pre_push(cap_out, store, push_example): + src, src_head, clone, _ = push_example + + stdin = f'(delete) {hook_impl.Z40} refs/heads/b {src_head}'.encode() + with mock.patch.object(sys.stdin.buffer, 'read', return_value=stdin): + with cwd(clone): + write_config('.', sample_local_config()) + ret = hook_impl.hook_impl( + store, + config=C.CONFIG_FILE, + color=False, + hook_type='pre-push', + hook_dir='.git/hooks', + skip_on_missing_config=False, + args=('origin', src), + ) + assert ret == 0 + assert cap_out.get() == '' + + +def test_hook_impl_main_runs_hooks(cap_out, tempdir_factory, store): + with cwd(git_dir(tempdir_factory)): + write_config('.', sample_local_config()) + ret = hook_impl.hook_impl( + store, + config=C.CONFIG_FILE, + color=False, + hook_type='pre-commit', + hook_dir='.git/hooks', + skip_on_missing_config=False, + args=(), + ) + assert ret == 0 + expected = '''\ +Block if "DO NOT COMMIT" is found....................(no files to check)Skipped +''' + assert cap_out.get() == expected diff --git a/tests/commands/init_templatedir_test.py b/tests/commands/init_templatedir_test.py new file mode 100644 index 0000000..28f29b7 --- /dev/null +++ b/tests/commands/init_templatedir_test.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import os.path +from unittest import mock + +import pytest + +import pre_commit.constants as C +from pre_commit.commands.init_templatedir import init_templatedir +from pre_commit.envcontext import envcontext +from pre_commit.util import cmd_output +from testing.fixtures import git_dir +from testing.fixtures import make_consuming_repo +from testing.util import cmd_output_mocked_pre_commit_home +from testing.util import cwd +from testing.util import git_commit + + +def test_init_templatedir(tmpdir, tempdir_factory, store, cap_out): + target = str(tmpdir.join('tmpl')) + init_templatedir(C.CONFIG_FILE, store, target, hook_types=['pre-commit']) + lines = cap_out.get().splitlines() + assert lines[0].startswith('pre-commit installed at ') + assert lines[1] == ( + '[WARNING] `init.templateDir` not set to the target directory' + ) + assert lines[2].startswith( + '[WARNING] maybe `git config --global init.templateDir', + ) + + with envcontext((('GIT_TEMPLATE_DIR', target),)): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + + with cwd(path): + retcode, output = git_commit( + fn=cmd_output_mocked_pre_commit_home, + tempdir_factory=tempdir_factory, + ) + assert retcode == 0 + assert 'Bash hook....' in output + + +def test_init_templatedir_already_set(tmpdir, tempdir_factory, store, cap_out): + target = str(tmpdir.join('tmpl')) + tmp_git_dir = git_dir(tempdir_factory) + with cwd(tmp_git_dir): + cmd_output('git', 'config', 'init.templateDir', target) + init_templatedir( + C.CONFIG_FILE, store, target, hook_types=['pre-commit'], + ) + + lines = cap_out.get().splitlines() + assert len(lines) == 1 + assert lines[0].startswith('pre-commit installed at') + + +def test_init_templatedir_not_set(tmpdir, store, cap_out): + # set HOME to ignore the current `.gitconfig` + with envcontext((('HOME', str(tmpdir)),)): + with tmpdir.join('tmpl').ensure_dir().as_cwd(): + # we have not set init.templateDir so this should produce a warning + init_templatedir( + C.CONFIG_FILE, store, '.', hook_types=['pre-commit'], + ) + + lines = cap_out.get().splitlines() + assert len(lines) == 3 + assert lines[1] == ( + '[WARNING] `init.templateDir` not set to the target directory' + ) + + +def test_init_templatedir_expanduser(tmpdir, tempdir_factory, store, cap_out): + target = str(tmpdir.join('tmpl')) + tmp_git_dir = git_dir(tempdir_factory) + with cwd(tmp_git_dir): + cmd_output('git', 'config', 'init.templateDir', '~/templatedir') + with mock.patch.object(os.path, 'expanduser', return_value=target): + init_templatedir( + C.CONFIG_FILE, store, target, hook_types=['pre-commit'], + ) + + lines = cap_out.get().splitlines() + assert len(lines) == 1 + assert lines[0].startswith('pre-commit installed at') + + +def test_init_templatedir_hookspath_set(tmpdir, tempdir_factory, store): + target = tmpdir.join('tmpl') + tmp_git_dir = git_dir(tempdir_factory) + with cwd(tmp_git_dir): + cmd_output('git', 'config', '--local', 'core.hooksPath', 'hooks') + init_templatedir( + C.CONFIG_FILE, store, target, hook_types=['pre-commit'], + ) + assert target.join('hooks/pre-commit').exists() + + +@pytest.mark.parametrize( + ('skip', 'commit_retcode', 'commit_output_snippet'), + ( + (True, 0, 'Skipping `pre-commit`.'), + (False, 1, f'No {C.CONFIG_FILE} file was found'), + ), +) +def test_init_templatedir_skip_on_missing_config( + tmpdir, + tempdir_factory, + store, + cap_out, + skip, + commit_retcode, + commit_output_snippet, +): + target = str(tmpdir.join('tmpl')) + init_git_dir = git_dir(tempdir_factory) + with cwd(init_git_dir): + cmd_output('git', 'config', 'init.templateDir', target) + init_templatedir( + C.CONFIG_FILE, + store, + target, + hook_types=['pre-commit'], + skip_on_missing_config=skip, + ) + + lines = cap_out.get().splitlines() + assert len(lines) == 1 + assert lines[0].startswith('pre-commit installed at') + + with envcontext((('GIT_TEMPLATE_DIR', target),)): + verify_git_dir = git_dir(tempdir_factory) + + with cwd(verify_git_dir): + retcode, output = git_commit( + fn=cmd_output_mocked_pre_commit_home, + tempdir_factory=tempdir_factory, + check=False, + ) + + assert retcode == commit_retcode + assert commit_output_snippet in output diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py new file mode 100644 index 0000000..9eb0e74 --- /dev/null +++ b/tests/commands/install_uninstall_test.py @@ -0,0 +1,1104 @@ +from __future__ import annotations + +import os.path +import re + +import re_assert + +import pre_commit.constants as C +from pre_commit import git +from pre_commit.commands.install_uninstall import _hook_types +from pre_commit.commands.install_uninstall import CURRENT_HASH +from pre_commit.commands.install_uninstall import install +from pre_commit.commands.install_uninstall import install_hooks +from pre_commit.commands.install_uninstall import is_our_script +from pre_commit.commands.install_uninstall import PRIOR_HASHES +from pre_commit.commands.install_uninstall import uninstall +from pre_commit.parse_shebang import find_executable +from pre_commit.util import cmd_output +from pre_commit.util import make_executable +from pre_commit.util import resource_text +from testing.fixtures import add_config_to_repo +from testing.fixtures import git_dir +from testing.fixtures import make_consuming_repo +from testing.fixtures import remove_config_from_repo +from testing.fixtures import write_config +from testing.util import cmd_output_mocked_pre_commit_home +from testing.util import cwd +from testing.util import git_commit + + +def test_hook_types_explicitly_listed(): + assert _hook_types(os.devnull, ['pre-push']) == ['pre-push'] + + +def test_hook_types_default_value_when_not_specified(): + assert _hook_types(os.devnull, None) == ['pre-commit'] + + +def test_hook_types_configured(tmpdir): + cfg = tmpdir.join('t.cfg') + cfg.write('default_install_hook_types: [pre-push]\nrepos: []\n') + + assert _hook_types(str(cfg), None) == ['pre-push'] + + +def test_hook_types_configured_nonsense(tmpdir): + cfg = tmpdir.join('t.cfg') + cfg.write('default_install_hook_types: []\nrepos: []\n') + + # hopefully the user doesn't do this, but the code allows it! + assert _hook_types(str(cfg), None) == [] + + +def test_hook_types_configuration_has_error(tmpdir): + cfg = tmpdir.join('t.cfg') + cfg.write('[') + + assert _hook_types(str(cfg), None) == ['pre-commit'] + + +def test_is_not_script(): + assert is_our_script('setup.py') is False + + +def test_is_script(): + assert is_our_script('pre_commit/resources/hook-tmpl') + + +def test_is_previous_pre_commit(tmpdir): + f = tmpdir.join('foo') + f.write(f'{PRIOR_HASHES[0].decode()}\n') + assert is_our_script(f.strpath) + + +def test_install_pre_commit(in_git_dir, store): + assert not install(C.CONFIG_FILE, store, hook_types=['pre-commit']) + assert os.access(in_git_dir.join('.git/hooks/pre-commit').strpath, os.X_OK) + + assert not install(C.CONFIG_FILE, store, hook_types=['pre-push']) + assert os.access(in_git_dir.join('.git/hooks/pre-push').strpath, os.X_OK) + + +def test_install_hooks_directory_not_present(in_git_dir, store): + # Simulate some git clients which don't make .git/hooks #234 + if in_git_dir.join('.git/hooks').exists(): # pragma: no cover (odd git) + in_git_dir.join('.git/hooks').remove() + install(C.CONFIG_FILE, store, hook_types=['pre-commit']) + assert in_git_dir.join('.git/hooks/pre-commit').exists() + + +def test_install_multiple_hooks_at_once(in_git_dir, store): + install(C.CONFIG_FILE, store, hook_types=['pre-commit', 'pre-push']) + assert in_git_dir.join('.git/hooks/pre-commit').exists() + assert in_git_dir.join('.git/hooks/pre-push').exists() + uninstall(C.CONFIG_FILE, hook_types=['pre-commit', 'pre-push']) + assert not in_git_dir.join('.git/hooks/pre-commit').exists() + assert not in_git_dir.join('.git/hooks/pre-push').exists() + + +def test_install_refuses_core_hookspath(in_git_dir, store): + cmd_output('git', 'config', '--local', 'core.hooksPath', 'hooks') + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) + + +def test_install_hooks_dead_symlink(in_git_dir, store): + hook = in_git_dir.join('.git/hooks').ensure_dir().join('pre-commit') + os.symlink('/fake/baz', hook.strpath) + install(C.CONFIG_FILE, store, hook_types=['pre-commit']) + assert hook.exists() + + +def test_uninstall_does_not_blow_up_when_not_there(in_git_dir): + assert uninstall(C.CONFIG_FILE, hook_types=['pre-commit']) == 0 + + +def test_uninstall(in_git_dir, store): + assert not in_git_dir.join('.git/hooks/pre-commit').exists() + install(C.CONFIG_FILE, store, hook_types=['pre-commit']) + assert in_git_dir.join('.git/hooks/pre-commit').exists() + uninstall(C.CONFIG_FILE, hook_types=['pre-commit']) + assert not in_git_dir.join('.git/hooks/pre-commit').exists() + + +def _get_commit_output(tempdir_factory, touch_file='foo', **kwargs): + open(touch_file, 'a').close() + cmd_output('git', 'add', touch_file) + return git_commit( + fn=cmd_output_mocked_pre_commit_home, + check=False, + tempdir_factory=tempdir_factory, + **kwargs, + ) + + +# osx does this different :( +FILES_CHANGED = ( + r'(' + r' 1 file changed, 0 insertions\(\+\), 0 deletions\(-\)\n' + r'|' + r' 0 files changed\n' + r')' +) + + +NORMAL_PRE_COMMIT_RUN = re_assert.Matches( + fr'^\[INFO\] Initializing environment for .+\.\n' + fr'Bash hook\.+Passed\n' + fr'\[master [a-f0-9]{{7}}\] commit!\n' + fr'{FILES_CHANGED}' + fr' create mode 100644 foo\n$', +) + + +def test_install_pre_commit_and_run(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + + ret, output = _get_commit_output(tempdir_factory) + assert ret == 0 + NORMAL_PRE_COMMIT_RUN.assert_matches(output) + + +def test_install_pre_commit_and_run_custom_path(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + cmd_output('git', 'mv', C.CONFIG_FILE, 'custom.yaml') + git_commit(cwd=path) + assert install('custom.yaml', store, hook_types=['pre-commit']) == 0 + + ret, output = _get_commit_output(tempdir_factory) + assert ret == 0 + NORMAL_PRE_COMMIT_RUN.assert_matches(output) + + +def test_install_in_submodule_and_run(tempdir_factory, store): + src_path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + parent_path = git_dir(tempdir_factory) + cmd_output('git', 'submodule', 'add', src_path, 'sub', cwd=parent_path) + git_commit(cwd=parent_path) + + sub_pth = os.path.join(parent_path, 'sub') + with cwd(sub_pth): + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + ret, output = _get_commit_output(tempdir_factory) + assert ret == 0 + NORMAL_PRE_COMMIT_RUN.assert_matches(output) + + +def test_install_in_worktree_and_run(tempdir_factory, store): + src_path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + path = tempdir_factory.get() + cmd_output('git', '-C', src_path, 'branch', '-m', 'notmaster') + cmd_output('git', '-C', src_path, 'worktree', 'add', path, '-b', 'master') + + with cwd(path): + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + ret, output = _get_commit_output(tempdir_factory) + assert ret == 0 + NORMAL_PRE_COMMIT_RUN.assert_matches(output) + + +def test_commit_am(tempdir_factory, store): + """Regression test for #322.""" + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + # Make an unstaged change + open('unstaged', 'w').close() + cmd_output('git', 'add', '.') + git_commit(cwd=path) + with open('unstaged', 'w') as foo_file: + foo_file.write('Oh hai') + + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + + ret, output = _get_commit_output(tempdir_factory) + assert ret == 0 + + +def test_unicode_merge_commit_message(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + cmd_output('git', 'checkout', 'master', '-b', 'foo') + git_commit('-n', cwd=path) + cmd_output('git', 'checkout', 'master') + cmd_output('git', 'merge', 'foo', '--no-ff', '--no-commit', '-m', '☃') + # Used to crash + git_commit( + '--no-edit', + msg=None, + fn=cmd_output_mocked_pre_commit_home, + tempdir_factory=tempdir_factory, + ) + + +def test_install_idempotent(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + + ret, output = _get_commit_output(tempdir_factory) + assert ret == 0 + NORMAL_PRE_COMMIT_RUN.assert_matches(output) + + +def _path_without_us(): + # Choose a path which *probably* doesn't include us + env = dict(os.environ) + exe = find_executable('pre-commit', env=env) + while exe: + parts = env['PATH'].split(os.pathsep) + after = [ + x for x in parts + if x.lower().rstrip(os.sep) != os.path.dirname(exe).lower() + ] + if parts == after: + raise AssertionError(exe, parts) + env['PATH'] = os.pathsep.join(after) + exe = find_executable('pre-commit', env=env) + return env['PATH'] + + +def test_environment_not_sourced(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + assert not install(C.CONFIG_FILE, store, hook_types=['pre-commit']) + # simulate deleting the virtualenv by rewriting the exe + hook = os.path.join(path, '.git/hooks/pre-commit') + with open(hook) as f: + src = f.read() + src = re.sub('\nINSTALL_PYTHON=.*\n', '\nINSTALL_PYTHON="/dne"\n', src) + with open(hook, 'w') as f: + f.write(src) + + # Use a specific homedir to ignore --user installs + homedir = tempdir_factory.get() + env = { + 'HOME': homedir, + 'PATH': _path_without_us(), + # Git needs this to make a commit + 'GIT_AUTHOR_NAME': os.environ['GIT_AUTHOR_NAME'], + 'GIT_COMMITTER_NAME': os.environ['GIT_COMMITTER_NAME'], + 'GIT_AUTHOR_EMAIL': os.environ['GIT_AUTHOR_EMAIL'], + 'GIT_COMMITTER_EMAIL': os.environ['GIT_COMMITTER_EMAIL'], + } + if os.name == 'nt' and 'PATHEXT' in os.environ: # pragma: no cover + env['PATHEXT'] = os.environ['PATHEXT'] + + ret, out = git_commit(env=env, check=False) + assert ret == 1 + assert out == ( + '`pre-commit` not found. ' + 'Did you forget to activate your virtualenv?\n' + ) + + +FAILING_PRE_COMMIT_RUN = re_assert.Matches( + r'^\[INFO\] Initializing environment for .+\.\n' + r'Failing hook\.+Failed\n' + r'- hook id: failing_hook\n' + r'- exit code: 1\n' + r'\n' + r'Fail\n' + r'foo\n' + r'\n$', +) + + +def test_failing_hooks_returns_nonzero(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'failing_hook_repo') + with cwd(path): + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + + ret, output = _get_commit_output(tempdir_factory) + assert ret == 1 + FAILING_PRE_COMMIT_RUN.assert_matches(output) + + +EXISTING_COMMIT_RUN = re_assert.Matches( + fr'^legacy hook\n' + fr'\[master [a-f0-9]{{7}}\] commit!\n' + fr'{FILES_CHANGED}' + fr' create mode 100644 baz\n$', +) + + +def _write_legacy_hook(path): + os.makedirs(os.path.join(path, '.git/hooks'), exist_ok=True) + with open(os.path.join(path, '.git/hooks/pre-commit'), 'w') as f: + f.write('#!/usr/bin/env bash\necho legacy hook\n') + make_executable(f.name) + + +def test_install_existing_hooks_no_overwrite(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + _write_legacy_hook(path) + + # Make sure we installed the "old" hook correctly + ret, output = _get_commit_output(tempdir_factory, touch_file='baz') + assert ret == 0 + EXISTING_COMMIT_RUN.assert_matches(output) + + # Now install pre-commit (no-overwrite) + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + + # We should run both the legacy and pre-commit hooks + ret, output = _get_commit_output(tempdir_factory) + assert ret == 0 + legacy = 'legacy hook\n' + assert output.startswith(legacy) + NORMAL_PRE_COMMIT_RUN.assert_matches(output.removeprefix(legacy)) + + +def test_legacy_overwriting_legacy_hook(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + _write_legacy_hook(path) + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + _write_legacy_hook(path) + # this previously crashed on windows. See #1010 + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + + +def test_install_existing_hook_no_overwrite_idempotent(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + _write_legacy_hook(path) + + # Install twice + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + + # We should run both the legacy and pre-commit hooks + ret, output = _get_commit_output(tempdir_factory) + assert ret == 0 + legacy = 'legacy hook\n' + assert output.startswith(legacy) + NORMAL_PRE_COMMIT_RUN.assert_matches(output.removeprefix(legacy)) + + +def test_install_with_existing_non_utf8_script(tmpdir, store): + cmd_output('git', 'init', str(tmpdir)) + tmpdir.join('.git/hooks').ensure_dir() + tmpdir.join('.git/hooks/pre-commit').write_binary( + b'#!/usr/bin/env bash\n' + b'# garbage: \xa0\xef\x12\xf2\n' + b'echo legacy hook\n', + ) + + with tmpdir.as_cwd(): + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + + +FAIL_OLD_HOOK = re_assert.Matches( + r'fail!\n' + r'\[INFO\] Initializing environment for .+\.\n' + r'Bash hook\.+Passed\n', +) + + +def test_failing_existing_hook_returns_1(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + # Write out a failing "old" hook + os.makedirs(os.path.join(path, '.git/hooks'), exist_ok=True) + with open(os.path.join(path, '.git/hooks/pre-commit'), 'w') as f: + f.write('#!/usr/bin/env bash\necho "fail!"\nexit 1\n') + make_executable(f.name) + + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + + # We should get a failure from the legacy hook + ret, output = _get_commit_output(tempdir_factory) + assert ret == 1 + FAIL_OLD_HOOK.assert_matches(output) + + +def test_install_overwrite_no_existing_hooks(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + assert not install( + C.CONFIG_FILE, store, hook_types=['pre-commit'], overwrite=True, + ) + + ret, output = _get_commit_output(tempdir_factory) + assert ret == 0 + NORMAL_PRE_COMMIT_RUN.assert_matches(output) + + +def test_install_overwrite(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + _write_legacy_hook(path) + assert not install( + C.CONFIG_FILE, store, hook_types=['pre-commit'], overwrite=True, + ) + + ret, output = _get_commit_output(tempdir_factory) + assert ret == 0 + NORMAL_PRE_COMMIT_RUN.assert_matches(output) + + +def test_uninstall_restores_legacy_hooks(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + _write_legacy_hook(path) + + # Now install and uninstall pre-commit + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + assert uninstall(C.CONFIG_FILE, hook_types=['pre-commit']) == 0 + + # Make sure we installed the "old" hook correctly + ret, output = _get_commit_output(tempdir_factory, touch_file='baz') + assert ret == 0 + EXISTING_COMMIT_RUN.assert_matches(output) + + +def test_replace_old_commit_script(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + # Install a script that looks like our old script + pre_commit_contents = resource_text('hook-tmpl') + new_contents = pre_commit_contents.replace( + CURRENT_HASH.decode(), PRIOR_HASHES[-1].decode(), + ) + + os.makedirs(os.path.join(path, '.git/hooks'), exist_ok=True) + with open(os.path.join(path, '.git/hooks/pre-commit'), 'w') as f: + f.write(new_contents) + make_executable(f.name) + + # Install normally + assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0 + + ret, output = _get_commit_output(tempdir_factory) + assert ret == 0 + NORMAL_PRE_COMMIT_RUN.assert_matches(output) + + +def test_uninstall_doesnt_remove_not_our_hooks(in_git_dir): + pre_commit = in_git_dir.join('.git/hooks').ensure_dir().join('pre-commit') + pre_commit.write('#!/usr/bin/env bash\necho 1\n') + make_executable(pre_commit.strpath) + + assert uninstall(C.CONFIG_FILE, hook_types=['pre-commit']) == 0 + + assert pre_commit.exists() + + +PRE_INSTALLED = re_assert.Matches( + fr'Bash hook\.+Passed\n' + fr'\[master [a-f0-9]{{7}}\] commit!\n' + fr'{FILES_CHANGED}' + fr' create mode 100644 foo\n$', +) + + +def test_installs_hooks_with_hooks_True(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + install(C.CONFIG_FILE, store, hook_types=['pre-commit'], hooks=True) + ret, output = _get_commit_output( + tempdir_factory, pre_commit_home=store.directory, + ) + + assert ret == 0 + PRE_INSTALLED.assert_matches(output) + + +def test_install_hooks_command(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + install(C.CONFIG_FILE, store, hook_types=['pre-commit']) + install_hooks(C.CONFIG_FILE, store) + ret, output = _get_commit_output( + tempdir_factory, pre_commit_home=store.directory, + ) + + assert ret == 0 + PRE_INSTALLED.assert_matches(output) + + +def test_installed_from_venv(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + install(C.CONFIG_FILE, store, hook_types=['pre-commit']) + # No environment so pre-commit is not on the path when running! + # Should still pick up the python from when we installed + ret, output = _get_commit_output( + tempdir_factory, + env={ + 'HOME': os.path.expanduser('~'), + 'PATH': _path_without_us(), + 'TERM': os.environ.get('TERM', ''), + # Windows needs this to import `random` + 'SYSTEMROOT': os.environ.get('SYSTEMROOT', ''), + # Windows needs this to resolve executables + 'PATHEXT': os.environ.get('PATHEXT', ''), + # Git needs this to make a commit + 'GIT_AUTHOR_NAME': os.environ['GIT_AUTHOR_NAME'], + 'GIT_COMMITTER_NAME': os.environ['GIT_COMMITTER_NAME'], + 'GIT_AUTHOR_EMAIL': os.environ['GIT_AUTHOR_EMAIL'], + 'GIT_COMMITTER_EMAIL': os.environ['GIT_COMMITTER_EMAIL'], + }, + ) + assert ret == 0 + NORMAL_PRE_COMMIT_RUN.assert_matches(output) + + +def _get_push_output(tempdir_factory, remote='origin', opts=()): + return cmd_output_mocked_pre_commit_home( + 'git', 'push', remote, 'HEAD:new_branch', *opts, + tempdir_factory=tempdir_factory, + check=False, + )[:2] + + +def test_pre_push_integration_failing(tempdir_factory, store): + upstream = make_consuming_repo(tempdir_factory, 'failing_hook_repo') + path = tempdir_factory.get() + cmd_output('git', 'clone', upstream, path) + with cwd(path): + install(C.CONFIG_FILE, store, hook_types=['pre-push']) + # commit succeeds because pre-commit is only installed for pre-push + assert _get_commit_output(tempdir_factory)[0] == 0 + assert _get_commit_output(tempdir_factory, touch_file='zzz')[0] == 0 + + retc, output = _get_push_output(tempdir_factory) + assert retc == 1 + assert 'Failing hook' in output + assert 'Failed' in output + assert 'foo zzz' in output # both filenames should be printed + assert 'hook id: failing_hook' in output + + +def test_pre_push_integration_accepted(tempdir_factory, store): + upstream = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + path = tempdir_factory.get() + cmd_output('git', 'clone', upstream, path) + with cwd(path): + install(C.CONFIG_FILE, store, hook_types=['pre-push']) + assert _get_commit_output(tempdir_factory)[0] == 0 + + retc, output = _get_push_output(tempdir_factory) + assert retc == 0 + assert 'Bash hook' in output + assert 'Passed' in output + + +def test_pre_push_force_push_without_fetch(tempdir_factory, store): + upstream = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + path1 = tempdir_factory.get() + path2 = tempdir_factory.get() + cmd_output('git', 'clone', upstream, path1) + cmd_output('git', 'clone', upstream, path2) + with cwd(path1): + assert _get_commit_output(tempdir_factory)[0] == 0 + assert _get_push_output(tempdir_factory)[0] == 0 + + with cwd(path2): + install(C.CONFIG_FILE, store, hook_types=['pre-push']) + assert _get_commit_output(tempdir_factory, msg='force!')[0] == 0 + + retc, output = _get_push_output(tempdir_factory, opts=('--force',)) + assert retc == 0 + assert 'Bash hook' in output + assert 'Passed' in output + + +def test_pre_push_new_upstream(tempdir_factory, store): + upstream = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + upstream2 = git_dir(tempdir_factory) + path = tempdir_factory.get() + cmd_output('git', 'clone', upstream, path) + with cwd(path): + install(C.CONFIG_FILE, store, hook_types=['pre-push']) + assert _get_commit_output(tempdir_factory)[0] == 0 + + cmd_output('git', 'remote', 'rename', 'origin', 'upstream') + cmd_output('git', 'remote', 'add', 'origin', upstream2) + retc, output = _get_push_output(tempdir_factory) + assert retc == 0 + assert 'Bash hook' in output + assert 'Passed' in output + + +def test_pre_push_environment_variables(tempdir_factory, store): + config = { + 'repo': 'local', + 'hooks': [ + { + 'id': 'print-remote-info', + 'name': 'print remote info', + 'entry': 'bash -c "echo remote: $PRE_COMMIT_REMOTE_NAME"', + 'language': 'system', + 'verbose': True, + }, + ], + } + + upstream = git_dir(tempdir_factory) + clone = tempdir_factory.get() + cmd_output('git', 'clone', upstream, clone) + add_config_to_repo(clone, config) + with cwd(clone): + install(C.CONFIG_FILE, store, hook_types=['pre-push']) + + cmd_output('git', 'remote', 'rename', 'origin', 'origin2') + retc, output = _get_push_output(tempdir_factory, remote='origin2') + assert retc == 0 + assert '\nremote: origin2\n' in output + + +def test_pre_push_integration_empty_push(tempdir_factory, store): + upstream = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + path = tempdir_factory.get() + cmd_output('git', 'clone', upstream, path) + with cwd(path): + install(C.CONFIG_FILE, store, hook_types=['pre-push']) + _get_push_output(tempdir_factory) + retc, output = _get_push_output(tempdir_factory) + assert output == 'Everything up-to-date\n' + assert retc == 0 + + +def test_pre_push_legacy(tempdir_factory, store): + upstream = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + path = tempdir_factory.get() + cmd_output('git', 'clone', upstream, path) + with cwd(path): + os.makedirs(os.path.join(path, '.git/hooks'), exist_ok=True) + with open(os.path.join(path, '.git/hooks/pre-push'), 'w') as f: + f.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(f.name) + + install(C.CONFIG_FILE, store, hook_types=['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, store, +): + install(C.CONFIG_FILE, store, hook_types=['commit-msg']) + retc, out = _get_commit_output(tempdir_factory) + assert retc == 1 + assert out == '''\ +Must have "Signed off by:"...............................................Failed +- hook id: must-have-signoff +- exit code: 1 +''' + + +def test_commit_msg_integration_passing( + commit_msg_repo, tempdir_factory, store, +): + install(C.CONFIG_FILE, store, hook_types=['commit-msg']) + msg = 'Hi\nSigned off by: me, lol' + retc, out = _get_commit_output(tempdir_factory, msg=msg) + assert retc == 0 + first_line = out.splitlines()[0] + assert first_line.startswith('Must have "Signed off by:"...') + assert first_line.endswith('...Passed') + + +def test_commit_msg_legacy(commit_msg_repo, tempdir_factory, store): + hook_path = os.path.join(commit_msg_repo, '.git/hooks/commit-msg') + os.makedirs(os.path.dirname(hook_path), exist_ok=True) + with open(hook_path, 'w') as hook_file: + hook_file.write( + '#!/usr/bin/env bash\n' + 'set -eu\n' + 'test -e "$1"\n' + 'echo legacy\n', + ) + make_executable(hook_path) + + install(C.CONFIG_FILE, store, hook_types=['commit-msg']) + + msg = 'Hi\nSigned off by: asottile' + retc, out = _get_commit_output(tempdir_factory, msg=msg) + assert retc == 0 + first_line, second_line = out.splitlines()[:2] + assert first_line == 'legacy' + assert second_line.startswith('Must have "Signed off by:"...') + + +def test_post_commit_integration(tempdir_factory, store): + path = git_dir(tempdir_factory) + config = { + 'repos': [ + { + 'repo': 'local', + 'hooks': [{ + 'id': 'post-commit', + 'name': 'Post commit', + 'entry': 'touch post-commit.tmp', + 'language': 'system', + 'always_run': True, + 'verbose': True, + 'stages': ['post-commit'], + }], + }, + ], + } + write_config(path, config) + with cwd(path): + _get_commit_output(tempdir_factory) + assert not os.path.exists('post-commit.tmp') + + install(C.CONFIG_FILE, store, hook_types=['post-commit']) + _get_commit_output(tempdir_factory) + assert os.path.exists('post-commit.tmp') + + +def test_post_merge_integration(tempdir_factory, store): + path = git_dir(tempdir_factory) + config = { + 'repos': [ + { + 'repo': 'local', + 'hooks': [{ + 'id': 'post-merge', + 'name': 'Post merge', + 'entry': 'touch post-merge.tmp', + 'language': 'system', + 'always_run': True, + 'verbose': True, + 'stages': ['post-merge'], + }], + }, + ], + } + write_config(path, config) + with cwd(path): + # create a simple diamond of commits for a non-trivial merge + open('init', 'a').close() + cmd_output('git', 'add', '.') + git_commit() + + open('master', 'a').close() + cmd_output('git', 'add', '.') + git_commit() + + cmd_output('git', 'checkout', '-b', 'branch', 'HEAD^') + open('branch', 'a').close() + cmd_output('git', 'add', '.') + git_commit() + + cmd_output('git', 'checkout', 'master') + install(C.CONFIG_FILE, store, hook_types=['post-merge']) + retc, stdout, stderr = cmd_output_mocked_pre_commit_home( + 'git', 'merge', 'branch', + tempdir_factory=tempdir_factory, + ) + assert retc == 0 + assert os.path.exists('post-merge.tmp') + + +def test_pre_rebase_integration(tempdir_factory, store): + path = git_dir(tempdir_factory) + config = { + 'repos': [ + { + 'repo': 'local', + 'hooks': [{ + 'id': 'pre-rebase', + 'name': 'Pre rebase', + 'entry': 'touch pre-rebase.tmp', + 'language': 'system', + 'always_run': True, + 'verbose': True, + 'stages': ['pre-rebase'], + }], + }, + ], + } + write_config(path, config) + with cwd(path): + install(C.CONFIG_FILE, store, hook_types=['pre-rebase']) + open('foo', 'a').close() + cmd_output('git', 'add', '.') + git_commit() + + cmd_output('git', 'checkout', '-b', 'branch') + open('bar', 'a').close() + cmd_output('git', 'add', '.') + git_commit() + + cmd_output('git', 'checkout', 'master') + open('baz', 'a').close() + cmd_output('git', 'add', '.') + git_commit() + + cmd_output('git', 'checkout', 'branch') + cmd_output('git', 'rebase', 'master', 'branch') + assert os.path.exists('pre-rebase.tmp') + + +def test_post_rewrite_integration(tempdir_factory, store): + path = git_dir(tempdir_factory) + config = { + 'repos': [ + { + 'repo': 'local', + 'hooks': [{ + 'id': 'post-rewrite', + 'name': 'Post rewrite', + 'entry': 'touch post-rewrite.tmp', + 'language': 'system', + 'always_run': True, + 'verbose': True, + 'stages': ['post-rewrite'], + }], + }, + ], + } + write_config(path, config) + with cwd(path): + open('init', 'a').close() + cmd_output('git', 'add', '.') + install(C.CONFIG_FILE, store, hook_types=['post-rewrite']) + git_commit() + + assert not os.path.exists('post-rewrite.tmp') + + git_commit('--amend', '-m', 'ammended message') + assert os.path.exists('post-rewrite.tmp') + + +def test_post_checkout_integration(tempdir_factory, store): + path = git_dir(tempdir_factory) + config = { + 'repos': [ + { + 'repo': 'local', + 'hooks': [{ + 'id': 'post-checkout', + 'name': 'Post checkout', + 'entry': 'bash -c "echo ${PRE_COMMIT_TO_REF}"', + 'language': 'system', + 'always_run': True, + 'verbose': True, + 'stages': ['post-checkout'], + }], + }, + {'repo': 'meta', 'hooks': [{'id': 'identity'}]}, + ], + } + write_config(path, config) + with cwd(path): + cmd_output('git', 'add', '.') + git_commit() + + # add a file only on `feature`, it should not be passed to hooks + cmd_output('git', 'checkout', '-b', 'feature') + open('some_file', 'a').close() + cmd_output('git', 'add', '.') + git_commit() + cmd_output('git', 'checkout', 'master') + + install(C.CONFIG_FILE, store, hook_types=['post-checkout']) + retc, _, stderr = cmd_output('git', 'checkout', 'feature') + assert stderr is not None + assert retc == 0 + assert git.head_rev(path) in stderr + assert 'some_file' not in stderr + + +def test_skips_post_checkout_unstaged_changes(tempdir_factory, store): + path = git_dir(tempdir_factory) + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'fail', + 'name': 'fail', + 'entry': 'fail', + 'language': 'fail', + 'always_run': True, + 'stages': ['post-checkout'], + }], + } + write_config(path, config) + with cwd(path): + cmd_output('git', 'add', '.') + _get_commit_output(tempdir_factory) + + install(C.CONFIG_FILE, store, hook_types=['pre-commit']) + install(C.CONFIG_FILE, store, hook_types=['post-checkout']) + + # make an unstaged change so staged_files_only fires + open('file', 'a').close() + cmd_output('git', 'add', 'file') + with open('file', 'w') as f: + f.write('unstaged changes') + + retc, out = _get_commit_output(tempdir_factory, all_files=False) + assert retc == 0 + + +def test_prepare_commit_msg_integration_failing( + failing_prepare_commit_msg_repo, tempdir_factory, store, +): + install(C.CONFIG_FILE, store, hook_types=['prepare-commit-msg']) + retc, out = _get_commit_output(tempdir_factory) + assert retc == 1 + assert out == '''\ +Add "Signed off by:".....................................................Failed +- hook id: add-signoff +- exit code: 1 +''' + + +def test_prepare_commit_msg_integration_passing( + prepare_commit_msg_repo, tempdir_factory, store, +): + install(C.CONFIG_FILE, store, hook_types=['prepare-commit-msg']) + retc, out = _get_commit_output(tempdir_factory, msg='Hi') + assert retc == 0 + first_line = out.splitlines()[0] + assert first_line.startswith('Add "Signed off by:"...') + assert first_line.endswith('...Passed') + commit_msg_path = os.path.join( + prepare_commit_msg_repo, '.git/COMMIT_EDITMSG', + ) + with open(commit_msg_path) as f: + assert 'Signed off by: ' in f.read() + + +def test_prepare_commit_msg_legacy( + prepare_commit_msg_repo, tempdir_factory, store, +): + hook_path = os.path.join( + prepare_commit_msg_repo, '.git/hooks/prepare-commit-msg', + ) + os.makedirs(os.path.dirname(hook_path), exist_ok=True) + with open(hook_path, 'w') as hook_file: + hook_file.write( + '#!/usr/bin/env bash\n' + 'set -eu\n' + 'test -e "$1"\n' + 'echo legacy\n', + ) + make_executable(hook_path) + + install(C.CONFIG_FILE, store, hook_types=['prepare-commit-msg']) + + retc, out = _get_commit_output(tempdir_factory, msg='Hi') + assert retc == 0 + first_line, second_line = out.splitlines()[:2] + assert first_line == 'legacy' + assert second_line.startswith('Add "Signed off by:"...') + commit_msg_path = os.path.join( + prepare_commit_msg_repo, '.git/COMMIT_EDITMSG', + ) + with open(commit_msg_path) as f: + assert 'Signed off by: ' in f.read() + + +def test_pre_merge_commit_integration(tempdir_factory, store): + output_pattern = re_assert.Matches( + r'^\[INFO\] Initializing environment for .+\n' + r'Bash hook\.+Passed\n' + r"Merge made by the '(ort|recursive)' strategy.\n" + r' foo \| 0\n' + r' 1 file changed, 0 insertions\(\+\), 0 deletions\(-\)\n' + r' create mode 100644 foo\n$', + ) + + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + ret = install(C.CONFIG_FILE, store, hook_types=['pre-merge-commit']) + assert ret == 0 + + cmd_output('git', 'checkout', 'master', '-b', 'feature') + _get_commit_output(tempdir_factory) + cmd_output('git', 'checkout', 'master') + ret, output, _ = cmd_output_mocked_pre_commit_home( + 'git', 'merge', '--no-ff', '--no-edit', 'feature', + tempdir_factory=tempdir_factory, + ) + assert ret == 0 + output_pattern.assert_matches(output) + + +def test_install_disallow_missing_config(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + remove_config_from_repo(path) + ret = install( + C.CONFIG_FILE, store, hook_types=['pre-commit'], + overwrite=True, skip_on_missing_config=False, + ) + assert ret == 0 + + ret, output = _get_commit_output(tempdir_factory) + assert ret == 1 + + +def test_install_allow_missing_config(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + remove_config_from_repo(path) + ret = install( + C.CONFIG_FILE, store, hook_types=['pre-commit'], + overwrite=True, skip_on_missing_config=True, + ) + assert ret == 0 + + ret, output = _get_commit_output(tempdir_factory) + assert ret == 0 + expected = ( + '`.pre-commit-config.yaml` config file not found. ' + 'Skipping `pre-commit`.' + ) + assert expected in output + + +def test_install_temporarily_allow_mising_config(tempdir_factory, store): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(path): + remove_config_from_repo(path) + ret = install( + C.CONFIG_FILE, store, hook_types=['pre-commit'], + overwrite=True, skip_on_missing_config=False, + ) + assert ret == 0 + + env = dict(os.environ, PRE_COMMIT_ALLOW_NO_CONFIG='1') + ret, output = _get_commit_output(tempdir_factory, env=env) + assert ret == 0 + expected = ( + '`.pre-commit-config.yaml` config file not found. ' + 'Skipping `pre-commit`.' + ) + assert expected in output + + +def test_install_uninstall_default_hook_types(in_git_dir, store): + cfg_src = 'default_install_hook_types: [pre-commit, pre-push]\nrepos: []\n' + in_git_dir.join(C.CONFIG_FILE).write(cfg_src) + + assert not install(C.CONFIG_FILE, store, hook_types=None) + assert os.access(in_git_dir.join('.git/hooks/pre-commit').strpath, os.X_OK) + assert os.access(in_git_dir.join('.git/hooks/pre-push').strpath, os.X_OK) + + assert not uninstall(C.CONFIG_FILE, hook_types=None) + assert not in_git_dir.join('.git/hooks/pre-commit').exists() + assert not in_git_dir.join('.git/hooks/pre-push').exists() diff --git a/tests/commands/migrate_config_test.py b/tests/commands/migrate_config_test.py new file mode 100644 index 0000000..ba18463 --- /dev/null +++ b/tests/commands/migrate_config_test.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import pytest + +import pre_commit.constants as C +from pre_commit.clientlib import InvalidConfigError +from pre_commit.commands.migrate_config import migrate_config + + +def test_migrate_config_normal_format(tmpdir, capsys): + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write( + '- repo: local\n' + ' hooks:\n' + ' - id: foo\n' + ' name: foo\n' + ' entry: ./bin/foo.sh\n' + ' language: script\n', + ) + with tmpdir.as_cwd(): + assert not migrate_config(C.CONFIG_FILE) + out, _ = capsys.readouterr() + assert out == 'Configuration has been migrated.\n' + contents = cfg.read() + assert contents == ( + 'repos:\n' + '- repo: local\n' + ' hooks:\n' + ' - id: foo\n' + ' name: foo\n' + ' entry: ./bin/foo.sh\n' + ' language: script\n' + ) + + +def test_migrate_config_document_marker(tmpdir): + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write( + '# comment\n' + '\n' + '---\n' + '- repo: local\n' + ' hooks:\n' + ' - id: foo\n' + ' name: foo\n' + ' entry: ./bin/foo.sh\n' + ' language: script\n', + ) + with tmpdir.as_cwd(): + assert not migrate_config(C.CONFIG_FILE) + contents = cfg.read() + assert contents == ( + '# comment\n' + '\n' + '---\n' + 'repos:\n' + '- repo: local\n' + ' hooks:\n' + ' - id: foo\n' + ' name: foo\n' + ' entry: ./bin/foo.sh\n' + ' language: script\n' + ) + + +def test_migrate_config_list_literal(tmpdir): + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write( + '[{\n' + ' repo: local,\n' + ' hooks: [{\n' + ' id: foo, name: foo, entry: ./bin/foo.sh,\n' + ' language: script,\n' + ' }]\n' + '}]', + ) + with tmpdir.as_cwd(): + assert not migrate_config(C.CONFIG_FILE) + contents = cfg.read() + assert contents == ( + 'repos:\n' + ' [{\n' + ' repo: local,\n' + ' hooks: [{\n' + ' id: foo, name: foo, entry: ./bin/foo.sh,\n' + ' language: script,\n' + ' }]\n' + ' }]' + ) + + +def test_already_migrated_configuration_noop(tmpdir, capsys): + contents = ( + 'repos:\n' + '- repo: local\n' + ' hooks:\n' + ' - id: foo\n' + ' name: foo\n' + ' entry: ./bin/foo.sh\n' + ' language: script\n' + ) + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write(contents) + with tmpdir.as_cwd(): + assert not migrate_config(C.CONFIG_FILE) + out, _ = capsys.readouterr() + assert out == 'Configuration is already migrated.\n' + assert cfg.read() == contents + + +def test_migrate_config_sha_to_rev(tmpdir): + contents = ( + 'repos:\n' + '- repo: https://github.com/pre-commit/pre-commit-hooks\n' + ' sha: v1.2.0\n' + ' hooks: []\n' + '- repo: https://github.com/pre-commit/pre-commit-hooks\n' + ' sha: v1.2.0\n' + ' hooks: []\n' + ) + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write(contents) + with tmpdir.as_cwd(): + assert not migrate_config(C.CONFIG_FILE) + contents = cfg.read() + assert contents == ( + 'repos:\n' + '- repo: https://github.com/pre-commit/pre-commit-hooks\n' + ' rev: v1.2.0\n' + ' hooks: []\n' + '- repo: https://github.com/pre-commit/pre-commit-hooks\n' + ' rev: v1.2.0\n' + ' hooks: []\n' + ) + + +def test_migrate_config_language_python_venv(tmp_path): + src = '''\ +repos: +- repo: local + hooks: + - id: example + name: example + entry: example + language: python_venv + - id: example + name: example + entry: example + language: system +''' + expected = '''\ +repos: +- repo: local + hooks: + - id: example + name: example + entry: example + language: python + - id: example + name: example + entry: example + language: system +''' + cfg = tmp_path.joinpath('cfg.yaml') + cfg.write_text(src) + assert migrate_config(str(cfg)) == 0 + assert cfg.read_text() == expected + + +def test_migrate_config_invalid_yaml(tmpdir): + contents = '[' + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write(contents) + with tmpdir.as_cwd(), pytest.raises(InvalidConfigError) as excinfo: + migrate_config(C.CONFIG_FILE) + expected = '\n==> File .pre-commit-config.yaml\n=====> ' + assert str(excinfo.value).startswith(expected) diff --git a/tests/commands/run_test.py b/tests/commands/run_test.py new file mode 100644 index 0000000..e36a3ca --- /dev/null +++ b/tests/commands/run_test.py @@ -0,0 +1,1219 @@ +from __future__ import annotations + +import os.path +import shlex +import sys +import time +from collections.abc import MutableMapping +from unittest import mock + +import pytest + +import pre_commit.constants as C +from pre_commit import color +from pre_commit.commands.install_uninstall import install +from pre_commit.commands.run import _compute_cols +from pre_commit.commands.run import _full_msg +from pre_commit.commands.run import _get_skips +from pre_commit.commands.run import _has_unmerged_paths +from pre_commit.commands.run import _start_msg +from pre_commit.commands.run import Classifier +from pre_commit.commands.run import filter_by_include_exclude +from pre_commit.commands.run import run +from pre_commit.util import cmd_output +from pre_commit.util import make_executable +from testing.auto_namedtuple import auto_namedtuple +from testing.fixtures import add_config_to_repo +from testing.fixtures import git_dir +from testing.fixtures import make_consuming_repo +from testing.fixtures import modify_config +from testing.fixtures import read_config +from testing.fixtures import sample_meta_config +from testing.fixtures import write_config +from testing.util import cmd_output_mocked_pre_commit_home +from testing.util import cwd +from testing.util import git_commit +from testing.util import run_opts + + +def test_start_msg(): + ret = _start_msg(start='start', end_len=5, cols=15) + # 4 dots: 15 - 5 - 5 - 1 + assert ret == 'start....' + + +def test_full_msg(): + ret = _full_msg( + start='start', + end_msg='end', + end_color='', + use_color=False, + cols=15, + ) + # 6 dots: 15 - 5 - 3 - 1 + assert ret == 'start......end\n' + + +def test_full_msg_with_cjk(): + ret = _full_msg( + start='啊あ아', + end_msg='end', + end_color='', + use_color=False, + cols=15, + ) + # 5 dots: 15 - 6 - 3 - 1 + assert ret == '啊あ아.....end\n' + + +def test_full_msg_with_color(): + ret = _full_msg( + start='start', + end_msg='end', + end_color=color.RED, + use_color=True, + cols=15, + ) + # 6 dots: 15 - 5 - 3 - 1 + assert ret == f'start......{color.RED}end{color.NORMAL}\n' + + +def test_full_msg_with_postfix(): + ret = _full_msg( + start='start', + postfix='post ', + end_msg='end', + end_color='', + use_color=False, + cols=20, + ) + # 6 dots: 20 - 5 - 5 - 3 - 1 + assert ret == 'start......post end\n' + + +def test_full_msg_postfix_not_colored(): + ret = _full_msg( + start='start', + postfix='post ', + end_msg='end', + end_color=color.RED, + use_color=True, + cols=20, + ) + # 6 dots: 20 - 5 - 5 - 3 - 1 + assert ret == f'start......post {color.RED}end{color.NORMAL}\n' + + +@pytest.fixture +def repo_with_passing_hook(tempdir_factory): + git_path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(git_path): + yield git_path + + +@pytest.fixture +def repo_with_failing_hook(tempdir_factory): + git_path = make_consuming_repo(tempdir_factory, 'failing_hook_repo') + with cwd(git_path): + yield git_path + + +@pytest.fixture +def aliased_repo(tempdir_factory): + git_path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(git_path): + with modify_config() as config: + config['repos'][0]['hooks'].append( + {'id': 'bash_hook', 'alias': 'foo_bash'}, + ) + stage_a_file() + yield git_path + + +def stage_a_file(filename='foo.py'): + open(filename, 'a').close() + cmd_output('git', 'add', filename) + + +def _do_run(cap_out, store, repo, args, environ={}, config_file=C.CONFIG_FILE): + with cwd(repo): # replicates `main._adjust_args_and_chdir` behaviour + ret = run(config_file, store, args, environ=environ) + printed = cap_out.get_bytes() + return ret, printed + + +def _test_run( + cap_out, store, repo, opts, expected_outputs, expected_ret, stage, + config_file=C.CONFIG_FILE, +): + if stage: + stage_a_file() + args = run_opts(**opts) + ret, printed = _do_run(cap_out, store, repo, args, config_file=config_file) + + assert ret == expected_ret, (ret, expected_ret, printed) + for expected_output_part in expected_outputs: + assert expected_output_part in printed + + +def test_run_all_hooks_failing(cap_out, store, repo_with_failing_hook): + _test_run( + cap_out, + store, + repo_with_failing_hook, + {}, + ( + b'Failing hook', + b'Failed', + b'hook id: failing_hook', + b'Fail\nfoo.py\n', + ), + expected_ret=1, + stage=True, + ) + + +def test_arbitrary_bytes_hook(cap_out, store, tempdir_factory): + git_path = make_consuming_repo(tempdir_factory, 'arbitrary_bytes_repo') + with cwd(git_path): + _test_run( + cap_out, store, git_path, {}, (b'\xe2\x98\x83\xb2\n',), 1, True, + ) + + +def test_hook_that_modifies_but_returns_zero(cap_out, store, tempdir_factory): + git_path = make_consuming_repo( + tempdir_factory, 'modified_file_returns_zero_repo', + ) + with cwd(git_path): + stage_a_file('bar.py') + _test_run( + cap_out, + store, + git_path, + {}, + ( + # The first should fail + b'Failed', + # With a modified file (default message + the hook's output) + b'- files were modified by this hook\n\n' + b'Modified: foo.py', + # The next hook should pass despite the first modifying + b'Passed', + # The next hook should fail + b'Failed', + # bar.py was modified, but provides no additional output + b'- files were modified by this hook\n', + ), + 1, + True, + ) + + +def test_types_hook_repository(cap_out, store, tempdir_factory): + git_path = make_consuming_repo(tempdir_factory, 'types_repo') + with cwd(git_path): + stage_a_file('bar.py') + stage_a_file('bar.notpy') + ret, printed = _do_run(cap_out, store, git_path, run_opts()) + assert ret == 1 + assert b'bar.py' in printed + assert b'bar.notpy' not in printed + + +def test_types_or_hook_repository(cap_out, store, tempdir_factory): + git_path = make_consuming_repo(tempdir_factory, 'types_or_repo') + with cwd(git_path): + stage_a_file('bar.notpy') + stage_a_file('bar.pxd') + stage_a_file('bar.py') + ret, printed = _do_run(cap_out, store, git_path, run_opts()) + assert ret == 1 + assert b'bar.notpy' not in printed + assert b'bar.pxd' in printed + assert b'bar.py' in printed + + +def test_exclude_types_hook_repository(cap_out, store, tempdir_factory): + git_path = make_consuming_repo(tempdir_factory, 'exclude_types_repo') + with cwd(git_path): + with open('exe', 'w') as exe: + exe.write('#!/usr/bin/env python3\n') + make_executable('exe') + cmd_output('git', 'add', 'exe') + stage_a_file('bar.py') + ret, printed = _do_run(cap_out, store, git_path, run_opts()) + assert ret == 1 + assert b'bar.py' in printed + assert b'exe' not in printed + + +def test_global_exclude(cap_out, store, in_git_dir): + config = { + 'exclude': r'^foo\.py$', + 'repos': [{'repo': 'meta', 'hooks': [{'id': 'identity'}]}], + } + write_config('.', config) + open('foo.py', 'a').close() + open('bar.py', 'a').close() + cmd_output('git', 'add', '.') + opts = run_opts(verbose=True) + ret, printed = _do_run(cap_out, store, str(in_git_dir), opts) + assert ret == 0 + # Does not contain foo.py since it was excluded + assert printed.startswith(f'identity{"." * 65}Passed\n'.encode()) + assert printed.endswith(b'\n\n.pre-commit-config.yaml\nbar.py\n\n') + + +def test_global_files(cap_out, store, in_git_dir): + config = { + 'files': r'^bar\.py$', + 'repos': [{'repo': 'meta', 'hooks': [{'id': 'identity'}]}], + } + write_config('.', config) + open('foo.py', 'a').close() + open('bar.py', 'a').close() + cmd_output('git', 'add', '.') + opts = run_opts(verbose=True) + ret, printed = _do_run(cap_out, store, str(in_git_dir), opts) + assert ret == 0 + # Does not contain foo.py since it was excluded + assert printed.startswith(f'identity{"." * 65}Passed\n'.encode()) + assert printed.endswith(b'\n\nbar.py\n\n') + + +@pytest.mark.parametrize( + ('t1', 't2', 'expected'), + ( + (1.234, 2., b'\n- duration: 0.77s\n'), + (1., 1., b'\n- duration: 0s\n'), + ), +) +def test_verbose_duration(cap_out, store, in_git_dir, t1, t2, expected): + write_config('.', {'repo': 'meta', 'hooks': [{'id': 'identity'}]}) + cmd_output('git', 'add', '.') + opts = run_opts(verbose=True) + with mock.patch.object(time, 'monotonic', side_effect=(t1, t2)): + ret, printed = _do_run(cap_out, store, str(in_git_dir), opts) + assert ret == 0 + assert expected in printed + + +@pytest.mark.parametrize( + ('args', 'expected_out'), + [ + ( + { + 'show_diff_on_failure': True, + }, + b'All changes made by hooks:', + ), + ( + { + 'show_diff_on_failure': True, + 'color': True, + }, + b'All changes made by hooks:', + ), + ( + { + 'show_diff_on_failure': True, + 'all_files': True, + }, + b'reproduce locally with: pre-commit run --all-files', + ), + ], +) +def test_show_diff_on_failure( + args, + expected_out, + capfd, + cap_out, + store, + tempdir_factory, +): + git_path = make_consuming_repo( + tempdir_factory, 'modified_file_returns_zero_repo', + ) + with cwd(git_path): + stage_a_file('bar.py') + _test_run( + cap_out, store, git_path, args, + # we're only testing the output after running + expected_out, 1, True, + ) + out, _ = capfd.readouterr() + assert 'diff --git' in out + + +@pytest.mark.parametrize( + ('options', 'outputs', 'expected_ret', 'stage'), + ( + ({}, (b'Bash hook', b'Passed'), 0, True), + ({'verbose': True}, (b'foo.py\nHello World',), 0, True), + ({'hook': 'bash_hook'}, (b'Bash hook', b'Passed'), 0, True), + ( + {'hook': 'nope'}, + (b'No hook with id `nope` in stage `pre-commit`',), + 1, + True, + ), + ( + {'hook': 'nope', 'hook_stage': 'pre-push'}, + (b'No hook with id `nope` in stage `pre-push`',), + 1, + True, + ), + ( + {'all_files': True, 'verbose': True}, + (b'foo.py',), + 0, + True, + ), + ( + {'files': ('foo.py',), 'verbose': True}, + (b'foo.py',), + 0, + True, + ), + ({}, (b'Bash hook', b'(no files to check)', b'Skipped'), 0, False), + ), +) +def test_run( + cap_out, + store, + repo_with_passing_hook, + options, + outputs, + expected_ret, + stage, +): + _test_run( + cap_out, + store, + repo_with_passing_hook, + options, + outputs, + expected_ret, + stage, + ) + + +def test_run_output_logfile(cap_out, store, tempdir_factory): + expected_output = ( + b'This is STDOUT output\n', + b'This is STDERR output\n', + ) + + git_path = make_consuming_repo(tempdir_factory, 'logfile_repo') + with cwd(git_path): + _test_run( + cap_out, + store, + git_path, {}, + expected_output, + expected_ret=1, + stage=True, + ) + logfile_path = os.path.join(git_path, 'test.log') + assert os.path.exists(logfile_path) + with open(logfile_path, 'rb') as logfile: + logfile_content = logfile.readlines() + + for expected_output_part in expected_output: + assert expected_output_part in logfile_content + + +def test_always_run(cap_out, store, repo_with_passing_hook): + with modify_config() as config: + config['repos'][0]['hooks'][0]['always_run'] = True + _test_run( + cap_out, + store, + repo_with_passing_hook, + {}, + (b'Bash hook', b'Passed'), + 0, + stage=False, + ) + + +def test_always_run_alt_config(cap_out, store, repo_with_passing_hook): + repo_root = '.' + config = read_config(repo_root) + config['repos'][0]['hooks'][0]['always_run'] = True + alt_config_file = 'alternate_config.yaml' + add_config_to_repo(repo_root, config, config_file=alt_config_file) + + _test_run( + cap_out, + store, + repo_with_passing_hook, + {}, + (b'Bash hook', b'Passed'), + 0, + stage=False, + config_file=alt_config_file, + ) + + +def test_hook_verbose_enabled(cap_out, store, repo_with_passing_hook): + with modify_config() as config: + config['repos'][0]['hooks'][0]['always_run'] = True + config['repos'][0]['hooks'][0]['verbose'] = True + + _test_run( + cap_out, + store, + repo_with_passing_hook, + {}, + (b'Hello World',), + 0, + stage=False, + ) + + +@pytest.mark.parametrize( + ('from_ref', 'to_ref'), (('master', ''), ('', 'master')), +) +def test_from_ref_to_ref_error_msg_error( + cap_out, store, repo_with_passing_hook, from_ref, to_ref, +): + args = run_opts(from_ref=from_ref, to_ref=to_ref) + ret, printed = _do_run(cap_out, store, repo_with_passing_hook, args) + assert ret == 1 + assert b'Specify both --from-ref and --to-ref.' in printed + + +def test_all_push_options_ok(cap_out, store, repo_with_passing_hook): + args = run_opts( + from_ref='master', to_ref='master', + remote_branch='master', + local_branch='master', + remote_name='origin', remote_url='https://example.com/repo', + ) + ret, printed = _do_run(cap_out, store, repo_with_passing_hook, args) + assert ret == 0 + assert b'Specify both --from-ref and --to-ref.' not in printed + + +def test_is_squash_merge(cap_out, store, repo_with_passing_hook): + args = run_opts(is_squash_merge='1') + environ: MutableMapping[str, str] = {} + ret, printed = _do_run( + cap_out, store, repo_with_passing_hook, args, environ, + ) + assert environ['PRE_COMMIT_IS_SQUASH_MERGE'] == '1' + + +def test_rewrite_command(cap_out, store, repo_with_passing_hook): + args = run_opts(rewrite_command='amend') + environ: MutableMapping[str, str] = {} + ret, printed = _do_run( + cap_out, store, repo_with_passing_hook, args, environ, + ) + assert environ['PRE_COMMIT_REWRITE_COMMAND'] == 'amend' + + +def test_checkout_type(cap_out, store, repo_with_passing_hook): + args = run_opts(from_ref='', to_ref='', checkout_type='1') + environ: MutableMapping[str, str] = {} + ret, printed = _do_run( + cap_out, store, repo_with_passing_hook, args, environ, + ) + assert environ['PRE_COMMIT_CHECKOUT_TYPE'] == '1' + + +def test_has_unmerged_paths(in_merge_conflict): + assert _has_unmerged_paths() is True + cmd_output('git', 'add', '.') + assert _has_unmerged_paths() is False + + +def test_merge_conflict(cap_out, store, in_merge_conflict): + ret, printed = _do_run(cap_out, store, in_merge_conflict, run_opts()) + assert ret == 1 + assert b'Unmerged files. Resolve before committing.' in printed + + +def test_files_during_merge_conflict(cap_out, store, in_merge_conflict): + opts = run_opts(files=['placeholder']) + ret, printed = _do_run(cap_out, store, in_merge_conflict, opts) + assert ret == 0 + assert b'Bash hook' in printed + + +def test_merge_conflict_modified(cap_out, store, in_merge_conflict): + # Touch another file so we have unstaged non-conflicting things + assert os.path.exists('placeholder') + with open('placeholder', 'w') as placeholder_file: + placeholder_file.write('bar\nbaz\n') + + ret, printed = _do_run(cap_out, store, in_merge_conflict, run_opts()) + assert ret == 1 + assert b'Unmerged files. Resolve before committing.' in printed + + +def test_merge_conflict_resolved(cap_out, store, in_merge_conflict): + cmd_output('git', 'add', '.') + ret, printed = _do_run(cap_out, store, in_merge_conflict, run_opts()) + for msg in ( + b'Checking merge-conflict files only.', b'Bash hook', b'Passed', + ): + assert msg in printed + + +def test_rebase(cap_out, store, repo_with_passing_hook): + args = run_opts(pre_rebase_upstream='master', pre_rebase_branch='topic') + environ: MutableMapping[str, str] = {} + ret, printed = _do_run( + cap_out, store, repo_with_passing_hook, args, environ, + ) + assert environ['PRE_COMMIT_PRE_REBASE_UPSTREAM'] == 'master' + assert environ['PRE_COMMIT_PRE_REBASE_BRANCH'] == 'topic' + + +@pytest.mark.parametrize( + ('hooks', 'expected'), + ( + ([], 80), + ([auto_namedtuple(id='a', name='a' * 51)], 81), + ( + [ + auto_namedtuple(id='a', name='a' * 51), + auto_namedtuple(id='b', name='b' * 52), + ], + 82, + ), + ), +) +def test_compute_cols(hooks, expected): + assert _compute_cols(hooks) == expected + + +@pytest.mark.parametrize( + ('environ', 'expected_output'), + ( + ({}, set()), + ({'SKIP': ''}, set()), + ({'SKIP': ','}, set()), + ({'SKIP': ',foo'}, {'foo'}), + ({'SKIP': 'foo'}, {'foo'}), + ({'SKIP': 'foo,bar'}, {'foo', 'bar'}), + ({'SKIP': ' foo , bar'}, {'foo', 'bar'}), + ), +) +def test_get_skips(environ, expected_output): + ret = _get_skips(environ) + assert ret == expected_output + + +def test_skip_hook(cap_out, store, repo_with_passing_hook): + ret, printed = _do_run( + cap_out, store, repo_with_passing_hook, run_opts(), + {'SKIP': 'bash_hook'}, + ) + for msg in (b'Bash hook', b'Skipped'): + assert msg in printed + + +def test_skip_aliased_hook(cap_out, store, aliased_repo): + ret, printed = _do_run( + cap_out, store, aliased_repo, + run_opts(hook='foo_bash'), + {'SKIP': 'foo_bash'}, + ) + assert ret == 0 + # Only the aliased hook runs and is skipped + for msg in (b'Bash hook', b'Skipped'): + assert printed.count(msg) == 1 + + +def test_skip_bypasses_installation(cap_out, store, repo_with_passing_hook): + config = { + 'repo': 'local', + 'hooks': [ + { + 'id': 'skipme', + 'name': 'skipme', + 'entry': 'skipme', + 'language': 'python', + 'additional_dependencies': ['/pre-commit-does-not-exist'], + }, + ], + } + add_config_to_repo(repo_with_passing_hook, config) + + ret, printed = _do_run( + cap_out, store, repo_with_passing_hook, + run_opts(all_files=True), + {'SKIP': 'skipme'}, + ) + assert ret == 0 + + +def test_skip_alias_bypasses_installation( + cap_out, store, repo_with_passing_hook, +): + config = { + 'repo': 'local', + 'hooks': [ + { + 'id': 'skipme', + 'name': 'skipme-1', + 'alias': 'skipme-1', + 'entry': 'skipme', + 'language': 'python', + 'additional_dependencies': ['/pre-commit-does-not-exist'], + }, + ], + } + add_config_to_repo(repo_with_passing_hook, config) + + ret, printed = _do_run( + cap_out, store, repo_with_passing_hook, + run_opts(all_files=True), + {'SKIP': 'skipme-1'}, + ) + assert ret == 0 + + +def test_hook_id_not_in_non_verbose_output( + cap_out, store, repo_with_passing_hook, +): + ret, printed = _do_run( + cap_out, store, repo_with_passing_hook, run_opts(verbose=False), + ) + assert b'[bash_hook]' not in printed + + +def test_hook_id_in_verbose_output(cap_out, store, repo_with_passing_hook): + ret, printed = _do_run( + cap_out, store, repo_with_passing_hook, run_opts(verbose=True), + ) + assert b'- hook id: bash_hook' in printed + + +def test_multiple_hooks_same_id(cap_out, store, repo_with_passing_hook): + with cwd(repo_with_passing_hook): + # Add bash hook on there again + with modify_config() as config: + config['repos'][0]['hooks'].append({'id': 'bash_hook'}) + stage_a_file() + + ret, output = _do_run(cap_out, store, repo_with_passing_hook, run_opts()) + assert ret == 0 + assert output.count(b'Bash hook') == 2 + + +def test_aliased_hook_run(cap_out, store, aliased_repo): + ret, output = _do_run( + cap_out, store, aliased_repo, + run_opts(verbose=True, hook='bash_hook'), + ) + assert ret == 0 + # Both hooks will run since they share the same ID + assert output.count(b'Bash hook') == 2 + + ret, output = _do_run( + cap_out, store, aliased_repo, + run_opts(verbose=True, hook='foo_bash'), + ) + assert ret == 0 + # Only the aliased hook runs + assert output.count(b'Bash hook') == 1 + + +def test_non_ascii_hook_id(repo_with_passing_hook, tempdir_factory): + with cwd(repo_with_passing_hook): + _, stdout, _ = cmd_output_mocked_pre_commit_home( + sys.executable, '-m', 'pre_commit.main', 'run', '☃', + check=False, tempdir_factory=tempdir_factory, + ) + assert 'UnicodeDecodeError' not in stdout + # Doesn't actually happen, but a reasonable assertion + assert 'UnicodeEncodeError' not in stdout + + +def test_stdout_write_bug_py26(repo_with_failing_hook, store, tempdir_factory): + with cwd(repo_with_failing_hook): + with modify_config() as config: + config['repos'][0]['hooks'][0]['args'] = ['☃'] + stage_a_file() + + install(C.CONFIG_FILE, store, hook_types=['pre-commit']) + + # Have to use subprocess because pytest monkeypatches sys.stdout + _, out = git_commit( + fn=cmd_output_mocked_pre_commit_home, + tempdir_factory=tempdir_factory, + check=False, + ) + assert 'UnicodeEncodeError' not in out + # Doesn't actually happen, but a reasonable assertion + assert 'UnicodeDecodeError' not in out + + +def test_lots_of_files(store, tempdir_factory): + # windows xargs seems to have a bug, here's a regression test for + # our workaround + git_path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + with cwd(git_path): + # Override files so we run against them + with modify_config() as config: + config['repos'][0]['hooks'][0]['files'] = '' + + # Write a crap ton of files + for i in range(400): + open(f'{"a" * 100}{i}', 'w').close() + + cmd_output('git', 'add', '.') + install(C.CONFIG_FILE, store, hook_types=['pre-commit']) + + git_commit( + fn=cmd_output_mocked_pre_commit_home, + tempdir_factory=tempdir_factory, + ) + + +def test_no_textconv(cap_out, store, repo_with_passing_hook): + # git textconv filters can hide changes from hooks + with open('.gitattributes', 'w') as fp: + fp.write('*.jpeg diff=empty\n') + + with open('.git/config', 'a') as fp: + fp.write('[diff "empty"]\n') + fp.write('textconv = "true"\n') + + config = { + 'repo': 'local', + 'hooks': [ + { + 'id': 'extend-jpeg', + 'name': 'extend-jpeg', + 'language': 'system', + 'entry': ( + f'{shlex.quote(sys.executable)} -c "import sys; ' + 'open(sys.argv[1], \'ab\').write(b\'\\x00\')"' + ), + 'types': ['jpeg'], + }, + ], + } + add_config_to_repo(repo_with_passing_hook, config) + + stage_a_file('example.jpeg') + + _test_run( + cap_out, + store, + repo_with_passing_hook, + {}, + ( + b'Failed', + ), + expected_ret=1, + stage=False, + ) + + +def test_stages(cap_out, store, repo_with_passing_hook): + config = { + 'repo': 'local', + 'hooks': [ + { + 'id': f'do-not-commit-{i}', + 'name': f'hook {i}', + 'entry': 'DO NOT COMMIT', + 'language': 'pygrep', + 'stages': [stage], + } + for i, stage in enumerate(('pre-commit', 'pre-push', 'manual'), 1) + ], + } + add_config_to_repo(repo_with_passing_hook, config) + + stage_a_file() + + def _run_for_stage(stage): + args = run_opts(hook_stage=stage) + ret, printed = _do_run(cap_out, store, repo_with_passing_hook, args) + assert not ret, (ret, printed) + # this test should only run one hook + assert printed.count(b'hook ') == 1 + return printed + + assert _run_for_stage('pre-commit').startswith(b'hook 1...') + assert _run_for_stage('pre-push').startswith(b'hook 2...') + assert _run_for_stage('manual').startswith(b'hook 3...') + + +def test_commit_msg_hook(cap_out, store, commit_msg_repo): + filename = '.git/COMMIT_EDITMSG' + with open(filename, 'w') as f: + f.write('This is the commit message') + + _test_run( + cap_out, + store, + commit_msg_repo, + {'hook_stage': 'commit-msg', 'commit_msg_filename': filename}, + expected_outputs=[b'Must have "Signed off by:"', b'Failed'], + expected_ret=1, + stage=False, + ) + + +def test_post_checkout_hook(cap_out, store, tempdir_factory): + path = git_dir(tempdir_factory) + config = { + 'repo': 'meta', 'hooks': [ + {'id': 'identity', 'stages': ['post-checkout']}, + ], + } + add_config_to_repo(path, config) + + with cwd(path): + _test_run( + cap_out, + store, + path, + {'hook_stage': 'post-checkout'}, + expected_outputs=[b'identity...'], + expected_ret=0, + stage=False, + ) + + +def test_prepare_commit_msg_hook(cap_out, store, prepare_commit_msg_repo): + filename = '.git/COMMIT_EDITMSG' + with open(filename, 'w') as f: + f.write('This is the commit message') + + _test_run( + cap_out, + store, + prepare_commit_msg_repo, + { + 'hook_stage': 'prepare-commit-msg', + 'commit_msg_filename': filename, + 'prepare_commit_message_source': 'commit', + 'commit_object_name': 'HEAD', + }, + expected_outputs=[b'Add "Signed off by:"', b'Passed'], + expected_ret=0, + stage=False, + ) + + with open(filename) as f: + assert 'Signed off by: ' in f.read() + + +def test_local_hook_passes(cap_out, store, repo_with_passing_hook): + config = { + 'repo': 'local', + 'hooks': [ + { + 'id': 'identity-copy', + 'name': 'identity-copy', + 'entry': '{} -m pre_commit.meta_hooks.identity'.format( + shlex.quote(sys.executable), + ), + 'language': 'system', + 'files': r'\.py$', + }, + { + 'id': 'do_not_commit', + 'name': 'Block if "DO NOT COMMIT" is found', + 'entry': 'DO NOT COMMIT', + 'language': 'pygrep', + }, + ], + } + add_config_to_repo(repo_with_passing_hook, config) + + with open('placeholder.py', 'w') as staged_file: + staged_file.write('"""TODO: something"""\n') + cmd_output('git', 'add', 'placeholder.py') + + _test_run( + cap_out, + store, + repo_with_passing_hook, + opts={}, + expected_outputs=[b''], + expected_ret=0, + stage=False, + ) + + +def test_local_hook_fails(cap_out, store, repo_with_passing_hook): + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'no-todo', + 'name': 'No TODO', + 'entry': 'sh -c "! grep -iI todo $@" --', + 'language': 'system', + }], + } + add_config_to_repo(repo_with_passing_hook, config) + + with open('placeholder.py', 'w') as staged_file: + staged_file.write('"""TODO: something"""\n') + cmd_output('git', 'add', 'placeholder.py') + + _test_run( + cap_out, + store, + repo_with_passing_hook, + opts={}, + expected_outputs=[b''], + expected_ret=1, + stage=False, + ) + + +def test_meta_hook_passes(cap_out, store, repo_with_passing_hook): + add_config_to_repo(repo_with_passing_hook, sample_meta_config()) + + _test_run( + cap_out, + store, + repo_with_passing_hook, + opts={}, + expected_outputs=[b'Check for useless excludes'], + expected_ret=0, + stage=False, + ) + + +@pytest.fixture +def modified_config_repo(repo_with_passing_hook): + with modify_config(repo_with_passing_hook, commit=False) as config: + # Some minor modification + config['repos'][0]['hooks'][0]['files'] = '' + yield repo_with_passing_hook + + +def test_error_with_unstaged_config(cap_out, store, modified_config_repo): + args = run_opts() + ret, printed = _do_run(cap_out, store, modified_config_repo, args) + assert b'Your pre-commit configuration is unstaged.' in printed + assert ret == 1 + + +def test_commit_msg_missing_filename(cap_out, store, repo_with_passing_hook): + args = run_opts(hook_stage='commit-msg') + ret, printed = _do_run(cap_out, store, repo_with_passing_hook, args) + assert ret == 1 + assert printed == ( + b'[ERROR] `--commit-msg-filename` is required for ' + b'`--hook-stage commit-msg`\n' + ) + + +@pytest.mark.parametrize( + 'opts', (run_opts(all_files=True), run_opts(files=[C.CONFIG_FILE])), +) +def test_no_unstaged_error_with_all_files_or_files( + cap_out, store, modified_config_repo, opts, +): + ret, printed = _do_run(cap_out, store, modified_config_repo, opts) + assert b'Your pre-commit configuration is unstaged.' not in printed + + +def test_files_running_subdir(repo_with_passing_hook, tempdir_factory): + with cwd(repo_with_passing_hook): + os.mkdir('subdir') + open('subdir/foo.py', 'w').close() + cmd_output('git', 'add', 'subdir/foo.py') + + with cwd('subdir'): + # Use subprocess to demonstrate behaviour in main + _, stdout, _ = cmd_output_mocked_pre_commit_home( + sys.executable, '-m', 'pre_commit.main', 'run', '-v', + # Files relative to where we are (#339) + '--files', 'foo.py', + tempdir_factory=tempdir_factory, + ) + assert 'subdir/foo.py' in stdout + + +@pytest.mark.parametrize( + ('pass_filenames', 'hook_args', 'expected_out'), + ( + (True, [], b'foo.py'), + (False, [], b''), + (True, ['some', 'args'], b'some args foo.py'), + (False, ['some', 'args'], b'some args'), + ), +) +def test_pass_filenames( + cap_out, store, repo_with_passing_hook, + pass_filenames, hook_args, expected_out, +): + with modify_config() as config: + config['repos'][0]['hooks'][0]['pass_filenames'] = pass_filenames + config['repos'][0]['hooks'][0]['args'] = hook_args + stage_a_file() + ret, printed = _do_run( + cap_out, store, repo_with_passing_hook, run_opts(verbose=True), + ) + assert expected_out + b'\nHello World' in printed + assert (b'foo.py' in printed) == pass_filenames + + +def test_fail_fast(cap_out, store, repo_with_failing_hook): + with modify_config() as config: + # More than one hook + config['fail_fast'] = True + config['repos'][0]['hooks'] *= 2 + stage_a_file() + + ret, printed = _do_run(cap_out, store, repo_with_failing_hook, run_opts()) + # it should have only run one hook + assert printed.count(b'Failing hook') == 1 + + +def test_fail_fast_per_hook(cap_out, store, repo_with_failing_hook): + with modify_config() as config: + # More than one hook + config['repos'][0]['hooks'] *= 2 + config['repos'][0]['hooks'][0]['fail_fast'] = True + stage_a_file() + + ret, printed = _do_run(cap_out, store, repo_with_failing_hook, run_opts()) + # it should have only run one hook + assert printed.count(b'Failing hook') == 1 + + +def test_classifier_removes_dne(): + classifier = Classifier(('this_file_does_not_exist',)) + assert classifier.filenames == [] + + +def test_classifier_normalizes_filenames_on_windows_to_forward_slashes(tmpdir): + with tmpdir.as_cwd(): + tmpdir.join('a/b/c').ensure() + with mock.patch.object(os, 'altsep', '/'): + with mock.patch.object(os, 'sep', '\\'): + classifier = Classifier.from_config((r'a\b\c',), '', '^$') + assert classifier.filenames == ['a/b/c'] + + +def test_classifier_does_not_normalize_backslashes_non_windows(tmpdir): + with mock.patch.object(os.path, 'lexists', return_value=True): + with mock.patch.object(os, 'altsep', None): + with mock.patch.object(os, 'sep', '/'): + classifier = Classifier.from_config((r'a/b\c',), '', '^$') + assert classifier.filenames == [r'a/b\c'] + + +def test_classifier_empty_types_or(tmpdir): + tmpdir.join('bar').ensure() + os.symlink(tmpdir.join('bar'), tmpdir.join('foo')) + with tmpdir.as_cwd(): + classifier = Classifier(('foo', 'bar')) + for_symlink = classifier.by_types( + classifier.filenames, + types=['symlink'], + types_or=[], + exclude_types=[], + ) + for_file = classifier.by_types( + classifier.filenames, + types=['file'], + types_or=[], + exclude_types=[], + ) + assert tuple(for_symlink) == ('foo',) + assert tuple(for_file) == ('bar',) + + +@pytest.fixture +def some_filenames(): + return ( + '.pre-commit-hooks.yaml', + 'pre_commit/git.py', + 'pre_commit/main.py', + ) + + +def test_include_exclude_base_case(some_filenames): + ret = filter_by_include_exclude(some_filenames, '', '^$') + assert tuple(ret) == ( + '.pre-commit-hooks.yaml', + 'pre_commit/git.py', + 'pre_commit/main.py', + ) + + +def test_matches_broken_symlink(tmpdir): + with tmpdir.as_cwd(): + os.symlink('does-not-exist', 'link') + ret = filter_by_include_exclude({'link'}, '', '^$') + assert tuple(ret) == ('link',) + + +def test_include_exclude_total_match(some_filenames): + ret = filter_by_include_exclude(some_filenames, r'^.*\.py$', '^$') + assert tuple(ret) == ('pre_commit/git.py', 'pre_commit/main.py') + + +def test_include_exclude_does_search_instead_of_match(some_filenames): + ret = filter_by_include_exclude(some_filenames, r'\.yaml$', '^$') + assert tuple(ret) == ('.pre-commit-hooks.yaml',) + + +def test_include_exclude_exclude_removes_files(some_filenames): + ret = filter_by_include_exclude(some_filenames, '', r'\.py$') + assert tuple(ret) == ('.pre-commit-hooks.yaml',) + + +def test_args_hook_only(cap_out, store, repo_with_passing_hook): + config = { + 'repo': 'local', + 'hooks': [ + { + 'id': 'identity-copy', + 'name': 'identity-copy', + 'entry': '{} -m pre_commit.meta_hooks.identity'.format( + shlex.quote(sys.executable), + ), + 'language': 'system', + 'files': r'\.py$', + 'stages': ['pre-commit'], + }, + { + 'id': 'do_not_commit', + 'name': 'Block if "DO NOT COMMIT" is found', + 'entry': 'DO NOT COMMIT', + 'language': 'pygrep', + }, + ], + } + add_config_to_repo(repo_with_passing_hook, config) + stage_a_file() + ret, printed = _do_run( + cap_out, + store, + repo_with_passing_hook, + run_opts(hook='do_not_commit'), + ) + assert b'identity-copy' not in printed + + +def test_skipped_without_any_setup_for_post_checkout(in_git_dir, store): + environ = {'_PRE_COMMIT_SKIP_POST_CHECKOUT': '1'} + opts = run_opts(hook_stage='post-checkout') + assert run(C.CONFIG_FILE, store, opts, environ=environ) == 0 + + +def test_pre_commit_env_variable_set(cap_out, store, repo_with_passing_hook): + args = run_opts() + environ: MutableMapping[str, str] = {} + ret, printed = _do_run( + cap_out, store, repo_with_passing_hook, args, environ, + ) + assert environ['PRE_COMMIT'] == '1' diff --git a/tests/commands/sample_config_test.py b/tests/commands/sample_config_test.py new file mode 100644 index 0000000..cf56e98 --- /dev/null +++ b/tests/commands/sample_config_test.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pre_commit.commands.sample_config import sample_config + + +def test_sample_config(capsys): + ret = sample_config() + assert ret == 0 + out, _ = capsys.readouterr() + assert out == '''\ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files +''' diff --git a/tests/commands/try_repo_test.py b/tests/commands/try_repo_test.py new file mode 100644 index 0000000..c5f891e --- /dev/null +++ b/tests/commands/try_repo_test.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import os.path +import re +import time +from unittest import mock + +import re_assert + +from pre_commit import git +from pre_commit.commands.try_repo import try_repo +from pre_commit.util import cmd_output +from testing.auto_namedtuple import auto_namedtuple +from testing.fixtures import git_dir +from testing.fixtures import make_repo +from testing.fixtures import modify_manifest +from testing.util import cwd +from testing.util import git_commit +from testing.util import run_opts + + +def try_repo_opts(repo, ref=None, **kwargs): + return auto_namedtuple(repo=repo, ref=ref, **run_opts(**kwargs)._asdict()) + + +def _get_out(cap_out): + out = re.sub(r'\[INFO\].+\n', '', cap_out.get()) + start, using_config, config, rest = out.split(f'{"=" * 79}\n') + assert using_config == 'Using config:\n' + return start, config, rest + + +def _add_test_file(): + open('test-file', 'a').close() + cmd_output('git', 'add', '.') + + +def _run_try_repo(tempdir_factory, **kwargs): + repo = make_repo(tempdir_factory, 'modified_file_returns_zero_repo') + with cwd(git_dir(tempdir_factory)): + _add_test_file() + assert not try_repo(try_repo_opts(repo, **kwargs)) + + +def test_try_repo_repo_only(cap_out, tempdir_factory): + with mock.patch.object(time, 'monotonic', return_value=0.0): + _run_try_repo(tempdir_factory, verbose=True) + start, config, rest = _get_out(cap_out) + assert start == '' + config_pattern = re_assert.Matches( + '^repos:\n' + '- repo: .+\n' + ' rev: .+\n' + ' hooks:\n' + ' - id: bash_hook\n' + ' - id: bash_hook2\n' + ' - id: bash_hook3\n$', + ) + config_pattern.assert_matches(config) + assert rest == '''\ +Bash hook............................................(no files to check)Skipped +- hook id: bash_hook +Bash hook................................................................Passed +- hook id: bash_hook2 +- duration: 0s + +test-file + +Bash hook............................................(no files to check)Skipped +- hook id: bash_hook3 +''' + + +def test_try_repo_with_specific_hook(cap_out, tempdir_factory): + _run_try_repo(tempdir_factory, hook='bash_hook', verbose=True) + start, config, rest = _get_out(cap_out) + assert start == '' + config_pattern = re_assert.Matches( + '^repos:\n' + '- repo: .+\n' + ' rev: .+\n' + ' hooks:\n' + ' - id: bash_hook\n$', + ) + config_pattern.assert_matches(config) + assert rest == '''\ +Bash hook............................................(no files to check)Skipped +- hook id: bash_hook +''' + + +def test_try_repo_relative_path(cap_out, tempdir_factory): + repo = make_repo(tempdir_factory, 'modified_file_returns_zero_repo') + with cwd(git_dir(tempdir_factory)): + _add_test_file() + relative_repo = os.path.relpath(repo, '.') + # previously crashed on cloning a relative path + assert not try_repo(try_repo_opts(relative_repo, hook='bash_hook')) + + +def test_try_repo_bare_repo(cap_out, tempdir_factory): + repo = make_repo(tempdir_factory, 'modified_file_returns_zero_repo') + with cwd(git_dir(tempdir_factory)): + _add_test_file() + bare_repo = os.path.join(repo, '.git') + # previously crashed attempting modification changes + assert not try_repo(try_repo_opts(bare_repo, hook='bash_hook')) + + +def test_try_repo_specific_revision(cap_out, tempdir_factory): + repo = make_repo(tempdir_factory, 'script_hooks_repo') + ref = git.head_rev(repo) + git_commit(cwd=repo) + with cwd(git_dir(tempdir_factory)): + _add_test_file() + assert not try_repo(try_repo_opts(repo, ref=ref)) + + _, config, _ = _get_out(cap_out) + assert ref in config + + +def test_try_repo_uncommitted_changes(cap_out, tempdir_factory): + repo = make_repo(tempdir_factory, 'script_hooks_repo') + # make an uncommitted change + with modify_manifest(repo, commit=False) as manifest: + manifest[0]['name'] = 'modified name!' + + with cwd(git_dir(tempdir_factory)): + open('test-fie', 'a').close() + cmd_output('git', 'add', '.') + assert not try_repo(try_repo_opts(repo)) + + start, config, rest = _get_out(cap_out) + assert start == '[WARNING] Creating temporary repo with uncommitted changes...\n' # noqa: E501 + config_pattern = re_assert.Matches( + '^repos:\n' + '- repo: .+shadow-repo\n' + ' rev: .+\n' + ' hooks:\n' + ' - id: bash_hook\n$', + ) + config_pattern.assert_matches(config) + assert rest == 'modified name!...........................................................Passed\n' # noqa: E501 + + +def test_try_repo_staged_changes(tempdir_factory): + repo = make_repo(tempdir_factory, 'modified_file_returns_zero_repo') + + with cwd(repo): + open('staged-file', 'a').close() + open('second-staged-file', 'a').close() + cmd_output('git', 'add', '.') + + with cwd(git_dir(tempdir_factory)): + assert not try_repo(try_repo_opts(repo, hook='bash_hook')) diff --git a/tests/commands/validate_config_test.py b/tests/commands/validate_config_test.py new file mode 100644 index 0000000..a475cd8 --- /dev/null +++ b/tests/commands/validate_config_test.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import logging + +from pre_commit.commands.validate_config import validate_config + + +def test_validate_config_ok(): + assert not validate_config(('.pre-commit-config.yaml',)) + + +def test_validate_warn_on_unknown_keys_at_repo_level(tmpdir, caplog): + f = tmpdir.join('cfg.yaml') + f.write( + 'repos:\n' + '- repo: https://gitlab.com/pycqa/flake8\n' + ' rev: 3.7.7\n' + ' hooks:\n' + ' - id: flake8\n' + ' args: [--some-args]\n', + ) + ret_val = validate_config((f.strpath,)) + assert not ret_val + assert caplog.record_tuples == [ + ( + 'pre_commit', + logging.WARNING, + 'Unexpected key(s) present on https://gitlab.com/pycqa/flake8: ' + 'args', + ), + ] + + +def test_validate_warn_on_unknown_keys_at_top_level(tmpdir, caplog): + f = tmpdir.join('cfg.yaml') + f.write( + 'repos:\n' + '- repo: https://gitlab.com/pycqa/flake8\n' + ' rev: 3.7.7\n' + ' hooks:\n' + ' - id: flake8\n' + 'foo:\n' + ' id: 1.0.0\n', + ) + ret_val = validate_config((f.strpath,)) + assert not ret_val + assert caplog.record_tuples == [ + ( + 'pre_commit', + logging.WARNING, + 'Unexpected key(s) present at root: foo', + ), + ] + + +def test_mains_not_ok(tmpdir): + not_yaml = tmpdir.join('f.notyaml') + not_yaml.write('{') + not_schema = tmpdir.join('notconfig.yaml') + not_schema.write('{}') + + assert validate_config(('does-not-exist',)) + assert validate_config((not_yaml.strpath,)) + assert validate_config((not_schema.strpath,)) diff --git a/tests/commands/validate_manifest_test.py b/tests/commands/validate_manifest_test.py new file mode 100644 index 0000000..a4bc8ac --- /dev/null +++ b/tests/commands/validate_manifest_test.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from pre_commit.commands.validate_manifest import validate_manifest + + +def test_validate_manifest_ok(): + assert not validate_manifest(('.pre-commit-hooks.yaml',)) + + +def test_not_ok(tmpdir): + not_yaml = tmpdir.join('f.notyaml') + not_yaml.write('{') + not_schema = tmpdir.join('notconfig.yaml') + not_schema.write('{}') + + assert validate_manifest(('does-not-exist',)) + assert validate_manifest((not_yaml.strpath,)) + assert validate_manifest((not_schema.strpath,)) -- cgit v1.2.3