From c0d06915b706bf90df47622210caceb7aa86068e Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 9 Feb 2025 21:10:22 +0100 Subject: [PATCH] Adding upstream version 2.2.0. Signed-off-by: Daniel Baumann --- .gitignore | 8 + .pre-commit-config.yaml | 54 + .pre-commit-hooks.yaml | 6 + CHANGELOG.md | 1407 +++++++++++++++++ CONTRIBUTING.md | 146 ++ LICENSE | 19 + README.md | 9 + azure-pipelines.yml | 50 + pre_commit/__init__.py | 0 pre_commit/__main__.py | 5 + pre_commit/clientlib.py | 317 ++++ pre_commit/color.py | 97 ++ pre_commit/commands/__init__.py | 0 pre_commit/commands/autoupdate.py | 182 +++ pre_commit/commands/clean.py | 14 + pre_commit/commands/gc.py | 90 ++ pre_commit/commands/hook_impl.py | 187 +++ pre_commit/commands/init_templatedir.py | 33 + pre_commit/commands/install_uninstall.py | 175 ++ pre_commit/commands/migrate_config.py | 59 + pre_commit/commands/run.py | 360 +++++ pre_commit/commands/sample_config.py | 21 + pre_commit/commands/try_repo.py | 77 + pre_commit/constants.py | 24 + pre_commit/envcontext.py | 67 + pre_commit/error_handler.py | 64 + pre_commit/file_lock.py | 76 + pre_commit/git.py | 196 +++ pre_commit/hook.py | 63 + pre_commit/languages/__init__.py | 0 pre_commit/languages/all.py | 60 + pre_commit/languages/conda.py | 84 + pre_commit/languages/docker.py | 114 ++ pre_commit/languages/docker_image.py | 22 + pre_commit/languages/fail.py | 20 + pre_commit/languages/golang.py | 97 ++ pre_commit/languages/helpers.py | 109 ++ pre_commit/languages/node.py | 93 ++ pre_commit/languages/perl.py | 67 + pre_commit/languages/pygrep.py | 87 + pre_commit/languages/python.py | 210 +++ pre_commit/languages/python_venv.py | 46 + pre_commit/languages/ruby.py | 126 ++ pre_commit/languages/rust.py | 106 ++ pre_commit/languages/script.py | 19 + pre_commit/languages/swift.py | 64 + pre_commit/languages/system.py | 19 + pre_commit/logging_handler.py | 40 + pre_commit/main.py | 410 +++++ pre_commit/make_archives.py | 65 + pre_commit/meta_hooks/__init__.py | 0 pre_commit/meta_hooks/check_hooks_apply.py | 39 + .../meta_hooks/check_useless_excludes.py | 72 + pre_commit/meta_hooks/identity.py | 16 + pre_commit/output.py | 32 + pre_commit/parse_shebang.py | 84 + pre_commit/prefix.py | 17 + pre_commit/repository.py | 208 +++ pre_commit/resources/__init__.py | 0 .../resources/empty_template_.npmignore | 1 + .../resources/empty_template_Cargo.toml | 7 + .../resources/empty_template_Makefile.PL | 6 + .../resources/empty_template_environment.yml | 9 + pre_commit/resources/empty_template_main.go | 3 + pre_commit/resources/empty_template_main.rs | 1 + .../resources/empty_template_package.json | 4 + ..._template_pre_commit_dummy_package.gemspec | 6 + pre_commit/resources/empty_template_setup.py | 4 + pre_commit/resources/hook-tmpl | 44 + pre_commit/resources/rbenv.tar.gz | Bin 0 -> 31781 bytes pre_commit/resources/ruby-build.tar.gz | Bin 0 -> 62567 bytes pre_commit/resources/ruby-download.tar.gz | Bin 0 -> 5343 bytes pre_commit/staged_files_only.py | 90 ++ pre_commit/store.py | 250 +++ pre_commit/util.py | 272 ++++ pre_commit/xargs.py | 157 ++ requirements-dev.txt | 4 + setup.cfg | 70 + setup.py | 2 + testing/__init__.py | 0 testing/auto_namedtuple.py | 11 + testing/fixtures.py | 146 ++ testing/gen-languages-all | 28 + testing/get-swift.sh | 27 + .../.pre-commit-hooks.yaml | 5 + .../resources/arbitrary_bytes_repo/hook.sh | 7 + .../.pre-commit-hooks.yaml | 6 + .../arg_per_line_hooks_repo/bin/hook.sh | 5 + .../conda_hooks_repo/.pre-commit-hooks.yaml | 10 + .../conda_hooks_repo/environment.yml | 6 + .../docker_hooks_repo/.pre-commit-hooks.yaml | 17 + .../resources/docker_hooks_repo/Dockerfile | 3 + .../.pre-commit-hooks.yaml | 8 + .../exclude_types_repo/.pre-commit-hooks.yaml | 6 + .../resources/exclude_types_repo/bin/hook.sh | 3 + .../failing_hook_repo/.pre-commit-hooks.yaml | 5 + .../resources/failing_hook_repo/bin/hook.sh | 4 + .../golang_hooks_repo/.pre-commit-hooks.yaml | 5 + .../golang-hello-world/main.go | 17 + testing/resources/img1.jpg | Bin 0 -> 843 bytes testing/resources/img2.jpg | Bin 0 -> 891 bytes testing/resources/img3.jpg | Bin 0 -> 859 bytes .../logfile_repo/.pre-commit-hooks.yaml | 6 + testing/resources/logfile_repo/bin/hook.sh | 5 + .../.pre-commit-hooks.yaml | 15 + .../bin/hook.sh | 7 + .../bin/hook2.sh | 2 + .../bin/hook3.sh | 6 + .../node_hooks_repo/.pre-commit-hooks.yaml | 5 + testing/resources/node_hooks_repo/bin/main.js | 3 + .../resources/node_hooks_repo/package.json | 5 + .../.pre-commit-hooks.yaml | 6 + .../node_versioned_hooks_repo/bin/main.js | 4 + .../node_versioned_hooks_repo/package.json | 5 + .../not_found_exe/.pre-commit-hooks.yaml | 5 + testing/resources/perl_hooks_repo/.gitignore | 7 + .../perl_hooks_repo/.pre-commit-hooks.yaml | 5 + testing/resources/perl_hooks_repo/MANIFEST | 4 + testing/resources/perl_hooks_repo/Makefile.PL | 10 + .../perl_hooks_repo/bin/pre-commit-perl-hello | 7 + .../perl_hooks_repo/lib/PreCommitHello.pm | 12 + .../prints_cwd_repo/.pre-commit-hooks.yaml | 5 + .../python3_hooks_repo/.pre-commit-hooks.yaml | 6 + .../resources/python3_hooks_repo/py3_hook.py | 8 + testing/resources/python3_hooks_repo/setup.py | 8 + .../python_hooks_repo/.pre-commit-hooks.yaml | 5 + testing/resources/python_hooks_repo/foo.py | 7 + testing/resources/python_hooks_repo/setup.py | 8 + .../.pre-commit-hooks.yaml | 5 + .../resources/python_venv_hooks_repo/foo.py | 7 + .../resources/python_venv_hooks_repo/setup.py | 8 + testing/resources/ruby_hooks_repo/.gitignore | 1 + .../ruby_hooks_repo/.pre-commit-hooks.yaml | 5 + .../resources/ruby_hooks_repo/bin/ruby_hook | 3 + .../resources/ruby_hooks_repo/lib/.gitignore | 0 .../ruby_hooks_repo/ruby_hook.gemspec | 9 + .../ruby_versioned_hooks_repo/.gitignore | 1 + .../.pre-commit-hooks.yaml | 6 + .../ruby_versioned_hooks_repo/bin/ruby_hook | 4 + .../ruby_versioned_hooks_repo/lib/.gitignore | 0 .../ruby_hook.gemspec | 9 + .../rust_hooks_repo/.pre-commit-hooks.yaml | 5 + testing/resources/rust_hooks_repo/Cargo.lock | 3 + testing/resources/rust_hooks_repo/Cargo.toml | 3 + testing/resources/rust_hooks_repo/src/main.rs | 3 + .../script_hooks_repo/.pre-commit-hooks.yaml | 5 + .../resources/script_hooks_repo/bin/hook.sh | 4 + .../stdout_stderr_repo/.pre-commit-hooks.yaml | 8 + .../stdout_stderr_repo/stdout-stderr-entry | 7 + .../stdout_stderr_repo/tty-check-entry | 11 + testing/resources/swift_hooks_repo/.gitignore | 4 + .../swift_hooks_repo/.pre-commit-hooks.yaml | 6 + .../resources/swift_hooks_repo/Package.swift | 7 + .../Sources/swift_hooks_repo/main.swift | 1 + .../.pre-commit-hooks.yaml | 5 + .../types_repo/.pre-commit-hooks.yaml | 5 + testing/resources/types_repo/bin/hook.sh | 3 + testing/util.py | 113 ++ tests/__init__.py | 0 tests/clientlib_test.py | 313 ++++ tests/color_test.py | 59 + tests/commands/__init__.py | 0 tests/commands/autoupdate_test.py | 437 +++++ tests/commands/clean_test.py | 33 + tests/commands/gc_test.py | 161 ++ tests/commands/hook_impl_test.py | 235 +++ tests/commands/init_templatedir_test.py | 92 ++ tests/commands/install_uninstall_test.py | 901 +++++++++++ tests/commands/migrate_config_test.py | 156 ++ tests/commands/run_test.py | 1012 ++++++++++++ tests/commands/sample_config_test.py | 19 + tests/commands/try_repo_test.py | 151 ++ tests/conftest.py | 277 ++++ tests/envcontext_test.py | 101 ++ tests/error_handler_test.py | 170 ++ tests/git_test.py | 188 +++ tests/languages/__init__.py | 0 tests/languages/docker_test.py | 23 + tests/languages/golang_test.py | 20 + tests/languages/helpers_test.py | 82 + tests/languages/pygrep_test.py | 65 + tests/languages/python_test.py | 75 + tests/languages/ruby_test.py | 28 + tests/logging_handler_test.py | 21 + tests/main_test.py | 189 +++ tests/make_archives_test.py | 46 + tests/meta_hooks/__init__.py | 0 tests/meta_hooks/check_hooks_apply_test.py | 138 ++ .../meta_hooks/check_useless_excludes_test.py | 115 ++ tests/meta_hooks/identity_test.py | 6 + tests/output_test.py | 9 + tests/parse_shebang_test.py | 152 ++ tests/prefix_test.py | 44 + tests/repository_test.py | 902 +++++++++++ tests/staged_files_only_test.py | 349 ++++ tests/store_test.py | 216 +++ tests/util_test.py | 122 ++ tests/xargs_test.py | 197 +++ tox.ini | 28 + 199 files changed, 14930 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .pre-commit-hooks.yaml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 azure-pipelines.yml create mode 100644 pre_commit/__init__.py create mode 100644 pre_commit/__main__.py create mode 100644 pre_commit/clientlib.py create mode 100644 pre_commit/color.py create mode 100644 pre_commit/commands/__init__.py create mode 100644 pre_commit/commands/autoupdate.py create mode 100644 pre_commit/commands/clean.py create mode 100644 pre_commit/commands/gc.py create mode 100644 pre_commit/commands/hook_impl.py create mode 100644 pre_commit/commands/init_templatedir.py create mode 100644 pre_commit/commands/install_uninstall.py create mode 100644 pre_commit/commands/migrate_config.py create mode 100644 pre_commit/commands/run.py create mode 100644 pre_commit/commands/sample_config.py create mode 100644 pre_commit/commands/try_repo.py create mode 100644 pre_commit/constants.py create mode 100644 pre_commit/envcontext.py create mode 100644 pre_commit/error_handler.py create mode 100644 pre_commit/file_lock.py create mode 100644 pre_commit/git.py create mode 100644 pre_commit/hook.py create mode 100644 pre_commit/languages/__init__.py create mode 100644 pre_commit/languages/all.py create mode 100644 pre_commit/languages/conda.py create mode 100644 pre_commit/languages/docker.py create mode 100644 pre_commit/languages/docker_image.py create mode 100644 pre_commit/languages/fail.py create mode 100644 pre_commit/languages/golang.py create mode 100644 pre_commit/languages/helpers.py create mode 100644 pre_commit/languages/node.py create mode 100644 pre_commit/languages/perl.py create mode 100644 pre_commit/languages/pygrep.py create mode 100644 pre_commit/languages/python.py create mode 100644 pre_commit/languages/python_venv.py create mode 100644 pre_commit/languages/ruby.py create mode 100644 pre_commit/languages/rust.py create mode 100644 pre_commit/languages/script.py create mode 100644 pre_commit/languages/swift.py create mode 100644 pre_commit/languages/system.py create mode 100644 pre_commit/logging_handler.py create mode 100644 pre_commit/main.py create mode 100644 pre_commit/make_archives.py create mode 100644 pre_commit/meta_hooks/__init__.py create mode 100644 pre_commit/meta_hooks/check_hooks_apply.py create mode 100644 pre_commit/meta_hooks/check_useless_excludes.py create mode 100644 pre_commit/meta_hooks/identity.py create mode 100644 pre_commit/output.py create mode 100644 pre_commit/parse_shebang.py create mode 100644 pre_commit/prefix.py create mode 100644 pre_commit/repository.py create mode 100644 pre_commit/resources/__init__.py create mode 100644 pre_commit/resources/empty_template_.npmignore create mode 100644 pre_commit/resources/empty_template_Cargo.toml create mode 100644 pre_commit/resources/empty_template_Makefile.PL create mode 100644 pre_commit/resources/empty_template_environment.yml create mode 100644 pre_commit/resources/empty_template_main.go create mode 100644 pre_commit/resources/empty_template_main.rs create mode 100644 pre_commit/resources/empty_template_package.json create mode 100644 pre_commit/resources/empty_template_pre_commit_dummy_package.gemspec create mode 100644 pre_commit/resources/empty_template_setup.py create mode 100755 pre_commit/resources/hook-tmpl create mode 100644 pre_commit/resources/rbenv.tar.gz create mode 100644 pre_commit/resources/ruby-build.tar.gz create mode 100644 pre_commit/resources/ruby-download.tar.gz create mode 100644 pre_commit/staged_files_only.py create mode 100644 pre_commit/store.py create mode 100644 pre_commit/util.py create mode 100644 pre_commit/xargs.py create mode 100644 requirements-dev.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 testing/__init__.py create mode 100644 testing/auto_namedtuple.py create mode 100644 testing/fixtures.py create mode 100755 testing/gen-languages-all create mode 100755 testing/get-swift.sh create mode 100644 testing/resources/arbitrary_bytes_repo/.pre-commit-hooks.yaml create mode 100755 testing/resources/arbitrary_bytes_repo/hook.sh create mode 100644 testing/resources/arg_per_line_hooks_repo/.pre-commit-hooks.yaml create mode 100755 testing/resources/arg_per_line_hooks_repo/bin/hook.sh create mode 100644 testing/resources/conda_hooks_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/conda_hooks_repo/environment.yml create mode 100644 testing/resources/docker_hooks_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/docker_hooks_repo/Dockerfile create mode 100644 testing/resources/docker_image_hooks_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/exclude_types_repo/.pre-commit-hooks.yaml create mode 100755 testing/resources/exclude_types_repo/bin/hook.sh create mode 100644 testing/resources/failing_hook_repo/.pre-commit-hooks.yaml create mode 100755 testing/resources/failing_hook_repo/bin/hook.sh create mode 100644 testing/resources/golang_hooks_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/golang_hooks_repo/golang-hello-world/main.go create mode 100644 testing/resources/img1.jpg create mode 100644 testing/resources/img2.jpg create mode 100644 testing/resources/img3.jpg create mode 100644 testing/resources/logfile_repo/.pre-commit-hooks.yaml create mode 100755 testing/resources/logfile_repo/bin/hook.sh create mode 100644 testing/resources/modified_file_returns_zero_repo/.pre-commit-hooks.yaml create mode 100755 testing/resources/modified_file_returns_zero_repo/bin/hook.sh create mode 100755 testing/resources/modified_file_returns_zero_repo/bin/hook2.sh create mode 100755 testing/resources/modified_file_returns_zero_repo/bin/hook3.sh create mode 100644 testing/resources/node_hooks_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/node_hooks_repo/bin/main.js create mode 100644 testing/resources/node_hooks_repo/package.json create mode 100644 testing/resources/node_versioned_hooks_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/node_versioned_hooks_repo/bin/main.js create mode 100644 testing/resources/node_versioned_hooks_repo/package.json create mode 100644 testing/resources/not_found_exe/.pre-commit-hooks.yaml create mode 100644 testing/resources/perl_hooks_repo/.gitignore create mode 100644 testing/resources/perl_hooks_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/perl_hooks_repo/MANIFEST create mode 100644 testing/resources/perl_hooks_repo/Makefile.PL create mode 100755 testing/resources/perl_hooks_repo/bin/pre-commit-perl-hello create mode 100644 testing/resources/perl_hooks_repo/lib/PreCommitHello.pm create mode 100644 testing/resources/prints_cwd_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/python3_hooks_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/python3_hooks_repo/py3_hook.py create mode 100644 testing/resources/python3_hooks_repo/setup.py create mode 100644 testing/resources/python_hooks_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/python_hooks_repo/foo.py create mode 100644 testing/resources/python_hooks_repo/setup.py create mode 100644 testing/resources/python_venv_hooks_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/python_venv_hooks_repo/foo.py create mode 100644 testing/resources/python_venv_hooks_repo/setup.py create mode 100644 testing/resources/ruby_hooks_repo/.gitignore create mode 100644 testing/resources/ruby_hooks_repo/.pre-commit-hooks.yaml create mode 100755 testing/resources/ruby_hooks_repo/bin/ruby_hook create mode 100644 testing/resources/ruby_hooks_repo/lib/.gitignore create mode 100644 testing/resources/ruby_hooks_repo/ruby_hook.gemspec create mode 100644 testing/resources/ruby_versioned_hooks_repo/.gitignore create mode 100644 testing/resources/ruby_versioned_hooks_repo/.pre-commit-hooks.yaml create mode 100755 testing/resources/ruby_versioned_hooks_repo/bin/ruby_hook create mode 100644 testing/resources/ruby_versioned_hooks_repo/lib/.gitignore create mode 100644 testing/resources/ruby_versioned_hooks_repo/ruby_hook.gemspec create mode 100644 testing/resources/rust_hooks_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/rust_hooks_repo/Cargo.lock create mode 100644 testing/resources/rust_hooks_repo/Cargo.toml create mode 100644 testing/resources/rust_hooks_repo/src/main.rs create mode 100644 testing/resources/script_hooks_repo/.pre-commit-hooks.yaml create mode 100755 testing/resources/script_hooks_repo/bin/hook.sh create mode 100644 testing/resources/stdout_stderr_repo/.pre-commit-hooks.yaml create mode 100755 testing/resources/stdout_stderr_repo/stdout-stderr-entry create mode 100755 testing/resources/stdout_stderr_repo/tty-check-entry create mode 100644 testing/resources/swift_hooks_repo/.gitignore create mode 100644 testing/resources/swift_hooks_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/swift_hooks_repo/Package.swift create mode 100644 testing/resources/swift_hooks_repo/Sources/swift_hooks_repo/main.swift create mode 100644 testing/resources/system_hook_with_spaces_repo/.pre-commit-hooks.yaml create mode 100644 testing/resources/types_repo/.pre-commit-hooks.yaml create mode 100755 testing/resources/types_repo/bin/hook.sh create mode 100644 testing/util.py create mode 100644 tests/__init__.py create mode 100644 tests/clientlib_test.py create mode 100644 tests/color_test.py 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/conftest.py create mode 100644 tests/envcontext_test.py create mode 100644 tests/error_handler_test.py create mode 100644 tests/git_test.py create mode 100644 tests/languages/__init__.py create mode 100644 tests/languages/docker_test.py create mode 100644 tests/languages/golang_test.py create mode 100644 tests/languages/helpers_test.py create mode 100644 tests/languages/pygrep_test.py create mode 100644 tests/languages/python_test.py create mode 100644 tests/languages/ruby_test.py create mode 100644 tests/logging_handler_test.py create mode 100644 tests/main_test.py create mode 100644 tests/make_archives_test.py create mode 100644 tests/meta_hooks/__init__.py create mode 100644 tests/meta_hooks/check_hooks_apply_test.py create mode 100644 tests/meta_hooks/check_useless_excludes_test.py create mode 100644 tests/meta_hooks/identity_test.py create mode 100644 tests/output_test.py create mode 100644 tests/parse_shebang_test.py create mode 100644 tests/prefix_test.py create mode 100644 tests/repository_test.py create mode 100644 tests/staged_files_only_test.py create mode 100644 tests/store_test.py create mode 100644 tests/util_test.py create mode 100644 tests/xargs_test.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5428b0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.egg-info +*.py[co] +/.coverage +/.mypy_cache +/.pytest_cache +/.tox +/dist +/venv* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c2df486 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,54 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-docstring-first + - id: check-json + - id: check-yaml + - id: debug-statements + - id: name-tests-test + - id: requirements-txt-fixer + - id: double-quote-string-fixer +- repo: https://gitlab.com/pycqa/flake8 + rev: 3.7.9 + hooks: + - id: flake8 + additional_dependencies: [flake8-typing-imports==1.6.0] +- repo: https://github.com/pre-commit/mirrors-autopep8 + rev: v1.5 + hooks: + - id: autopep8 +- repo: https://github.com/pre-commit/pre-commit + rev: v2.1.1 + hooks: + - id: validate_manifest +- repo: https://github.com/asottile/pyupgrade + rev: v2.0.1 + hooks: + - id: pyupgrade + args: [--py36-plus] +- repo: https://github.com/asottile/reorder_python_imports + rev: v1.9.0 + hooks: + - id: reorder-python-imports + args: [--py3-plus] +- repo: https://github.com/asottile/add-trailing-comma + rev: v1.5.0 + hooks: + - id: add-trailing-comma + args: [--py36-plus] +- repo: https://github.com/asottile/setup-cfg-fmt + rev: v1.6.0 + hooks: + - id: setup-cfg-fmt +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.761 + hooks: + - id: mypy + exclude: ^testing/resources/ +- repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..ef269d1 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: validate_manifest + name: Validate Pre-Commit Manifest + description: This validator validates a pre-commit hooks manifest file + entry: pre-commit-validate-manifest + language: python + files: ^(\.pre-commit-hooks\.yaml|hooks\.yaml)$ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9a6892c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1407 @@ +2.2.0 - 2020-03-12 +================== + +### Features +- Add support for the `post-checkout` hook + - #1210 issue by @domenkozar. + - #1339 PR by @andrewhare. +- Add more readable `--from-ref` / `--to-ref` aliases for `--source` / + `--origin` + - #1343 PR by @asottile. + +### Fixes +- Make sure that `--commit-msg-filename` is passed for `commit-msg` / + `prepare-commit-msg`. + - #1336 PR by @particledecay. + - #1341 PR by @particledecay. +- Fix crash when installation error is un-decodable bytes + - #1358 issue by @Guts. + - #1359 PR by @asottile. +- Fix python `healthy()` check when `python` executable goes missing. + - #1363 PR by @asottile. +- Fix crash when script executables are missing shebangs. + - #1350 issue by @chriselion. + - #1364 PR by @asottile. + +### Misc. +- pre-commit now requires python>=3.6.1 (previously 3.6.0) + - #1346 PR by @asottile. + +2.1.1 - 2020-02-24 +================== + +### Fixes +- Temporarily restore python 3.6.0 support (broken in 2.0.0) + - reported by @obestwalter. + - 081f3028 by @asottile. + +2.1.0 - 2020-02-18 +================== + +### Features +- Replace `aspy.yaml` with `sort_keys=False`. + - #1306 PR by @asottile. +- Add support for `perl`. + - #1303 PR by @scop. + +### Fixes +- Improve `.git/hooks/*` shebang creation when pythons are in `/usr/local/bin`. + - #1312 issue by @kbsezginel. + - #1319 PR by @asottile. + +### Misc. +- Add repository badge for pre-commit. + - [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) + - #1334 PR by @ddelange. + +2.0.1 - 2020-01-29 +================== + +### Fixes +- Fix `ImportError` in python 3.6.0 / 3.6.1 for `typing.NoReturn`. + - #1302 PR by @asottile. + +2.0.0 - 2020-01-28 +================== + +### Features +- Expose `PRE_COMMIT_REMOTE_NAME` and `PRE_COMMIT_REMOTE_URL` as environment + variables during `pre-push` hooks. + - #1274 issue by @dmbarreiro. + - #1288 PR by @dmbarreiro. + +### Fixes +- Fix `python -m pre_commit --version` to mention `pre-commit` instead of + `__main__.py`. + - #1273 issue by @ssbarnea. + - #1276 PR by @orcutt989. +- Don't filter `GIT_SSL_NO_VERIFY` from environment when cloning. + - #1293 PR by @schiermike. +- Allow `pre-commit init-templatedir` to succeed even if `core.hooksPath` is + set. + - #1298 issue by @damienrj. + - #1299 PR by @asottile. + +### Misc +- Fix changelog date for 1.21.0. + - #1275 PR by @flaudisio. + +### Updating +- Removed `pcre` language, use `pygrep` instead. + - #1268 PR by @asottile. +- Removed `--tags-only` argument to `pre-commit autoupdate` (it has done + nothing since 0.14.0). + - #1269 by @asottile. +- Remove python2 / python3.5 support. Note that pre-commit still supports + running hooks written in python2, but pre-commit itself requires python 3.6+. + - #1260 issue by @asottile. + - #1277 PR by @asottile. + - #1281 PR by @asottile. + - #1282 PR by @asottile. + - #1287 PR by @asottile. + - #1289 PR by @asottile. + - #1292 PR by @asottile. + +1.21.0 - 2020-01-02 +=================== + +### Features +- Add `conda` as a new `language`. + - #1204 issue by @xhochy. + - #1232 PR by @xhochy. +- Add top-level configuration `files` for file selection. + - #1220 issue by @TheButlah. + - #1248 PR by @asottile. +- Rework `--verbose` / `verbose` to be more consistent with normal runs. + - #1249 PR by @asottile. +- Add support for the `pre-merge-commit` git hook. + - #1210 PR by @asottile. + - this requires git 2.24+. +- Add `pre-commit autoupdate --freeze` which produces "frozen" revisions. + - #1068 issue by @SkypLabs. + - #1256 PR by @asottile. +- Display hook runtime duration when run with `--verbose`. + - #1144 issue by @potiuk. + - #1257 PR by @asottile. + +### Fixes +- Produce better error message when erroneously running inside of `.git`. + - #1219 issue by @Nusserdt. + - #1224 PR by @asottile. + - Note: `git` has since fixed this bug: git/git@36fd304d +- Produce better error message when hook installation fails. + - #1250 issue by @asottile. + - #1251 PR by @asottile. +- Fix cloning when `GIT_SSL_CAINFO` is necessary. + - #1253 issue by @igankevich. + - #1254 PR by @igankevich. +- Fix `pre-commit try-repo` for bare, on-disk repositories. + - #1258 issue by @webknjaz. + - #1259 PR by @asottile. +- Add some whitespace to `pre-commit autoupdate` to improve terminal autolink. + - #1261 issue by @yhoiseth. + - #1262 PR by @yhoiseth. + +### Misc. +- Minor code documentation updates. + - #1200 PR by @ryanrhee. + - #1201 PR by @ryanrhee. + +1.20.0 - 2019-10-28 +=================== + +### Features +- Allow building newer versions of `ruby`. + - #1193 issue by @choffee. + - #1195 PR by @choffee. +- Bump versions reported in `pre-commit sample-config`. + - #1197 PR by @asottile. + +### Fixes +- Fix rare race condition with multiple concurrent first-time runs. + - #1192 issue by @raholler. + - #1196 PR by @asottile. + +1.19.0 - 2019-10-26 +=================== + +### Features +- Allow `--hook-type` to be specified multiple times. + - example: `pre-commit install --hook-type pre-commit --hook-type pre-push` + - #1139 issue by @MaxymVlasov. + - #1145 PR by @asottile. +- Include more version information in crash logs. + - #1142 by @marqueewinq. +- Hook colors are now passed through on platforms which support `pty`. + - #1169 by @asottile. +- pre-commit now uses `importlib.metadata` directly when running in python 3.8 + - #1176 by @asottile. +- Normalize paths to forward slash separators on windows. + - makes it easier to match paths with `files:` regex + - avoids some quoting bugs in shell-based hooks + - #1173 issue by @steigenTI. + - #1179 PR by @asottile. + +### Fixes +- Remove some extra newlines from error messages. + - #1148 by @asottile. +- When a hook is not executable it now reports `not executable` instead of + `not found`. + - #1159 issue by @nixjdm. + - #1161 PR by @WillKoehrsen. +- Fix interleaving of stdout / stderr in hooks. + - #1168 by @asottile. +- Fix python environment `healthy()` check when current working directory + contains modules which shadow standard library names. + - issue by @vwhsu92. + - #1185 PR by @asottile. + +### Updating +- Regexes handling both backslashes and forward slashes for directory + separators now only need to handle forward slashes. + +1.18.3 - 2019-08-27 +=================== + +### Fixes +- Fix `node_modules` plugin installation on windows + - #1123 issue by @henryykt. + - #1122 PR by @henryykt. + +1.18.2 - 2019-08-15 +=================== + +### Fixes +- Make default python lookup more deterministic to avoid redundant installs + - #1117 PR by @scop. + +1.18.1 - 2019-08-11 +=================== + +### Fixes +- Fix installation of `rust` hooks with new `cargo` + - #1112 issue by @zimbatm. + - #1113 PR by @zimbatm. + +1.18.0 - 2019-08-03 +=================== + +### Features +- Use the current running executable if it matches the requested + `language_version` + - #1062 PR by @asottile. +- Print the stage when a hook is not found + - #1078 issue by @madkinsz. + - #1079 PR by @madkinsz. +- `pre-commit autoupdate` now supports non-`master` default branches + - #1089 PR by @asottile. +- Add `pre-commit init-templatedir` which makes it easier to automatically + enable `pre-commit` in cloned repositories. + - #1084 issue by @ssbarnea. + - #1090 PR by @asottile. + - #1107 PR by @asottile. +- pre-commit's color can be controlled using + `PRE_COMMIT_COLOR={auto,always,never}` + - #1073 issue by @saper. + - #1092 PR by @geieredgar. + - #1098 PR by @geieredgar. +- pre-commit's color can now be disabled using `TERM=dumb` + - #1073 issue by @saper. + - #1103 PR by @asottile. +- pre-commit now supports `docker` based hooks on windows + - #1072 by @cz-fish. + - #1093 PR by @geieredgar. + +### Fixes +- Fix shallow clone + - #1077 PR by @asottile. +- Fix autoupdate version flip flop when using shallow cloning + - #1076 issue by @mxr. + - #1088 PR by @asottile. +- Fix autoupdate when the current revision is invalid + - #1088 PR by @asottile. + +### Misc. +- Replace development instructions with `tox --devenv ...` + - #1032 issue by @yoavcaspi. + - #1067 PR by @asottile. + + +1.17.0 - 2019-06-06 +=================== + +### Features +- Produce better output on `^C` + - #1030 PR by @asottile. +- Warn on unknown keys at the top level and repo level + - #1028 PR by @yoavcaspi. + - #1048 PR by @asottile. + +### Fixes +- Fix handling of `^C` in wrapper script in python 3.x + - #1027 PR by @asottile. +- Fix `rmtree` for non-writable directories + - #1042 issue by @detailyang. + - #1043 PR by @asottile. +- Pass `--color` option to `git diff` in `--show-diff-on-failure` + - #1007 issue by @chadrik. + - #1051 PR by @mandarvaze. + +### Misc. +- Fix test when `pre-commit` is installed globally + - #1032 issue by @yoavcaspi. + - #1045 PR by @asottile. + + +1.16.1 - 2019-05-08 +=================== + +### Fixes +- Don't `UnicodeDecodeError` on unexpected non-UTF8 output in python health + check on windows. + - #1021 issue by @nicoddemus. + - #1022 PR by @asottile. + +1.16.0 - 2019-05-04 +=================== + +### Features +- Add support for `prepare-commit-msg` hook + - #1004 PR by @marcjay. + +### Fixes +- Fix repeated legacy `pre-commit install` on windows + - #1010 issue by @AbhimanyuHK. + - #1011 PR by @asottile. +- Whitespace fixup + - #1014 PR by @mxr. +- Fix CI check for working pcre support + - #1015 PR by @Myrheimb. + +### Misc. +- Switch CI from travis / appveyor to azure pipelines + - #1012 PR by @asottile. + +1.15.2 - 2019-04-16 +=================== + +### Fixes +- Fix cloning non-branch tag while in the fallback slow-clone strategy. + - #997 issue by @jpinner. + - #998 PR by @asottile. + +1.15.1 - 2019-04-01 +=================== + +### Fixes +- Fix command length calculation on posix when `SC_ARG_MAX` is not defined. + - #691 issue by @ushuz. + - #987 PR by @asottile. + +1.15.0 - 2019-03-30 +=================== + +### Features +- No longer require being in a `git` repo to run `pre-commit` `clean` / `gc` / + `sample-config`. + - #959 PR by @asottile. +- Improve command line length limit detection. + - #691 issue by @antonbabenko. + - #966 PR by @asottile. +- Use shallow cloning when possible. + - #958 PR by @DanielChabrowski. +- Add `minimum_pre_commit_version` top level key to require a new-enough + version of `pre-commit`. + - #977 PR by @asottile. +- Add helpful CI-friendly message when running + `pre-commit run --all-files --show-diff-on-failure`. + - #982 PR by @bnorquist. + +### Fixes +- Fix `try-repo` for staged untracked changes. + - #973 PR by @DanielChabrowski. +- Fix rpm build by explicitly using `#!/usr/bin/env python3` in hook template. + - #985 issue by @tim77. + - #986 PR by @tim77. +- Guard against infinite recursion when executing legacy hook script. + - #981 PR by @tristan0x. + +### Misc +- Add test for `git.no_git_env()` + - #972 PR by @javabrett. + +1.14.4 - 2019-02-18 +=================== + +### Fixes +- Don't filter `GIT_SSH_COMMAND` env variable from `git` commands + - #947 issue by @firba1. + - #948 PR by @firba1. +- Install npm packages as if they were installed from `git` + - #943 issue by @ssbarnea. + - #949 PR by @asottile. +- Don't filter `GIT_EXEC_PREFIX` env variable from `git` commands + - #664 issue by @revolter. + - #944 PR by @minrk. + +1.14.3 - 2019-02-04 +=================== + +### Fixes +- Improve performance of filename classification by 45% - 55%. + - #921 PR by @asottile. +- Fix installing `go` hooks while `GOBIN` environment variable is set. + - #924 PR by @ashanbrown. +- Fix crash while running `pre-commit migrate-config` / `pre-commit autoupdate` + with an empty configuration file. + - #929 issue by @ardakuyumcu. + - #933 PR by @jessebona. +- Require a newer virtualenv to fix metadata-based setup.cfg installs. + - #936 PR by @asottile. + +1.14.2 - 2019-01-10 +=================== + +### Fixes +- Make the hook shebang detection more timid (1.14.0 regression) + - Homebrew/homebrew-core#35825. + - #915 PR by @asottile. + +1.14.1 - 2019-01-10 +=================== + +### Fixes +- Fix python executable lookup on windows when using conda + - #913 issue by @dawelter2. + - #914 PR by @asottile. + +1.14.0 - 2019-01-08 +=================== + +### Features +- Add an `alias` configuration value to allow repeated hooks to be + differentiated + - #882 issue by @s0undt3ch. + - #886 PR by @s0undt3ch. +- Add `identity` meta hook which just prints filenames + - #865 issue by @asottile. + - #898 PR by @asottile. +- Factor out `cached-property` and improve startup performance by ~10% + - #899 PR by @asottile. +- Add a warning on unexpected keys in configuration + - #899 PR by @asottile. +- Teach `pre-commit try-repo` to clone uncommitted changes on disk. + - #589 issue by @sverhagen. + - #703 issue by @asottile. + - #904 PR by @asottile. +- Implement `pre-commit gc` which will clean up no-longer-referenced cache + repos. + - #283 issue by @jtwang. + - #906 PR by @asottile. +- Add top level config `default_language_version` to streamline overriding the + `language_version` configuration in many places + - #647 issue by @asottile. + - #908 PR by @asottile. +- Add top level config `default_stages` to streamline overriding the `stages` + configuration in many places + - #768 issue by @mattlqx. + - #909 PR by @asottile. + +### Fixes +- More intelligently pick hook shebang (`#!/usr/bin/env python3`) + - #878 issue by @fristedt. + - #893 PR by @asottile. +- Several fixes related to `--files` / `--config`: + - `pre-commit run --files x` outside of a git dir no longer stacktraces + - `pre-commit run --config ./relative` while in a sub directory of the git + repo is now able to find the configuration + - `pre-commit run --files ...` no longer runs a subprocess per file + (performance) + - #895 PR by @asottile. +- `pre-commit try-repo ./relative` while in a sub directory of the git repo is + now able to clone properly + - #903 PR by @asottile. +- Ensure `meta` repos cannot have a language other than `system` + - #905 issue by @asottile. + - #907 PR by @asottile. +- Fix committing with unstaged files that were `git add --intent-to-add` added + - #881 issue by @henniss. + - #912 PR by @asottile. + +### Misc. +- Use `--no-gpg-sign` when running tests + - #894 PR by @s0undt3ch. + + +1.13.0 - 2018-12-20 +=================== + +### Features +- Run hooks in parallel + - individual hooks may opt out of parallel execution with `require_serial: true` + - #510 issue by @chriskuehl. + - #851 PR by @chriskuehl. + +### Fixes +- Improve platform-specific `xargs` command length detection + - #691 issue by @antonbabenko. + - #839 PR by @georgeyk. +- Fix `pre-commit autoupdate` when updating to a latest tag missing a + `.pre-commit-hooks.yaml` + - #856 issue by @asottile. + - #857 PR by @runz0rd. +- Upgrade the `pre-commit-hooks` version in `pre-commit sample-config` + - #870 by @asottile. +- Improve balancing of multiprocessing by deterministic shuffling of args + - #861 issue by @Dunedan. + - #874 PR by @chriskuehl. +- `ruby` hooks work with latest `gem` by removing `--no-ri` / `--no-rdoc` and + instead using `--no-document`. + - #889 PR by @asottile. + +### Misc. +- Use `--no-gpg-sign` when running tests + - #885 PR by @s0undt3ch. + +### Updating +- If a hook requires serial execution, set `require_serial: true` to avoid the new + parallel execution. +- `ruby` hooks now require `gem>=2.0.0`. If your platform doesn't support this + by default, select a newer version using + [`language_version`](https://pre-commit.com/#overriding-language-version). + + +1.12.0 - 2018-10-23 +=================== + +### Fixes +- Install multi-hook repositories only once (performance) + - issue by @chriskuehl. + - #852 PR by @asottile. +- Improve performance by factoring out pkg_resources (performance) + - #840 issue by @RonnyPfannschmidt. + - #846 PR by @asottile. + +1.11.2 - 2018-10-10 +=================== + +### Fixes +- `check-useless-exclude` now considers `types` + - #704 issue by @asottile. + - #837 PR by @georgeyk. +- `pre-push` hook was not identifying all commits on push to new branch + - #843 issue by @prem-nuro. + - #844 PR by @asottile. + +1.11.1 - 2018-09-22 +=================== + +### Fixes +- Fix `.git` dir detection in `git<2.5` (regression introduced in + [1.10.5](#1105)) + - #831 issue by @mmacpherson. + - #832 PR by @asottile. + +1.11.0 - 2018-09-02 +=================== + +### Features +- Add new `fail` language which always fails + - light-weight way to forbid files by name. + - #812 #821 PRs by @asottile. + +### Fixes +- Fix `ResourceWarning`s for unclosed files + - #811 PR by @BoboTiG. +- Don't write ANSI colors on windows when color enabling fails + - #819 PR by @jeffreyrack. + +1.10.5 - 2018-08-06 +=================== + +### Fixes +- Work around `PATH` issue with `brew` `python` on `macos` + - Homebrew/homebrew-core#30445 issue by @asottile. + - #805 PR by @asottile. +- Support `pre-commit install` inside a worktree + - #808 issue by @s0undt3ch. + - #809 PR by @asottile. + +1.10.4 - 2018-07-22 +=================== + +### Fixes +- Replace `yaml.load` with safe alternative + - `yaml.load` can lead to arbitrary code execution, though not where it + was used + - issue by @tonybaloney. + - #779 PR by @asottile. +- Improve not found error with script paths (`./exe`) + - #782 issue by @ssbarnea. + - #785 PR by @asottile. +- Fix minor buffering issue during `--show-diff-on-failure` + - #796 PR by @asottile. +- Default `language_version: python3` for `python_venv` when running in python2 + - #794 issue by @ssbarnea. + - #797 PR by @asottile. +- `pre-commit run X` only run `X` and not hooks with `stages: [...]` + - #772 issue by @asottile. + - #803 PR by @mblayman. + +### Misc. +- Improve travis-ci build times by caching rust / swift artifacts + - #781 PR by @expobrain. +- Test against python3.7 + - #789 PR by @expobrain. + +1.10.3 - 2018-07-02 +=================== + +### Fixes +- Fix `pre-push` during a force push without a fetch + - #777 issue by @domenkozar. + - #778 PR by @asottile. + +1.10.2 - 2018-06-11 +=================== + +### Fixes +- pre-commit now invokes hooks with a consistent ordering of filenames + - issue by @mxr. + - #767 PR by @asottile. + +1.10.1 - 2018-05-28 +=================== + +### Fixes +- `python_venv` language would leak dependencies when pre-commit was installed + in a `-mvirtualenv` virtualenv + - #755 #756 issue and PR by @asottile. + +1.10.0 - 2018-05-26 +=================== + +### Features +- Add support for hooks written in `rust` + - #751 PR by @chriskuehl. + +1.9.0 - 2018-05-21 +================== + +### Features +- Add new `python_venv` language which uses the `venv` module instead of + `virtualenv` + - #631 issue by @dongyuzheng. + - #739 PR by @ojii. +- Include `LICENSE` in distribution + - #745 issue by @nicoddemus. + - #746 PR by @nicoddemus. + +### Fixes +- Normalize relative paths for `pre-commit try-repo` + - #750 PR by @asottile. + + +1.8.2 - 2018-03-17 +================== + +### Fixes +- Fix cloning relative paths (regression in 1.7.0) + - #728 issue by @jdswensen. + - #729 PR by @asottile. + + +1.8.1 - 2018-03-12 +================== + +### Fixes +- Fix integration with go 1.10 and `pkg` directory + - #725 PR by @asottile +- Restore support for `git<1.8.5` (inadvertently removed in 1.7.0) + - #723 issue by @JohnLyman. + - #724 PR by @asottile. + + +1.8.0 - 2018-03-11 +================== + +### Features +- Add a `manual` stage for cli-only interaction + - #719 issue by @hectorv. + - #720 PR by @asottile. +- Add a `--multiline` option to `pygrep` hooks + - #716 PR by @tdeo. + + +1.7.0 - 2018-03-03 +================== + +### Features +- pre-commit config validation was split to a separate `cfgv` library + - #700 PR by @asottile. +- Allow `--repo` to be specified multiple times to autoupdate + - #658 issue by @KevinHock. + - #713 PR by @asottile. +- Enable `rev` as a preferred alternative to `sha` in `.pre-commit-config.yaml` + - #106 issue by @asottile. + - #715 PR by @asottile. +- Use `--clean-src` option when invoking `nodeenv` to save ~70MB per node env + - #717 PR by @asottile. +- Refuse to install with `core.hooksPath` set + - pre-commit/pre-commit-hooks#250 issue by @revolter. + - #663 issue by @asottile. + - #718 PR by @asottile. + +### Fixes +- hooks with `additional_dependencies` now get isolated environments + - #590 issue by @coldnight. + - #711 PR by @asottile. + +### Misc. +- test against swift 4.x + - #709 by @theresama. + +### Updating + +- Run `pre-commit migrate-config` to convert `sha` to `rev` in the + `.pre-commit-config.yaml` file. + + +1.6.0 - 2018-02-04 +================== + +### Features +- Hooks now may have a `verbose` option to produce output even without failure + - #689 issue by @bagerard. + - #695 PR by @bagerard. +- Installed hook no longer requires `bash` + - #699 PR by @asottile. + +### Fixes +- legacy pre-push / commit-msg hooks are now invoked as if `git` called them + - #693 issue by @samskiter. + - #694 PR by @asottile. + - #699 PR by @asottile. + +1.5.1 - 2018-01-24 +================== + +### Fixes +- proper detection for root commit during pre-push + - #503 PR by @philipgian. + - #692 PR by @samskiter. + +1.5.0 - 2018-01-13 +================== + +### Features +- pre-commit now supports node hooks on windows. + - for now, requires python3 due to https://bugs.python.org/issue32539 + - huge thanks to @wenzowski for the tip! + - #200 issue by @asottile. + - #685 PR by @asottile. + +### Misc. +- internal reorganization of `PrefixedCommandRunner` -> `Prefix` + - #684 PR by @asottile. +- https-ify links. + - pre-commit.com is now served over https. + - #688 PR by @asottile. + + +1.4.5 - 2018-01-09 +================== + +### Fixes +- Fix `local` golang repositories with `additional_dependencies`. + - #679 #680 issue and PR by @asottile. + +### Misc. +- Replace some string literals with constants + - #678 PR by @revolter. + +1.4.4 - 2018-01-07 +================== + +### Fixes +- Invoke `git diff` without a pager during `--show-diff-on-failure`. + - #676 PR by @asottile. + +1.4.3 - 2018-01-02 +================== + +### Fixes +- `pre-commit` on windows can find pythons at non-hardcoded paths. + - #674 PR by @asottile. + +1.4.2 - 2018-01-02 +================== + +### Fixes +- `pre-commit` no longer clears `GIT_SSH` environment variable when cloning. + - #671 PR by @rp-tanium. + +1.4.1 - 2017-11-09 +================== + +### Fixes +- `pre-commit autoupdate --repo ...` no longer deletes other repos. + - #660 issue by @KevinHock. + - #661 PR by @KevinHock. + +1.4.0 - 2017-11-08 +================== + +### Features +- Lazily install repositories. + - When running `pre-commit run `, pre-commit will only install + the necessary repositories. + - #637 issue by @webknjaz. + - #639 PR by @asottile. +- Version defaulting now applies to local hooks as well. + - This extends #556 to apply to local hooks. + - #646 PR by @asottile. +- Add new `repo: meta` hooks. + - `meta` hooks expose some linters of the pre-commit configuration itself. + - `id: check-useless-excludes`: ensures that `exclude` directives actually + apply to *any* file in the repository. + - `id: check-hooks-apply`: ensures that the configured hooks apply to + at least one file in the repository. + - pre-commit/pre-commit-hooks#63 issue by @asottile. + - #405 issue by @asottile. + - #643 PR by @hackedd. + - #653 PR by @asottile. + - #654 PR by @asottile. +- Allow a specific repository to be autoupdated instead of all repositories. + - `pre-commit autoupdate --repo ...` + - #656 issue by @KevinHock. + - #657 PR by @KevinHock. + +### Fixes +- Apply selinux labelling option to docker volumes + - #642 PR by @jimmidyson. + + +1.3.0 - 2017-10-08 +================== + +### Features +- Add `pre-commit try-repo` commands + - The new `try-repo` takes a repo and will run the hooks configured in + that hook repository. + - An example invocation: + `pre-commit try-repo https://github.com/pre-commit/pre-commit-hooks` + - `pre-commit try-repo` can also take all the same arguments as + `pre-commit run`. + - It can be used to try out a repository without needing to configure it. + - It can also be used to test a hook repository while developing it. + - #589 issue by @sverhagen. + - #633 PR by @asottile. + +1.2.0 - 2017-10-03 +================== + +### Features +- Add `pygrep` language + - `pygrep` aims to be a more cross-platform alternative to `pcre` hooks. + - #630 PR by @asottile. + +### Fixes +- Use `pipes.quote` for executable path in hook template + - Fixes bash syntax error when git dir contains spaces + - #626 PR by @asottile. +- Clean up hook template + - Simplify code + - Fix `--config` not being respected in some situations + - #627 PR by @asottile. +- Use `file://` protocol for cloning under test + - Fix `file://` clone paths being treated as urls for golang + - #629 PR by @asottile. +- Add `ctypes` as an import for virtualenv healthchecks + - Fixes python3.6.2 <=> python3.6.3 virtualenv invalidation + - e70825ab by @asottile. + +1.1.2 - 2017-09-20 +================== + +### Fixes +- pre-commit can successfully install commit-msg hooks + - Due to an oversight, the commit-msg-tmpl was missing from the packaging + - #623 issue by @sobolevn. + - #624 PR by @asottile. + +1.1.1 - 2017-09-17 +================== + +### Features +- pre-commit also checks the `ssl` module for virtualenv health + - Suggestion by @merwok. + - #619 PR by @asottile. +### Fixes +- pre-commit no longer crashes with unstaged files when run for the first time + - #620 #621 issue by @Lucas-C. + - #622 PR by @asottile. + +1.1.0 - 2017-09-11 +================== + +### Features +- pre-commit configuration gains a `fail_fast` option. + - You must be using the v2 configuration format introduced in 1.0.0. + - `fail_fast` defaults to `false`. + - #240 issue by @Lucas-C. + - #616 PR by @asottile. +- pre-commit configuration gains a global `exclude` option. + - This option takes a python regular expression and can be used to exclude + files from _all_ hooks. + - You must be using the v2 configuration format introduced in 1.0.0. + - #281 issue by @asieira. + - #617 PR by @asottile. + +1.0.1 - 2017-09-07 +================== + +### Fixes +- Fix a regression in the return code of `pre-commit autoupdate` + - `pre-commit migrate-config` and `pre-commit autoupdate` return 0 when + successful. + - #614 PR by @asottile. + +1.0.0 - 2017-09-07 +================== +pre-commit will now be following [semver](https://semver.org/). Thanks to all +of the [contributors](https://github.com/pre-commit/pre-commit/graphs/contributors) +that have helped us get this far! + +### Features + +- pre-commit's cache directory has moved from `~/.pre-commit` to + `$XDG_CACHE_HOME/pre-commit` (usually `~/.cache/pre-commit`). + - `pre-commit clean` now cleans up both the old and new directory. + - If you were caching this directory in CI, you'll want to adjust the + location. + - #562 issue by @nagromc. + - #602 PR by @asottile. +- A new configuration format for `.pre-commit-config.yaml` is introduced which + will enable future development. + - The new format has a top-level map instead of a top-level list. The + new format puts the hook repositories in a `repos` key. + - Old list-based configurations will continue to be supported. + - A command `pre-commit migrate-config` has been introduced to "upgrade" + the configuration format to the new map-based configuration. + - `pre-commit autoupdate` now automatically calls `migrate-config`. + - In a later release, list-based configurations will issue a deprecation + warning. + - An example diff for upgrading a configuration: + + ```diff + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + sha: v0.9.2 + hooks: + ``` + - #414 issue by @asottile. + - #610 PR by @asottile. + +### Updating + +- Run `pre-commit migrate-config` to convert `.pre-commit-config.yaml` to the + new map format. +- Update any references from `~/.pre-commit` to `~/.cache/pre-commit`. + +0.18.3 - 2017-09-06 +=================== +- Allow --config to affect `pre-commit install` +- Tweak not found error message during `pre-push` / `commit-msg` +- Improve node support when running under cygwin. + +0.18.2 - 2017-09-05 +=================== +- Fix `--all-files`, detection of staged files, detection of manually edited + files during merge conflict, and detection of files to push for non-ascii + filenames. + +0.18.1 - 2017-09-04 +=================== +- Only mention locking when waiting for a lock. +- Fix `IOError` during locking in timeout situtation on windows under python 2. + +0.18.0 - 2017-09-02 +=================== +- Add a new `docker_image` language type. `docker_image` is intended to be a + lightweight hook type similar to `system` / `script` which allows one to use + an existing docker image that provides a hook. `docker_image` hooks can + also be used as repository `local` hooks. + +0.17.0 - 2017-08-24 +=================== +- Fix typos in help +- Allow `commit-msg` hook to be uninstalled +- Upgrade the `sample-config` +- Remove undocumented `--no-stash` and `--allow-unstaged-config` +- Remove `validate_config` hook pre-commit hook. +- Fix installation race condition when multiple `pre-commit` processes would + attempt to install the same repository. + +0.16.3 - 2017-08-10 +=================== +- autoupdate attempts to maintain config formatting. + +0.16.2 - 2017-08-06 +=================== +- Initialize submodules in hook repositories. + +0.16.1 - 2017-08-04 +=================== +- Improve node support when running under cygwin. + +0.16.0 - 2017-08-01 +=================== +- Remove backward compatibility with repositories providing metadata via + `hooks.yaml`. New repositories should provide `.pre-commit-hooks.yaml`. + Run `pre-commit autoupdate` to upgrade to the latest repositories. +- Improve golang support when running under cygwin. +- Fix crash with unstaged trailing whitespace additions while git was + configured with `apply.whitespace = error`. +- Fix crash with unstaged end-of-file crlf additions and the file's lines + ended with crlf while git was configured with `core-autocrlf = true`. + +0.15.4 - 2017-07-23 +=================== +- Add support for the `commit-msg` git hook + +0.15.3 - 2017-07-20 +=================== +- Recover from invalid python virtualenvs + + +0.15.2 - 2017-07-09 +=================== +- Work around a windows-specific virtualenv bug pypa/virtualenv#1062 + This failure mode was introduced in 0.15.1 + +0.15.1 - 2017-07-09 +=================== +- Use a more intelligent default language version for python + +0.15.0 - 2017-07-02 +=================== +- Add `types` and `exclude_types` for filtering files. These options take + an array of "tags" identified for each file. The tags are sourced from + [identify](https://github.com/chriskuehl/identify). One can list the tags + for a file by running `identify-cli filename`. +- `files` is now optional (defaulting to `''`) +- `always_run` + missing `files` also defaults to `files: ''` (previously it + defaulted to `'^$'` (this reverses e150921c). + +0.14.3 - 2017-06-28 +=================== +- Expose `--origin` and `--source` as `PRE_COMMIT_ORIGIN` and + `PRE_COMMIT_SOURCE` environment variables when running as `pre-push`. + +0.14.2 - 2017-06-09 +=================== +- Use `--no-ext-diff` when running `git diff` + +0.14.1 - 2017-06-02 +=================== +- Don't crash when `always_run` is `True` and `files` is not provided. +- Set `VIRTUALENV_NO_DOWNLOAD` when making python virtualenvs. + +0.14.0 - 2017-05-16 +=================== +- Add a `pre-commit sample-config` command +- Enable ansi color escapes on modern windows +- `autoupdate` now defaults to `--tags-only`, use `--bleeding-edge` for the + old behavior +- Add support for `log_file` in hook configuration to tee hook output to a + file for CI consumption, etc. +- Fix crash with unicode commit messages during merges in python 2. +- Add a `pass_filenames` option to allow disabling automatic filename + positional arguments to hooks. + +0.13.6 - 2017-03-27 +=================== +- Fix regression in 0.13.5: allow `always_run` and `files` together despite + doing nothing. + +0.13.5 - 2017-03-26 +=================== +- 0.13.4 contained incorrect files + +0.13.4 - 2017-03-26 +=================== +- Add `--show-diff-on-failure` option to `pre-commit run` +- Replace `jsonschema` with better error messages + +0.13.3 - 2017-02-23 +=================== +- Add `--allow-missing-config` to install: allows `git commit` without a + configuration. + +0.13.2 - 2017-02-17 +=================== +- Version the local hooks repo +- Allow `minimum_pre_commit_version` for local hooks + +0.13.1 - 2017-02-16 +=================== +- Fix dummy gem for ruby local hooks + +0.13.0 - 2017-02-16 +=================== +- Autoupdate now works even when the current state is broken. +- Improve pre-push fileset on new branches +- Allow "language local" hooks, hooks which install dependencies using + `additional_dependencies` and `language` are now allowed in `repo: local`. + +0.12.2 - 2017-01-27 +=================== +- Fix docker hooks on older (<1.12) docker + +0.12.1 - 2017-01-25 +=================== +- golang hooks now support additional_dependencies +- Added a --tags-only option to pre-commit autoupdate + +0.12.0 - 2017-01-24 +=================== +- The new default file for implementing hooks in remote repositories is now + .pre-commit-hooks.yaml to encourage repositories to add the metadata. As + such, the previous hooks.yaml is now deprecated and generates a warning. +- Fix bug with local configuration interfering with ruby hooks +- Added support for hooks written in golang. + +0.11.0 - 2017-01-20 +=================== +- SwiftPM support. + +0.10.1 - 2017-01-05 +=================== +- shlex entry of docker based hooks. +- Make shlex behaviour of entry more consistent. + +0.10.0 - 2017-01-04 +=================== +- Add an `install-hooks` command similar to `install --install-hooks` but + without the `install` side-effects. +- Adds support for docker based hooks. + +0.9.4 - 2016-12-05 +================== +- Warn when cygwin / python mismatch +- Add --config for customizing configuration during run +- Update rbenv + plugins to latest versions +- pcre hooks now fail when grep / ggrep are not present + +0.9.3 - 2016-11-07 +================== +- Fix python hook installation when a strange setup.cfg exists + +0.9.2 - 2016-10-25 +================== +- Remove some python2.6 compatibility +- UI is no longer sized to terminal width, instead 80 characters or longest + necessary width. +- Fix inability to create python hook environments when using venv / pyvenv on + osx + +0.9.1 - 2016-09-10 +================== +- Remove some python2.6 compatibility +- Fix staged-files-only with external diff tools + +0.9.0 - 2016-08-31 +================== +- Only consider forward diff in changed files +- Don't run on staged deleted files that still exist +- Autoupdate to tags when available +- Stop supporting python2.6 +- Fix crash with staged files containing unstaged lines which have non-utf8 + bytes and trailing whitespace + +0.8.2 - 2016-05-20 +================== +- Fix a crash introduced in 0.8.0 when an executable was not found + +0.8.1 - 2016-05-17 +================== +- Fix regression introduced in 0.8.0 when already using rbenv with no + configured ruby hook version + +0.8.0 - 2016-04-11 +================== +- Fix --files when running in a subdir +- Improve --help a bit +- Switch to pyterminalsize for determining terminal size + +0.7.6 - 2016-01-19 +================== +- Work under latest virtualenv +- No longer create empty directories on windows with latest virtualenv + +0.7.5 - 2016-01-15 +================== +- Consider dead symlinks as files when committing + +0.7.4 - 2016-01-12 +================== +- Produce error message instead of crashing on non-utf8 installation failure + +0.7.3 - 2015-12-22 +================== +- Fix regression introduced in 0.7.1 breaking `git commit -a` + +0.7.2 - 2015-12-22 +================== +- Add `always_run` setting for hooks to run even without file changes. + +0.7.1 - 2015-12-19 +================== +- Support running pre-commit inside submodules + +0.7.0 - 2015-12-13 +================== +- Store state about additional_dependencies for rollforward/rollback compatibility + +0.6.8 - 2015-12-07 +================== +- Build as a universal wheel +- Allow '.format('-like strings in arguments +- Add an option to require a minimum pre-commit version + +0.6.7 - 2015-12-02 +================== +- Print a useful message when a hook id is not present +- Fix printing of non-ascii with unexpected errors +- Print a message when a hook modifies files but produces no output + +0.6.6 - 2015-11-25 +================== +- Add `additional_dependencies` to hook configuration. +- Fix pre-commit cloning under git 2.6 +- Small improvements for windows + +0.6.5 - 2015-11-19 +================== +- Allow args for pcre hooks + +0.6.4 - 2015-11-13 +================== +- Fix regression introduced in 0.6.3 regarding hooks which make non-utf8 diffs + +0.6.3 - 2015-11-12 +================== +- Remove `expected_return_code` +- Fail a hook if it makes modifications to the working directory + +0.6.2 - 2015-10-14 +================== +- Use --no-ri --no-rdoc instead of --no-document for gem to fix old gem + +0.6.1 - 2015-10-08 +================== +- Fix pre-push when pushing something that's already up to date + +0.6.0 - 2015-10-05 +================== +- Filter hooks by stage (commit, push). + +0.5.5 - 2015-09-04 +================== +- Change permissions a few files +- Rename the validate entrypoints +- Add --version to some entrypoints +- Add --no-document to gem installations +- Use expanduser when finding the python binary +- Suppress complaint about $TERM when no tty is attached +- Support pcre hooks on osx through ggrep + +0.5.4 - 2015-07-24 +================== +- Allow hooks to produce outputs with arbitrary bytes +- Fix pre-commit install when .git/hooks/pre-commit is a dead symlink +- Allow an unstaged config when using --files or --all-files + +0.5.3 - 2015-06-15 +================== +- Fix autoupdate with "local" hooks - don't purge local hooks. + +0.5.2 - 2015-06-02 +================== +- Fix autoupdate with "local" hooks + +0.5.1 - 2015-05-23 +================== +- Fix bug with unknown non-ascii hook-id +- Avoid crash when .git/hooks is not present in some git clients + +0.5.0 - 2015-05-19 +================== +- Add a new "local" hook type for running hooks without remote configuration. +- Complain loudly when .pre-commit-config.yaml is unstaged. +- Better support for multiple language versions when running hooks. +- Allow exclude to be defaulted in repository configuration. + +0.4.4 - 2015-03-29 +================== +- Use sys.executable when executing virtualenv + +0.4.3 - 2015-03-25 +================== +- Use reset instead of checkout when checkout out hook repo + +0.4.2 - 2015-02-27 +================== +- Limit length of xargs arguments to workaround windows xargs bug + +0.4.1 - 2015-02-27 +================== +- Don't rename across devices when creating sqlite database + +0.4.0 - 2015-02-27 +================== +- Make ^C^C During installation not cause all subsequent runs to fail +- Print while installing (instead of while cloning) +- Use sqlite to manage repositories (instead of symlinks) +- MVP Windows support + +0.3.6 - 2015-02-05 +================== +- `args` in venv'd languages are now property quoted. + +0.3.5 - 2015-01-15 +================== +- Support running during `pre-push`. See https://pre-commit.com/#advanced 'pre-commit during push'. + +0.3.4 - 2015-01-13 +================== +- Allow hook providers to default `args` in `hooks.yaml` + +0.3.3 - 2015-01-06 +================== +- Improve message for `CalledProcessError` + +0.3.2 - 2014-10-07 +================== +- Fix for `staged_files_only` with color.diff = always #176. + +0.3.1 - 2014-10-03 +================== +- Fix error clobbering #174. +- Remove dependency on `plumbum`. +- Allow pre-commit to be run from anywhere in a repository #175. + +0.3.0 - 2014-09-18 +================== +- Add `--files` option to `pre-commit run` + +0.2.11 - 2014-09-05 +=================== +- Fix terminal width detection (broken in 0.2.10) + +0.2.10 - 2014-09-04 +=================== +- Bump version of nodeenv to fix bug with ~/.npmrc +- Choose `python` more intelligently when running. + +0.2.9 - 2014-09-02 +================== +- Fix bug where sys.stdout.write must take `bytes` in python 2.6 + +0.2.8 - 2014-08-13 +================== +- Allow a client to have duplicates of hooks. +- Use --prebuilt instead of system for node. +- Improve some fatal error messages + +0.2.7 - 2014-07-28 +================== +- Produce output when running pre-commit install --install-hooks + +0.2.6 - 2014-07-28 +================== +- Print hookid on failure +- Use sys.executable for running nodeenv +- Allow running as `python -m pre_commit` + +0.2.5 - 2014-07-17 +================== +- Default columns to 80 (for non-terminal execution). + +0.2.4 - 2014-07-07 +================== +- Support --install-hooks as an argument to `pre-commit install` +- Install hooks before attempting to run anything +- Use `python -m nodeenv` instead of `nodeenv` + +0.2.3 - 2014-06-25 +================== +- Freeze ruby building infrastructure +- Fix bug that assumed diffs were utf-8 + +0.2.2 - 2014-06-22 +================== +- Fix filenames with spaces + +0.2.1 - 2014-06-18 +================== +- Use either `pre-commit` or `python -m pre_commit.main` depending on which is + available +- Don't use readlink -f + +0.2.0 - 2014-06-17 +================== +- Fix for merge-conflict during cherry-picking. +- Add -V / --version +- Add migration install mode / install -f / --overwrite +- Add `pcre` "language" for perl compatible regexes +- Reorganize packages. + +0.1.1 - 2014-06-11 +================== +- Fixed bug with autoupdate setting defaults on un-updated repos. + +0.1.0 - 2014-06-07 +================== +- Initial Release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2b83c82 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,146 @@ +# Contributing + +## Local development + +- The complete test suite depends on having at least the following installed + (possibly not a complete list) + - git (A sufficiently newer version is required to run pre-push tests) + - python2 (Required by a test which checks different python versions) + - python3 (Required by a test which checks different python versions) + - tox (or virtualenv) + - ruby + gem + - docker + +### Setting up an environment + +This is useful for running specific tests. The easiest way to set this up +is to run: + +1. `tox --devenv venv` (note: requires tox>=3.13) +2. `. venv/bin/activate` + +This will create and put you into a virtualenv which has an editable +installation of pre-commit. Hack away! Running `pre-commit` will reflect +your changes immediately. + +### Running a specific test + +Running a specific test with the environment activated is as easy as: +`pytest tests -k test_the_name_of_your_test` + +### Running all the tests + +Running all the tests can be done by running `tox -e py37` (or your +interpreter version of choice). These often take a long time and consume +significant cpu while running the slower node / ruby integration tests. + +Alternatively, with the environment activated you can run all of the tests +using: +`pytest tests` + +### Setting up the hooks + +With the environment activated simply run `pre-commit install`. + +## Documentation + +Documentation is hosted at https://pre-commit.com + +This website is controlled through +https://github.com/pre-commit/pre-commit.github.io + +## Adding support for a new hook language + +pre-commit already supports many [programming languages](https://pre-commit.com/#supported-languages) +to write hook executables with. + +When adding support for a language, you must first decide what level of support +to implement. The current implemented languages are at varying levels: + +- 0th class - pre-commit does not require any dependencies for these languages + as they're not actually languages (current examples: fail, pygrep) +- 1st class - pre-commit will bootstrap a full interpreter requiring nothing to + be installed globally (current examples: node, ruby) +- 2nd class - pre-commit requires the user to install the language globally but + will install tools in an isolated fashion (current examples: python, go, rust, + swift, docker). +- 3rd class - pre-commit requires the user to install both the tool and the + language globally (current examples: script, system) + +"third class" is usually the easiest to implement first and is perfectly +acceptable. + +Ideally the language works on the supported platforms for pre-commit (linux, +windows, macos) but it's ok to skip one or more platforms (for example, swift +doesn't run on windows). + +When writing your new language, it's often useful to look at other examples in +the `pre_commit/languages` directory. + +It might also be useful to look at a recent pull request which added a +language, for example: + +- [rust](https://github.com/pre-commit/pre-commit/pull/751) +- [fail](https://github.com/pre-commit/pre-commit/pull/812) +- [swift](https://github.com/pre-commit/pre-commit/pull/467) + +### `language` api + +here are the apis that should be implemented for a language + +Note that these are also documented in [`pre_commit/languages/all.py`](https://github.com/pre-commit/pre-commit/blob/master/pre_commit/languages/all.py) + +#### `ENVIRONMENT_DIR` + +a short string which will be used for the prefix of where packages will be +installed. For example, python uses `py_env` and installs a `virtualenv` at +that location. + +this will be `None` for 0th / 3rd class languages as they don't have an install +step. + +#### `get_default_version` + +This is used to retrieve the default `language_version` for a language. If +one cannot be determined, return `'default'`. + +You generally don't need to implement this on a first pass and can just use: + +```python +get_default_version = helpers.basic_default_version +``` + +`python` is currently the only language which implements this api + +#### `healthy` + +This is used to check whether the installed environment is considered healthy. +This function should return `True` or `False`. + +You generally don't need to implement this on a first pass and can just use: + +```python +healthy = helpers.basic_healthy +``` + +`python` is currently the only language which implements this api, for python +it is checking whether some common dlls are still available. + +#### `install_environment` + +this is the trickiest one to implement and where all the smart parts happen. + +this api should do the following things + +- (0th / 3rd class): `install_environment = helpers.no_install` +- (1st class): install a language runtime into the hook's directory +- (2nd class): install the package at `.` into the `ENVIRONMENT_DIR` +- (2nd class, optional): install packages listed in `additional_dependencies` + into `ENVIRONMENT_DIR` (not a required feature for a first pass) + +#### `run_hook` + +This is usually the easiest to implement, most of them look the same as the +`node` hook implementation: + +https://github.com/pre-commit/pre-commit/blob/160238220f022035c8ef869c9a8642f622c02118/pre_commit/languages/node.py#L72-L74 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4a071fc --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2014 pre-commit dev team: Anthony Sottile, Ken Struys + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..98a6d00 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +[![Build Status](https://dev.azure.com/asottile/asottile/_apis/build/status/pre-commit.pre-commit?branchName=master)](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=21&branchName=master) +[![Azure DevOps coverage](https://img.shields.io/azure-devops/coverage/asottile/asottile/21/master.svg)](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=21&branchName=master) +[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) + +## pre-commit + +A framework for managing and maintaining multi-language pre-commit hooks. + +For more information see: https://pre-commit.com/ diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..9b385b4 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,50 @@ +trigger: + branches: + include: [master, test-me-*] + tags: + include: ['*'] + +resources: + repositories: + - repository: asottile + type: github + endpoint: github + name: asottile/azure-pipeline-templates + ref: refs/tags/v1.0.0 + +jobs: +- template: job--pre-commit.yml@asottile +- template: job--python-tox.yml@asottile + parameters: + toxenvs: [py37] + os: windows + pre_test: + - powershell: Write-Host "##vso[task.prependpath]$env:CONDA\Scripts" + displayName: Add conda to PATH + - powershell: | + Write-Host "##vso[task.prependpath]C:\Strawberry\perl\bin" + Write-Host "##vso[task.prependpath]C:\Strawberry\perl\site\bin" + Write-Host "##vso[task.prependpath]C:\Strawberry\c\bin" + displayName: Add strawberry perl to PATH +- template: job--python-tox.yml@asottile + parameters: + toxenvs: [py37] + os: linux + name_postfix: _latest_git + pre_test: + - task: UseRubyVersion@0 + - template: step--git-install.yml + - bash: | + testing/get-swift.sh + echo '##vso[task.prependpath]/tmp/swift/usr/bin' + displayName: install swift +- template: job--python-tox.yml@asottile + parameters: + toxenvs: [pypy3, py36, py37, py38] + os: linux + pre_test: + - task: UseRubyVersion@0 + - bash: | + testing/get-swift.sh + echo '##vso[task.prependpath]/tmp/swift/usr/bin' + displayName: install swift diff --git a/pre_commit/__init__.py b/pre_commit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pre_commit/__main__.py b/pre_commit/__main__.py new file mode 100644 index 0000000..5414068 --- /dev/null +++ b/pre_commit/__main__.py @@ -0,0 +1,5 @@ +from pre_commit.main import main + + +if __name__ == '__main__': + exit(main()) diff --git a/pre_commit/clientlib.py b/pre_commit/clientlib.py new file mode 100644 index 0000000..56ec0dd --- /dev/null +++ b/pre_commit/clientlib.py @@ -0,0 +1,317 @@ +import argparse +import functools +import logging +import shlex +import sys +from typing import Any +from typing import Dict +from typing import Optional +from typing import Sequence + +import cfgv +from identify.identify import ALL_TAGS + +import pre_commit.constants as C +from pre_commit.error_handler import FatalError +from pre_commit.languages.all import all_languages +from pre_commit.util import parse_version +from pre_commit.util import yaml_load + +logger = logging.getLogger('pre_commit') + +check_string_regex = cfgv.check_and(cfgv.check_string, cfgv.check_regex) + + +def check_type_tag(tag: str) -> None: + if tag not in ALL_TAGS: + raise cfgv.ValidationError( + f'Type tag {tag!r} is not recognized. ' + f'Try upgrading identify and pre-commit?', + ) + + +def check_min_version(version: str) -> None: + if parse_version(version) > parse_version(C.VERSION): + raise cfgv.ValidationError( + f'pre-commit version {version} is required but version ' + f'{C.VERSION} is installed. ' + f'Perhaps run `pip install --upgrade pre-commit`.', + ) + + +def _make_argparser(filenames_help: str) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + parser.add_argument('filenames', nargs='*', help=filenames_help) + parser.add_argument('-V', '--version', action='version', version=C.VERSION) + return parser + + +MANIFEST_HOOK_DICT = cfgv.Map( + 'Hook', 'id', + + cfgv.Required('id', cfgv.check_string), + cfgv.Required('name', cfgv.check_string), + cfgv.Required('entry', cfgv.check_string), + cfgv.Required('language', cfgv.check_one_of(all_languages)), + cfgv.Optional('alias', cfgv.check_string, ''), + + cfgv.Optional('files', check_string_regex, ''), + cfgv.Optional('exclude', check_string_regex, '^$'), + cfgv.Optional('types', cfgv.check_array(check_type_tag), ['file']), + cfgv.Optional('exclude_types', cfgv.check_array(check_type_tag), []), + + cfgv.Optional( + 'additional_dependencies', cfgv.check_array(cfgv.check_string), [], + ), + cfgv.Optional('args', cfgv.check_array(cfgv.check_string), []), + cfgv.Optional('always_run', cfgv.check_bool, False), + cfgv.Optional('pass_filenames', cfgv.check_bool, True), + cfgv.Optional('description', cfgv.check_string, ''), + cfgv.Optional('language_version', cfgv.check_string, C.DEFAULT), + cfgv.Optional('log_file', cfgv.check_string, ''), + cfgv.Optional('minimum_pre_commit_version', cfgv.check_string, '0'), + cfgv.Optional('require_serial', cfgv.check_bool, False), + cfgv.Optional('stages', cfgv.check_array(cfgv.check_one_of(C.STAGES)), []), + cfgv.Optional('verbose', cfgv.check_bool, False), +) +MANIFEST_SCHEMA = cfgv.Array(MANIFEST_HOOK_DICT) + + +class InvalidManifestError(FatalError): + pass + + +load_manifest = functools.partial( + cfgv.load_from_filename, + schema=MANIFEST_SCHEMA, + load_strategy=yaml_load, + exc_tp=InvalidManifestError, +) + + +def validate_manifest_main(argv: Optional[Sequence[str]] = None) -> int: + parser = _make_argparser('Manifest filenames.') + args = parser.parse_args(argv) + ret = 0 + for filename in args.filenames: + try: + load_manifest(filename) + except InvalidManifestError as e: + print(e) + ret = 1 + return ret + + +LOCAL = 'local' +META = 'meta' + + +class MigrateShaToRev: + key = 'rev' + + @staticmethod + def _cond(key: str) -> cfgv.Conditional: + return cfgv.Conditional( + key, cfgv.check_string, + condition_key='repo', + condition_value=cfgv.NotIn(LOCAL, META), + ensure_absent=True, + ) + + def check(self, dct: Dict[str, Any]) -> None: + if dct.get('repo') in {LOCAL, META}: + self._cond('rev').check(dct) + self._cond('sha').check(dct) + elif 'sha' in dct and 'rev' in dct: + raise cfgv.ValidationError('Cannot specify both sha and rev') + elif 'sha' in dct: + self._cond('sha').check(dct) + else: + self._cond('rev').check(dct) + + def apply_default(self, dct: Dict[str, Any]) -> None: + if 'sha' in dct: + dct['rev'] = dct.pop('sha') + + remove_default = cfgv.Required.remove_default + + +def _entry(modname: str) -> str: + """the hook `entry` is passed through `shlex.split()` by the command + runner, so to prevent issues with spaces and backslashes (on Windows) + it must be quoted here. + """ + return f'{shlex.quote(sys.executable)} -m pre_commit.meta_hooks.{modname}' + + +def warn_unknown_keys_root( + extra: Sequence[str], + orig_keys: Sequence[str], + dct: Dict[str, str], +) -> None: + logger.warning(f'Unexpected key(s) present at root: {", ".join(extra)}') + + +def warn_unknown_keys_repo( + extra: Sequence[str], + orig_keys: Sequence[str], + dct: Dict[str, str], +) -> None: + logger.warning( + f'Unexpected key(s) present on {dct["repo"]}: {", ".join(extra)}', + ) + + +_meta = ( + ( + 'check-hooks-apply', ( + ('name', 'Check hooks apply to the repository'), + ('files', C.CONFIG_FILE), + ('entry', _entry('check_hooks_apply')), + ), + ), + ( + 'check-useless-excludes', ( + ('name', 'Check for useless excludes'), + ('files', C.CONFIG_FILE), + ('entry', _entry('check_useless_excludes')), + ), + ), + ( + 'identity', ( + ('name', 'identity'), + ('verbose', True), + ('entry', _entry('identity')), + ), + ), +) + +META_HOOK_DICT = cfgv.Map( + 'Hook', 'id', + cfgv.Required('id', cfgv.check_string), + cfgv.Required('id', cfgv.check_one_of(tuple(k for k, _ in _meta))), + # language must be system + cfgv.Optional('language', cfgv.check_one_of({'system'}), 'system'), + *( + # default to the hook definition for the meta hooks + cfgv.ConditionalOptional(key, cfgv.check_any, value, 'id', hook_id) + for hook_id, values in _meta + for key, value in values + ), + *( + # default to the "manifest" parsing + cfgv.OptionalNoDefault(item.key, item.check_fn) + # these will always be defaulted above + if item.key in {'name', 'language', 'entry'} else + item + for item in MANIFEST_HOOK_DICT.items + ), +) +CONFIG_HOOK_DICT = cfgv.Map( + 'Hook', 'id', + + cfgv.Required('id', cfgv.check_string), + + # All keys in manifest hook dict are valid in a config hook dict, but + # are optional. + # No defaults are provided here as the config is merged on top of the + # manifest. + *( + cfgv.OptionalNoDefault(item.key, item.check_fn) + for item in MANIFEST_HOOK_DICT.items + if item.key != 'id' + ), +) +CONFIG_REPO_DICT = cfgv.Map( + 'Repository', 'repo', + + cfgv.Required('repo', cfgv.check_string), + + cfgv.ConditionalRecurse( + 'hooks', cfgv.Array(CONFIG_HOOK_DICT), + 'repo', cfgv.NotIn(LOCAL, META), + ), + cfgv.ConditionalRecurse( + 'hooks', cfgv.Array(MANIFEST_HOOK_DICT), + 'repo', LOCAL, + ), + cfgv.ConditionalRecurse( + 'hooks', cfgv.Array(META_HOOK_DICT), + 'repo', META, + ), + + MigrateShaToRev(), + cfgv.WarnAdditionalKeys(('repo', 'rev', 'hooks'), warn_unknown_keys_repo), +) +DEFAULT_LANGUAGE_VERSION = cfgv.Map( + 'DefaultLanguageVersion', None, + cfgv.NoAdditionalKeys(all_languages), + *(cfgv.Optional(x, cfgv.check_string, C.DEFAULT) for x in all_languages), +) +CONFIG_SCHEMA = cfgv.Map( + 'Config', None, + + cfgv.RequiredRecurse('repos', cfgv.Array(CONFIG_REPO_DICT)), + cfgv.OptionalRecurse( + 'default_language_version', DEFAULT_LANGUAGE_VERSION, {}, + ), + cfgv.Optional( + 'default_stages', + cfgv.check_array(cfgv.check_one_of(C.STAGES)), + C.STAGES, + ), + cfgv.Optional('files', check_string_regex, ''), + cfgv.Optional('exclude', check_string_regex, '^$'), + cfgv.Optional('fail_fast', cfgv.check_bool, False), + cfgv.Optional( + 'minimum_pre_commit_version', + cfgv.check_and(cfgv.check_string, check_min_version), + '0', + ), + cfgv.WarnAdditionalKeys( + ( + 'repos', + 'default_language_version', + 'default_stages', + 'files', + 'exclude', + 'fail_fast', + 'minimum_pre_commit_version', + ), + warn_unknown_keys_root, + ), +) + + +class InvalidConfigError(FatalError): + pass + + +def ordered_load_normalize_legacy_config(contents: str) -> Dict[str, Any]: + data = yaml_load(contents) + if isinstance(data, list): + # TODO: Once happy, issue a deprecation warning and instructions + return {'repos': data} + else: + return data + + +load_config = functools.partial( + cfgv.load_from_filename, + schema=CONFIG_SCHEMA, + load_strategy=ordered_load_normalize_legacy_config, + exc_tp=InvalidConfigError, +) + + +def validate_config_main(argv: Optional[Sequence[str]] = None) -> int: + parser = _make_argparser('Config filenames.') + args = parser.parse_args(argv) + ret = 0 + for filename in args.filenames: + try: + load_config(filename) + except InvalidConfigError as e: + print(e) + ret = 1 + return ret diff --git a/pre_commit/color.py b/pre_commit/color.py new file mode 100644 index 0000000..5fa7042 --- /dev/null +++ b/pre_commit/color.py @@ -0,0 +1,97 @@ +import os +import sys + +if sys.platform == 'win32': # pragma: no cover (windows) + def _enable() -> None: + from ctypes import POINTER + from ctypes import windll + from ctypes import WinError + from ctypes import WINFUNCTYPE + from ctypes.wintypes import BOOL + from ctypes.wintypes import DWORD + from ctypes.wintypes import HANDLE + + STD_OUTPUT_HANDLE = -11 + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4 + + def bool_errcheck(result, func, args): + if not result: + raise WinError() + return args + + GetStdHandle = WINFUNCTYPE(HANDLE, DWORD)( + ('GetStdHandle', windll.kernel32), ((1, 'nStdHandle'),), + ) + + GetConsoleMode = WINFUNCTYPE(BOOL, HANDLE, POINTER(DWORD))( + ('GetConsoleMode', windll.kernel32), + ((1, 'hConsoleHandle'), (2, 'lpMode')), + ) + GetConsoleMode.errcheck = bool_errcheck + + SetConsoleMode = WINFUNCTYPE(BOOL, HANDLE, DWORD)( + ('SetConsoleMode', windll.kernel32), + ((1, 'hConsoleHandle'), (1, 'dwMode')), + ) + SetConsoleMode.errcheck = bool_errcheck + + # As of Windows 10, the Windows console supports (some) ANSI escape + # sequences, but it needs to be enabled using `SetConsoleMode` first. + # + # More info on the escape sequences supported: + # https://msdn.microsoft.com/en-us/library/windows/desktop/mt638032(v=vs.85).aspx + stdout = GetStdHandle(STD_OUTPUT_HANDLE) + flags = GetConsoleMode(stdout) + SetConsoleMode(stdout, flags | ENABLE_VIRTUAL_TERMINAL_PROCESSING) + + try: + _enable() + except OSError: + terminal_supports_color = False + else: + terminal_supports_color = True +else: # pragma: win32 no cover + terminal_supports_color = True + +RED = '\033[41m' +GREEN = '\033[42m' +YELLOW = '\033[43;30m' +TURQUOISE = '\033[46;30m' +SUBTLE = '\033[2m' +NORMAL = '\033[m' + + +def format_color(text: str, color: str, use_color_setting: bool) -> str: + """Format text with color. + + Args: + text - Text to be formatted with color if `use_color` + color - The color start string + use_color_setting - Whether or not to color + """ + if use_color_setting: + return f'{color}{text}{NORMAL}' + else: + return text + + +COLOR_CHOICES = ('auto', 'always', 'never') + + +def use_color(setting: str) -> bool: + """Choose whether to use color based on the command argument. + + Args: + setting - Either `auto`, `always`, or `never` + """ + if setting not in COLOR_CHOICES: + raise ValueError(setting) + + return ( + setting == 'always' or ( + setting == 'auto' and + sys.stdout.isatty() and + terminal_supports_color and + os.getenv('TERM') != 'dumb' + ) + ) diff --git a/pre_commit/commands/__init__.py b/pre_commit/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pre_commit/commands/autoupdate.py b/pre_commit/commands/autoupdate.py new file mode 100644 index 0000000..5a9a988 --- /dev/null +++ b/pre_commit/commands/autoupdate.py @@ -0,0 +1,182 @@ +import os.path +import re +from typing import Any +from typing import Dict +from typing import List +from typing import NamedTuple +from typing import Optional +from typing import Sequence +from typing import Tuple + +import pre_commit.constants as C +from pre_commit import git +from pre_commit import output +from pre_commit.clientlib import InvalidManifestError +from pre_commit.clientlib import load_config +from pre_commit.clientlib import load_manifest +from pre_commit.clientlib import LOCAL +from pre_commit.clientlib import META +from pre_commit.commands.migrate_config import migrate_config +from pre_commit.store import Store +from pre_commit.util import CalledProcessError +from pre_commit.util import cmd_output +from pre_commit.util import cmd_output_b +from pre_commit.util import tmpdir +from pre_commit.util import yaml_dump +from pre_commit.util import yaml_load + + +class RevInfo(NamedTuple): + repo: str + rev: str + frozen: Optional[str] + + @classmethod + def from_config(cls, config: Dict[str, Any]) -> 'RevInfo': + return cls(config['repo'], config['rev'], None) + + def update(self, tags_only: bool, freeze: bool) -> 'RevInfo': + if tags_only: + tag_cmd = ('git', 'describe', 'FETCH_HEAD', '--tags', '--abbrev=0') + else: + tag_cmd = ('git', 'describe', 'FETCH_HEAD', '--tags', '--exact') + + with tmpdir() as tmp: + git.init_repo(tmp, self.repo) + cmd_output_b('git', 'fetch', 'origin', 'HEAD', '--tags', cwd=tmp) + + try: + rev = cmd_output(*tag_cmd, cwd=tmp)[1].strip() + except CalledProcessError: + cmd = ('git', 'rev-parse', 'FETCH_HEAD') + rev = cmd_output(*cmd, cwd=tmp)[1].strip() + + frozen = None + if freeze: + exact = cmd_output('git', 'rev-parse', rev, cwd=tmp)[1].strip() + if exact != rev: + rev, frozen = exact, rev + return self._replace(rev=rev, frozen=frozen) + + +class RepositoryCannotBeUpdatedError(RuntimeError): + pass + + +def _check_hooks_still_exist_at_rev( + repo_config: Dict[str, Any], + info: RevInfo, + store: Store, +) -> None: + try: + path = store.clone(repo_config['repo'], info.rev) + manifest = load_manifest(os.path.join(path, C.MANIFEST_FILE)) + except InvalidManifestError as e: + raise RepositoryCannotBeUpdatedError(str(e)) + + # See if any of our hooks were deleted with the new commits + hooks = {hook['id'] for hook in repo_config['hooks']} + hooks_missing = hooks - {hook['id'] for hook in manifest} + if hooks_missing: + raise RepositoryCannotBeUpdatedError( + f'Cannot update because the tip of HEAD is missing these hooks:\n' + f'{", ".join(sorted(hooks_missing))}', + ) + + +REV_LINE_RE = re.compile(r'^(\s+)rev:(\s*)([^\s#]+)(.*)(\r?\n)$', re.DOTALL) + + +def _original_lines( + path: str, + rev_infos: List[Optional[RevInfo]], + retry: bool = False, +) -> Tuple[List[str], List[int]]: + """detect `rev:` lines or reformat the file""" + with open(path) as f: + original = f.read() + + lines = original.splitlines(True) + idxs = [i for i, line in enumerate(lines) if REV_LINE_RE.match(line)] + if len(idxs) == len(rev_infos): + return lines, idxs + elif retry: + raise AssertionError('could not find rev lines') + else: + with open(path, 'w') as f: + f.write(yaml_dump(yaml_load(original))) + return _original_lines(path, rev_infos, retry=True) + + +def _write_new_config(path: str, rev_infos: List[Optional[RevInfo]]) -> None: + lines, idxs = _original_lines(path, rev_infos) + + for idx, rev_info in zip(idxs, rev_infos): + if rev_info is None: + continue + match = REV_LINE_RE.match(lines[idx]) + assert match is not None + new_rev_s = yaml_dump({'rev': rev_info.rev}) + new_rev = new_rev_s.split(':', 1)[1].strip() + if rev_info.frozen is not None: + comment = f' # frozen: {rev_info.frozen}' + elif match[4].strip().startswith('# frozen:'): + comment = '' + else: + comment = match[4] + lines[idx] = f'{match[1]}rev:{match[2]}{new_rev}{comment}{match[5]}' + + with open(path, 'w') as f: + f.write(''.join(lines)) + + +def autoupdate( + config_file: str, + store: Store, + tags_only: bool, + freeze: bool, + repos: Sequence[str] = (), +) -> int: + """Auto-update the pre-commit config to the latest versions of repos.""" + migrate_config(config_file, quiet=True) + retv = 0 + rev_infos: List[Optional[RevInfo]] = [] + changed = False + + config = load_config(config_file) + for repo_config in config['repos']: + if repo_config['repo'] in {LOCAL, META}: + continue + + info = RevInfo.from_config(repo_config) + if repos and info.repo not in repos: + rev_infos.append(None) + continue + + output.write(f'Updating {info.repo} ... ') + new_info = info.update(tags_only=tags_only, freeze=freeze) + try: + _check_hooks_still_exist_at_rev(repo_config, new_info, store) + except RepositoryCannotBeUpdatedError as error: + output.write_line(error.args[0]) + rev_infos.append(None) + retv = 1 + continue + + if new_info.rev != info.rev: + changed = True + if new_info.frozen: + updated_to = f'{new_info.frozen} (frozen)' + else: + updated_to = new_info.rev + msg = f'updating {info.rev} -> {updated_to}.' + output.write_line(msg) + rev_infos.append(new_info) + else: + output.write_line('already up to date.') + rev_infos.append(None) + + if changed: + _write_new_config(config_file, rev_infos) + + return retv diff --git a/pre_commit/commands/clean.py b/pre_commit/commands/clean.py new file mode 100644 index 0000000..2be6c16 --- /dev/null +++ b/pre_commit/commands/clean.py @@ -0,0 +1,14 @@ +import os.path + +from pre_commit import output +from pre_commit.store import Store +from pre_commit.util import rmtree + + +def clean(store: Store) -> int: + legacy_path = os.path.expanduser('~/.pre-commit') + for directory in (store.directory, legacy_path): + if os.path.exists(directory): + rmtree(directory) + output.write_line(f'Cleaned {directory}.') + return 0 diff --git a/pre_commit/commands/gc.py b/pre_commit/commands/gc.py new file mode 100644 index 0000000..7f6d311 --- /dev/null +++ b/pre_commit/commands/gc.py @@ -0,0 +1,90 @@ +import os.path +from typing import Any +from typing import Dict +from typing import Set +from typing import Tuple + +import pre_commit.constants as C +from pre_commit import output +from pre_commit.clientlib import InvalidConfigError +from pre_commit.clientlib import InvalidManifestError +from pre_commit.clientlib import load_config +from pre_commit.clientlib import load_manifest +from pre_commit.clientlib import LOCAL +from pre_commit.clientlib import META +from pre_commit.store import Store + + +def _mark_used_repos( + store: Store, + all_repos: Dict[Tuple[str, str], str], + unused_repos: Set[Tuple[str, str]], + repo: Dict[str, Any], +) -> None: + if repo['repo'] == META: + return + elif repo['repo'] == LOCAL: + for hook in repo['hooks']: + deps = hook.get('additional_dependencies') + unused_repos.discard(( + store.db_repo_name(repo['repo'], deps), C.LOCAL_REPO_VERSION, + )) + else: + key = (repo['repo'], repo['rev']) + path = all_repos.get(key) + # can't inspect manifest if it isn't cloned + if path is None: + return + + try: + manifest = load_manifest(os.path.join(path, C.MANIFEST_FILE)) + except InvalidManifestError: + return + else: + unused_repos.discard(key) + by_id = {hook['id']: hook for hook in manifest} + + for hook in repo['hooks']: + if hook['id'] not in by_id: + continue + + deps = hook.get( + 'additional_dependencies', + by_id[hook['id']]['additional_dependencies'], + ) + unused_repos.discard(( + store.db_repo_name(repo['repo'], deps), repo['rev'], + )) + + +def _gc_repos(store: Store) -> int: + configs = store.select_all_configs() + repos = store.select_all_repos() + + # delete config paths which do not exist + dead_configs = [p for p in configs if not os.path.exists(p)] + live_configs = [p for p in configs if os.path.exists(p)] + + all_repos = {(repo, ref): path for repo, ref, path in repos} + unused_repos = set(all_repos) + for config_path in live_configs: + try: + config = load_config(config_path) + except InvalidConfigError: + dead_configs.append(config_path) + continue + else: + for repo in config['repos']: + _mark_used_repos(store, all_repos, unused_repos, repo) + + store.delete_configs(dead_configs) + for db_repo_name, ref in unused_repos: + store.delete_repo(db_repo_name, ref, all_repos[(db_repo_name, ref)]) + return len(unused_repos) + + +def gc(store: Store) -> int: + with store.exclusive_lock(): + repos_removed = _gc_repos(store) + output.write_line(f'{repos_removed} repo(s) removed.') + return 0 diff --git a/pre_commit/commands/hook_impl.py b/pre_commit/commands/hook_impl.py new file mode 100644 index 0000000..5ff4555 --- /dev/null +++ b/pre_commit/commands/hook_impl.py @@ -0,0 +1,187 @@ +import argparse +import os.path +import subprocess +import sys +from typing import Optional +from typing import Sequence +from typing import Tuple + +from pre_commit.commands.run import run +from pre_commit.envcontext import envcontext +from pre_commit.parse_shebang import normalize_cmd +from pre_commit.store import Store + +Z40 = '0' * 40 + + +def _run_legacy( + hook_type: str, + hook_dir: str, + args: Sequence[str], +) -> Tuple[int, bytes]: + if os.environ.get('PRE_COMMIT_RUNNING_LEGACY'): + raise SystemExit( + f"bug: pre-commit's script is installed in migration mode\n" + f'run `pre-commit install -f --hook-type {hook_type}` to fix ' + f'this\n\n' + f'Please report this bug at ' + f'https://github.com/pre-commit/pre-commit/issues', + ) + + if hook_type == 'pre-push': + stdin = sys.stdin.buffer.read() + else: + stdin = b'' + + # not running in legacy mode + legacy_hook = os.path.join(hook_dir, f'{hook_type}.legacy') + if not os.access(legacy_hook, os.X_OK): + return 0, stdin + + with envcontext((('PRE_COMMIT_RUNNING_LEGACY', '1'),)): + cmd = normalize_cmd((legacy_hook, *args)) + return subprocess.run(cmd, input=stdin).returncode, stdin + + +def _validate_config( + retv: int, + config: str, + skip_on_missing_config: bool, +) -> None: + if not os.path.isfile(config): + if skip_on_missing_config or os.getenv('PRE_COMMIT_ALLOW_NO_CONFIG'): + print(f'`{config}` config file not found. Skipping `pre-commit`.') + raise SystemExit(retv) + else: + print( + f'No {config} file was found\n' + f'- To temporarily silence this, run ' + f'`PRE_COMMIT_ALLOW_NO_CONFIG=1 git ...`\n' + f'- To permanently silence this, install pre-commit with the ' + f'--allow-missing-config option\n' + f'- To uninstall pre-commit run `pre-commit uninstall`', + ) + raise SystemExit(1) + + +def _ns( + hook_type: str, + color: bool, + *, + all_files: bool = False, + from_ref: Optional[str] = None, + to_ref: Optional[str] = None, + remote_name: Optional[str] = None, + remote_url: Optional[str] = None, + commit_msg_filename: Optional[str] = None, + checkout_type: Optional[str] = None, +) -> argparse.Namespace: + return argparse.Namespace( + color=color, + hook_stage=hook_type.replace('pre-', ''), + from_ref=from_ref, + to_ref=to_ref, + remote_name=remote_name, + remote_url=remote_url, + commit_msg_filename=commit_msg_filename, + all_files=all_files, + checkout_type=checkout_type, + files=(), + hook=None, + verbose=False, + show_diff_on_failure=False, + ) + + +def _rev_exists(rev: str) -> bool: + return not subprocess.call(('git', 'rev-list', '--quiet', rev)) + + +def _pre_push_ns( + color: bool, + args: Sequence[str], + stdin: bytes, +) -> Optional[argparse.Namespace]: + remote_name = args[0] + remote_url = args[1] + + for line in stdin.decode().splitlines(): + _, local_sha, _, remote_sha = line.split() + if local_sha == Z40: + continue + elif remote_sha != Z40 and _rev_exists(remote_sha): + return _ns( + 'pre-push', color, + from_ref=remote_sha, to_ref=local_sha, + remote_name=remote_name, remote_url=remote_url, + ) + else: + # ancestors not found in remote + ancestors = subprocess.check_output(( + 'git', 'rev-list', local_sha, '--topo-order', '--reverse', + '--not', f'--remotes={remote_name}', + )).decode().strip() + if not ancestors: + continue + else: + first_ancestor = ancestors.splitlines()[0] + cmd = ('git', 'rev-list', '--max-parents=0', local_sha) + roots = set(subprocess.check_output(cmd).decode().splitlines()) + if first_ancestor in roots: + # pushing the whole tree including root commit + return _ns( + 'pre-push', color, + all_files=True, + remote_name=remote_name, remote_url=remote_url, + ) + else: + rev_cmd = ('git', 'rev-parse', f'{first_ancestor}^') + source = subprocess.check_output(rev_cmd).decode().strip() + return _ns( + 'pre-push', color, + from_ref=source, to_ref=local_sha, + remote_name=remote_name, remote_url=remote_url, + ) + + # nothing to push + return None + + +def _run_ns( + hook_type: str, + color: bool, + args: Sequence[str], + stdin: bytes, +) -> Optional[argparse.Namespace]: + if hook_type == 'pre-push': + return _pre_push_ns(color, args, stdin) + elif hook_type in {'prepare-commit-msg', 'commit-msg'}: + return _ns(hook_type, color, commit_msg_filename=args[0]) + elif hook_type in {'pre-merge-commit', 'pre-commit'}: + return _ns(hook_type, color) + elif hook_type == 'post-checkout': + return _ns( + hook_type, color, + from_ref=args[0], to_ref=args[1], checkout_type=args[2], + ) + else: + raise AssertionError(f'unexpected hook type: {hook_type}') + + +def hook_impl( + store: Store, + *, + config: str, + color: bool, + hook_type: str, + hook_dir: str, + skip_on_missing_config: bool, + args: Sequence[str], +) -> int: + retv, stdin = _run_legacy(hook_type, hook_dir, args) + _validate_config(retv, config, skip_on_missing_config) + ns = _run_ns(hook_type, color, args, stdin) + if ns is None: + return retv + else: + return retv | run(config, store, ns) diff --git a/pre_commit/commands/init_templatedir.py b/pre_commit/commands/init_templatedir.py new file mode 100644 index 0000000..f676fb1 --- /dev/null +++ b/pre_commit/commands/init_templatedir.py @@ -0,0 +1,33 @@ +import logging +import os.path +from typing import Sequence + +from pre_commit.commands.install_uninstall import install +from pre_commit.store import Store +from pre_commit.util import CalledProcessError +from pre_commit.util import cmd_output + +logger = logging.getLogger('pre_commit') + + +def init_templatedir( + config_file: str, + store: Store, + directory: str, + hook_types: Sequence[str], +) -> int: + install( + config_file, store, hook_types=hook_types, + overwrite=True, skip_on_missing_config=True, git_dir=directory, + ) + try: + _, out, _ = cmd_output('git', 'config', 'init.templateDir') + except CalledProcessError: + configured_path = None + else: + configured_path = os.path.realpath(os.path.expanduser(out.strip())) + dest = os.path.realpath(directory) + if configured_path != dest: + logger.warning('`init.templateDir` not set to the target directory') + logger.warning(f'maybe `git config --global init.templateDir {dest}`?') + return 0 diff --git a/pre_commit/commands/install_uninstall.py b/pre_commit/commands/install_uninstall.py new file mode 100644 index 0000000..c8b7633 --- /dev/null +++ b/pre_commit/commands/install_uninstall.py @@ -0,0 +1,175 @@ +import itertools +import logging +import os.path +import shutil +import sys +from typing import Optional +from typing import Sequence +from typing import Tuple + +from pre_commit import git +from pre_commit import output +from pre_commit.clientlib import load_config +from pre_commit.repository import all_hooks +from pre_commit.repository import install_hook_envs +from pre_commit.store import Store +from pre_commit.util import make_executable +from pre_commit.util import resource_text + + +logger = logging.getLogger(__name__) + +# This is used to identify the hook file we install +PRIOR_HASHES = ( + '4d9958c90bc262f47553e2c073f14cfe', + 'd8ee923c46731b42cd95cc869add4062', + '49fd668cb42069aa1b6048464be5d395', + '79f09a650522a87b0da915d0d983b2de', + 'e358c9dae00eac5d06b38dfdb1e33a8c', +) +CURRENT_HASH = '138fd403232d2ddd5efb44317e38bf03' +TEMPLATE_START = '# start templated\n' +TEMPLATE_END = '# end templated\n' +# Homebrew/homebrew-core#35825: be more timid about appropriate `PATH` +# #1312 os.defpath is too restrictive on BSD +POSIX_SEARCH_PATH = ('/usr/local/bin', '/usr/bin', '/bin') +SYS_EXE = os.path.basename(os.path.realpath(sys.executable)) + + +def _hook_paths( + hook_type: str, + git_dir: Optional[str] = None, +) -> Tuple[str, str]: + git_dir = git_dir if git_dir is not None else git.get_git_dir() + pth = os.path.join(git_dir, 'hooks', hook_type) + return pth, f'{pth}.legacy' + + +def is_our_script(filename: str) -> bool: + if not os.path.exists(filename): # pragma: win32 no cover (symlink) + return False + with open(filename) as f: + contents = f.read() + return any(h in contents for h in (CURRENT_HASH,) + PRIOR_HASHES) + + +def shebang() -> str: + if sys.platform == 'win32': + py = SYS_EXE + else: + exe_choices = [ + f'python{sys.version_info[0]}.{sys.version_info[1]}', + f'python{sys.version_info[0]}', + ] + # avoid searching for bare `python` as it's likely to be python 2 + if SYS_EXE != 'python': + exe_choices.append(SYS_EXE) + for path, exe in itertools.product(POSIX_SEARCH_PATH, exe_choices): + if os.access(os.path.join(path, exe), os.X_OK): + py = exe + break + else: + py = SYS_EXE + return f'#!/usr/bin/env {py}' + + +def _install_hook_script( + config_file: str, + hook_type: str, + overwrite: bool = False, + skip_on_missing_config: bool = False, + git_dir: Optional[str] = None, +) -> None: + hook_path, legacy_path = _hook_paths(hook_type, git_dir=git_dir) + + os.makedirs(os.path.dirname(hook_path), exist_ok=True) + + # If we have an existing hook, move it to pre-commit.legacy + if os.path.lexists(hook_path) and not is_our_script(hook_path): + shutil.move(hook_path, legacy_path) + + # If we specify overwrite, we simply delete the legacy file + if overwrite and os.path.exists(legacy_path): + os.remove(legacy_path) + elif os.path.exists(legacy_path): + output.write_line( + f'Running in migration mode with existing hooks at {legacy_path}\n' + f'Use -f to use only pre-commit.', + ) + + args = ['hook-impl', f'--config={config_file}', f'--hook-type={hook_type}'] + if skip_on_missing_config: + args.append('--skip-on-missing-config') + params = {'INSTALL_PYTHON': sys.executable, 'ARGS': args} + + with open(hook_path, 'w') as hook_file: + contents = resource_text('hook-tmpl') + before, rest = contents.split(TEMPLATE_START) + to_template, after = rest.split(TEMPLATE_END) + + before = before.replace('#!/usr/bin/env python3', shebang()) + + hook_file.write(before + TEMPLATE_START) + for line in to_template.splitlines(): + var = line.split()[0] + hook_file.write(f'{var} = {params[var]!r}\n') + hook_file.write(TEMPLATE_END + after) + make_executable(hook_path) + + output.write_line(f'pre-commit installed at {hook_path}') + + +def install( + config_file: str, + store: Store, + hook_types: Sequence[str], + overwrite: bool = False, + hooks: bool = False, + skip_on_missing_config: bool = False, + git_dir: Optional[str] = None, +) -> int: + if git_dir is None and git.has_core_hookpaths_set(): + logger.error( + 'Cowardly refusing to install hooks with `core.hooksPath` set.\n' + 'hint: `git config --unset-all core.hooksPath`', + ) + return 1 + + for hook_type in hook_types: + _install_hook_script( + config_file, hook_type, + overwrite=overwrite, + skip_on_missing_config=skip_on_missing_config, + git_dir=git_dir, + ) + + if hooks: + install_hooks(config_file, store) + + return 0 + + +def install_hooks(config_file: str, store: Store) -> int: + install_hook_envs(all_hooks(load_config(config_file), store), store) + return 0 + + +def _uninstall_hook_script(hook_type: str) -> None: + hook_path, legacy_path = _hook_paths(hook_type) + + # If our file doesn't exist or it isn't ours, gtfo. + if not os.path.exists(hook_path) or not is_our_script(hook_path): + return + + os.remove(hook_path) + output.write_line(f'{hook_type} uninstalled') + + if os.path.exists(legacy_path): + os.rename(legacy_path, hook_path) + output.write_line(f'Restored previous hooks to {hook_path}') + + +def uninstall(hook_types: Sequence[str]) -> int: + for hook_type in hook_types: + _uninstall_hook_script(hook_type) + return 0 diff --git a/pre_commit/commands/migrate_config.py b/pre_commit/commands/migrate_config.py new file mode 100644 index 0000000..d83b8e9 --- /dev/null +++ b/pre_commit/commands/migrate_config.py @@ -0,0 +1,59 @@ +import re + +import yaml + +from pre_commit.util import yaml_load + + +def _indent(s: str) -> str: + lines = s.splitlines(True) + return ''.join(' ' * 4 + line if line.strip() else line for line in lines) + + +def _is_header_line(line: str) -> bool: + return line.startswith(('#', '---')) or not line.strip() + + +def _migrate_map(contents: str) -> str: + # Find the first non-header line + lines = contents.splitlines(True) + i = 0 + # Only loop on non empty configuration file + while i < len(lines) and _is_header_line(lines[i]): + i += 1 + + header = ''.join(lines[:i]) + rest = ''.join(lines[i:]) + + if isinstance(yaml_load(contents), list): + # If they are using the "default" flow style of yaml, this operation + # will yield a valid configuration + try: + trial_contents = f'{header}repos:\n{rest}' + yaml_load(trial_contents) + contents = trial_contents + except yaml.YAMLError: + contents = f'{header}repos:\n{_indent(rest)}' + + return contents + + +def _migrate_sha_to_rev(contents: str) -> str: + return re.sub(r'(\n\s+)sha:', r'\1rev:', contents) + + +def migrate_config(config_file: str, quiet: bool = False) -> int: + with open(config_file) as f: + orig_contents = contents = f.read() + + contents = _migrate_map(contents) + contents = _migrate_sha_to_rev(contents) + + if contents != orig_contents: + with open(config_file, 'w') as f: + f.write(contents) + + print('Configuration has been migrated.') + elif not quiet: + print('Configuration is already migrated.') + return 0 diff --git a/pre_commit/commands/run.py b/pre_commit/commands/run.py new file mode 100644 index 0000000..2f74578 --- /dev/null +++ b/pre_commit/commands/run.py @@ -0,0 +1,360 @@ +import argparse +import contextlib +import functools +import logging +import os +import re +import subprocess +import time +from typing import Any +from typing import Collection +from typing import Dict +from typing import List +from typing import Sequence +from typing import Set +from typing import Tuple + +from identify.identify import tags_from_path + +from pre_commit import color +from pre_commit import git +from pre_commit import output +from pre_commit.clientlib import load_config +from pre_commit.hook import Hook +from pre_commit.languages.all import languages +from pre_commit.repository import all_hooks +from pre_commit.repository import install_hook_envs +from pre_commit.staged_files_only import staged_files_only +from pre_commit.store import Store +from pre_commit.util import cmd_output_b +from pre_commit.util import EnvironT + + +logger = logging.getLogger('pre_commit') + + +def _start_msg(*, start: str, cols: int, end_len: int) -> str: + dots = '.' * (cols - len(start) - end_len - 1) + return f'{start}{dots}' + + +def _full_msg( + *, + start: str, + cols: int, + end_msg: str, + end_color: str, + use_color: bool, + postfix: str = '', +) -> str: + dots = '.' * (cols - len(start) - len(postfix) - len(end_msg) - 1) + end = color.format_color(end_msg, end_color, use_color) + return f'{start}{dots}{postfix}{end}\n' + + +def filter_by_include_exclude( + names: Collection[str], + include: str, + exclude: str, +) -> List[str]: + include_re, exclude_re = re.compile(include), re.compile(exclude) + return [ + filename for filename in names + if include_re.search(filename) + if not exclude_re.search(filename) + ] + + +class Classifier: + def __init__(self, filenames: Sequence[str]) -> None: + # on windows we normalize all filenames to use forward slashes + # this makes it easier to filter using the `files:` regex + # this also makes improperly quoted shell-based hooks work better + # see #1173 + if os.altsep == '/' and os.sep == '\\': + filenames = [f.replace(os.sep, os.altsep) for f in filenames] + self.filenames = [f for f in filenames if os.path.lexists(f)] + + @functools.lru_cache(maxsize=None) + def _types_for_file(self, filename: str) -> Set[str]: + return tags_from_path(filename) + + def by_types( + self, + names: Sequence[str], + types: Collection[str], + exclude_types: Collection[str], + ) -> List[str]: + types, exclude_types = frozenset(types), frozenset(exclude_types) + ret = [] + for filename in names: + tags = self._types_for_file(filename) + if tags >= types and not tags & exclude_types: + ret.append(filename) + return ret + + def filenames_for_hook(self, hook: Hook) -> Tuple[str, ...]: + names = self.filenames + names = filter_by_include_exclude(names, hook.files, hook.exclude) + names = self.by_types(names, hook.types, hook.exclude_types) + return tuple(names) + + +def _get_skips(environ: EnvironT) -> Set[str]: + skips = environ.get('SKIP', '') + return {skip.strip() for skip in skips.split(',') if skip.strip()} + + +SKIPPED = 'Skipped' +NO_FILES = '(no files to check)' + + +def _subtle_line(s: str, use_color: bool) -> None: + output.write_line(color.format_color(s, color.SUBTLE, use_color)) + + +def _run_single_hook( + classifier: Classifier, + hook: Hook, + skips: Set[str], + cols: int, + verbose: bool, + use_color: bool, +) -> bool: + filenames = classifier.filenames_for_hook(hook) + + if hook.id in skips or hook.alias in skips: + output.write( + _full_msg( + start=hook.name, + end_msg=SKIPPED, + end_color=color.YELLOW, + use_color=use_color, + cols=cols, + ), + ) + duration = None + retcode = 0 + files_modified = False + out = b'' + elif not filenames and not hook.always_run: + output.write( + _full_msg( + start=hook.name, + postfix=NO_FILES, + end_msg=SKIPPED, + end_color=color.TURQUOISE, + use_color=use_color, + cols=cols, + ), + ) + duration = None + retcode = 0 + files_modified = False + out = b'' + else: + # print hook and dots first in case the hook takes a while to run + output.write(_start_msg(start=hook.name, end_len=6, cols=cols)) + + diff_cmd = ('git', 'diff', '--no-ext-diff') + diff_before = cmd_output_b(*diff_cmd, retcode=None) + if not hook.pass_filenames: + filenames = () + time_before = time.time() + language = languages[hook.language] + retcode, out = language.run_hook(hook, filenames, use_color) + duration = round(time.time() - time_before, 2) or 0 + diff_after = cmd_output_b(*diff_cmd, retcode=None) + + # if the hook makes changes, fail the commit + files_modified = diff_before != diff_after + + if retcode or files_modified: + print_color = color.RED + status = 'Failed' + else: + print_color = color.GREEN + status = 'Passed' + + output.write_line(color.format_color(status, print_color, use_color)) + + if verbose or hook.verbose or retcode or files_modified: + _subtle_line(f'- hook id: {hook.id}', use_color) + + if (verbose or hook.verbose) and duration is not None: + _subtle_line(f'- duration: {duration}s', use_color) + + if retcode: + _subtle_line(f'- exit code: {retcode}', use_color) + + # Print a message if failing due to file modifications + if files_modified: + _subtle_line('- files were modified by this hook', use_color) + + if out.strip(): + output.write_line() + output.write_line_b(out.strip(), logfile_name=hook.log_file) + output.write_line() + + return files_modified or bool(retcode) + + +def _compute_cols(hooks: Sequence[Hook]) -> int: + """Compute the number of columns to display hook messages. The widest + that will be displayed is in the no files skipped case: + + Hook name...(no files to check) Skipped + """ + if hooks: + name_len = max(len(hook.name) for hook in hooks) + else: + name_len = 0 + + cols = name_len + 3 + len(NO_FILES) + 1 + len(SKIPPED) + return max(cols, 80) + + +def _all_filenames(args: argparse.Namespace) -> Collection[str]: + if args.hook_stage == 'post-checkout': # no files for post-checkout + return () + elif args.hook_stage in {'prepare-commit-msg', 'commit-msg'}: + return (args.commit_msg_filename,) + elif args.from_ref and args.to_ref: + return git.get_changed_files(args.from_ref, args.to_ref) + elif args.files: + return args.files + elif args.all_files: + return git.get_all_files() + elif git.is_in_merge_conflict(): + return git.get_conflicted_files() + else: + return git.get_staged_files() + + +def _run_hooks( + config: Dict[str, Any], + hooks: Sequence[Hook], + args: argparse.Namespace, + environ: EnvironT, +) -> int: + """Actually run the hooks.""" + skips = _get_skips(environ) + cols = _compute_cols(hooks) + filenames = filter_by_include_exclude( + _all_filenames(args), config['files'], config['exclude'], + ) + classifier = Classifier(filenames) + retval = 0 + for hook in hooks: + retval |= _run_single_hook( + classifier, hook, skips, cols, + verbose=args.verbose, use_color=args.color, + ) + if retval and config['fail_fast']: + break + if retval and args.show_diff_on_failure and git.has_diff(): + if args.all_files: + output.write_line( + 'pre-commit hook(s) made changes.\n' + 'If you are seeing this message in CI, ' + 'reproduce locally with: `pre-commit run --all-files`.\n' + 'To run `pre-commit` as part of git workflow, use ' + '`pre-commit install`.', + ) + output.write_line('All changes made by hooks:') + # args.color is a boolean. + # See user_color function in color.py + git_color_opt = 'always' if args.color else 'never' + subprocess.call(( + 'git', '--no-pager', 'diff', '--no-ext-diff', + f'--color={git_color_opt}', + )) + + return retval + + +def _has_unmerged_paths() -> bool: + _, stdout, _ = cmd_output_b('git', 'ls-files', '--unmerged') + return bool(stdout.strip()) + + +def _has_unstaged_config(config_file: str) -> bool: + retcode, _, _ = cmd_output_b( + 'git', 'diff', '--no-ext-diff', '--exit-code', config_file, + retcode=None, + ) + # be explicit, other git errors don't mean it has an unstaged config. + return retcode == 1 + + +def run( + config_file: str, + store: Store, + args: argparse.Namespace, + environ: EnvironT = os.environ, +) -> int: + stash = not args.all_files and not args.files + + # Check if we have unresolved merge conflict files and fail fast. + if _has_unmerged_paths(): + logger.error('Unmerged files. Resolve before committing.') + return 1 + if bool(args.from_ref) != bool(args.to_ref): + logger.error('Specify both --from-ref and --to-ref.') + return 1 + if stash and _has_unstaged_config(config_file): + logger.error( + f'Your pre-commit configuration is unstaged.\n' + f'`git add {config_file}` to fix this.', + ) + return 1 + if ( + args.hook_stage in {'prepare-commit-msg', 'commit-msg'} and + not args.commit_msg_filename + ): + logger.error( + f'`--commit-msg-filename` is required for ' + f'`--hook-stage {args.hook_stage}`', + ) + return 1 + + # Expose from-ref / to-ref as environment variables for hooks to consume + if args.from_ref and args.to_ref: + # legacy names + environ['PRE_COMMIT_ORIGIN'] = args.from_ref + environ['PRE_COMMIT_SOURCE'] = args.to_ref + # new names + environ['PRE_COMMIT_FROM_REF'] = args.from_ref + environ['PRE_COMMIT_TO_REF'] = args.to_ref + + if args.remote_name and args.remote_url: + environ['PRE_COMMIT_REMOTE_NAME'] = args.remote_name + environ['PRE_COMMIT_REMOTE_URL'] = args.remote_url + + if args.checkout_type: + environ['PRE_COMMIT_CHECKOUT_TYPE'] = args.checkout_type + + with contextlib.ExitStack() as exit_stack: + if stash: + exit_stack.enter_context(staged_files_only(store.directory)) + + config = load_config(config_file) + hooks = [ + hook + for hook in all_hooks(config, store) + if not args.hook or hook.id == args.hook or hook.alias == args.hook + if args.hook_stage in hook.stages + ] + + if args.hook and not hooks: + output.write_line( + f'No hook with id `{args.hook}` in stage `{args.hook_stage}`', + ) + return 1 + + install_hook_envs(hooks, store) + + return _run_hooks(config, hooks, args, environ) + + # https://github.com/python/mypy/issues/7726 + raise AssertionError('unreachable') diff --git a/pre_commit/commands/sample_config.py b/pre_commit/commands/sample_config.py new file mode 100644 index 0000000..d435faa --- /dev/null +++ b/pre_commit/commands/sample_config.py @@ -0,0 +1,21 @@ +# TODO: maybe `git ls-remote git://github.com/pre-commit/pre-commit-hooks` to +# determine the latest revision? This adds ~200ms from my tests (and is +# significantly faster than https:// or http://). For now, periodically +# manually updating the revision is fine. +SAMPLE_CONFIG = '''\ +# 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: v2.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files +''' + + +def sample_config() -> int: + print(SAMPLE_CONFIG, end='') + return 0 diff --git a/pre_commit/commands/try_repo.py b/pre_commit/commands/try_repo.py new file mode 100644 index 0000000..4aee209 --- /dev/null +++ b/pre_commit/commands/try_repo.py @@ -0,0 +1,77 @@ +import argparse +import logging +import os.path +from typing import Optional +from typing import Tuple + +import pre_commit.constants as C +from pre_commit import git +from pre_commit import output +from pre_commit.clientlib import load_manifest +from pre_commit.commands.run import run +from pre_commit.store import Store +from pre_commit.util import cmd_output_b +from pre_commit.util import tmpdir +from pre_commit.util import yaml_dump +from pre_commit.xargs import xargs + +logger = logging.getLogger(__name__) + + +def _repo_ref(tmpdir: str, repo: str, ref: Optional[str]) -> Tuple[str, str]: + # if `ref` is explicitly passed, use it + if ref is not None: + return repo, ref + + ref = git.head_rev(repo) + # if it exists on disk, we'll try and clone it with the local changes + if os.path.exists(repo) and git.has_diff('HEAD', repo=repo): + logger.warning('Creating temporary repo with uncommitted changes...') + + shadow = os.path.join(tmpdir, 'shadow-repo') + cmd_output_b('git', 'clone', repo, shadow) + cmd_output_b('git', 'checkout', ref, '-b', '_pc_tmp', cwd=shadow) + + idx = git.git_path('index', repo=shadow) + objs = git.git_path('objects', repo=shadow) + env = dict(os.environ, GIT_INDEX_FILE=idx, GIT_OBJECT_DIRECTORY=objs) + + staged_files = git.get_staged_files(cwd=repo) + if staged_files: + xargs(('git', 'add', '--'), staged_files, cwd=repo, env=env) + + cmd_output_b('git', 'add', '-u', cwd=repo, env=env) + git.commit(repo=shadow) + + return shadow, git.head_rev(shadow) + else: + return repo, ref + + +def try_repo(args: argparse.Namespace) -> int: + with tmpdir() as tempdir: + repo, ref = _repo_ref(tempdir, args.repo, args.ref) + + store = Store(tempdir) + if args.hook: + hooks = [{'id': args.hook}] + else: + repo_path = store.clone(repo, ref) + manifest = load_manifest(os.path.join(repo_path, C.MANIFEST_FILE)) + manifest = sorted(manifest, key=lambda hook: hook['id']) + hooks = [{'id': hook['id']} for hook in manifest] + + config = {'repos': [{'repo': repo, 'rev': ref, 'hooks': hooks}]} + config_s = yaml_dump(config) + + config_filename = os.path.join(tempdir, C.CONFIG_FILE) + with open(config_filename, 'w') as cfg: + cfg.write(config_s) + + output.write_line('=' * 79) + output.write_line('Using config:') + output.write_line('=' * 79) + output.write(config_s) + output.write_line('=' * 79) + + return run(config_filename, store, args) diff --git a/pre_commit/constants.py b/pre_commit/constants.py new file mode 100644 index 0000000..e2b8e3a --- /dev/null +++ b/pre_commit/constants.py @@ -0,0 +1,24 @@ +import sys + +if sys.version_info < (3, 8): # pragma: no cover ( str: + return ''.join( + env.get(part.name, part.default) if isinstance(part, Var) else part + for part in parts + ) + + +@contextlib.contextmanager +def envcontext( + patch: PatchesT, + _env: Optional[EnvironT] = None, +) -> Generator[None, None, None]: + """In this context, `os.environ` is modified according to `patch`. + + `patch` is an iterable of 2-tuples (key, value): + `key`: string + `value`: + - string: `environ[key] == value` inside the context. + - UNSET: `key not in environ` inside the context. + - template: A template is a tuple of strings and Var which will be + replaced with the previous environment + """ + env = os.environ if _env is None else _env + before = env.copy() + + for k, v in patch: + if v is UNSET: + env.pop(k, None) + elif isinstance(v, tuple): + env[k] = format_env(v, before) + else: + env[k] = v + + try: + yield + finally: + env.clear() + env.update(before) diff --git a/pre_commit/error_handler.py b/pre_commit/error_handler.py new file mode 100644 index 0000000..b2321ae --- /dev/null +++ b/pre_commit/error_handler.py @@ -0,0 +1,64 @@ +import contextlib +import functools +import os.path +import sys +import traceback +from typing import Generator + +import pre_commit.constants as C +from pre_commit import output +from pre_commit.store import Store +from pre_commit.util import force_bytes + + +class FatalError(RuntimeError): + pass + + +def _log_and_exit(msg: str, exc: BaseException, formatted: str) -> None: + error_msg = f'{msg}: {type(exc).__name__}: '.encode() + force_bytes(exc) + output.write_line_b(error_msg) + log_path = os.path.join(Store().directory, 'pre-commit.log') + output.write_line(f'Check the log at {log_path}') + + with open(log_path, 'wb') as log: + _log_line = functools.partial(output.write_line, stream=log) + _log_line_b = functools.partial(output.write_line_b, stream=log) + + _log_line('### version information') + _log_line() + _log_line('```') + _log_line(f'pre-commit version: {C.VERSION}') + _log_line('sys.version:') + for line in sys.version.splitlines(): + _log_line(f' {line}') + _log_line(f'sys.executable: {sys.executable}') + _log_line(f'os.name: {os.name}') + _log_line(f'sys.platform: {sys.platform}') + _log_line('```') + _log_line() + + _log_line('### error information') + _log_line() + _log_line('```') + _log_line_b(error_msg) + _log_line('```') + _log_line() + _log_line('```') + _log_line(formatted) + _log_line('```') + raise SystemExit(1) + + +@contextlib.contextmanager +def error_handler() -> Generator[None, None, None]: + try: + yield + except (Exception, KeyboardInterrupt) as e: + if isinstance(e, FatalError): + msg = 'An error has occurred' + elif isinstance(e, KeyboardInterrupt): + msg = 'Interrupted (^C)' + else: + msg = 'An unexpected error has occurred' + _log_and_exit(msg, e, traceback.format_exc()) diff --git a/pre_commit/file_lock.py b/pre_commit/file_lock.py new file mode 100644 index 0000000..ff0dc5e --- /dev/null +++ b/pre_commit/file_lock.py @@ -0,0 +1,76 @@ +import contextlib +import errno +import os +from typing import Callable +from typing import Generator + + +if os.name == 'nt': # pragma: no cover (windows) + import msvcrt + + # https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/locking + + # on windows we lock "regions" of files, we don't care about the actual + # byte region so we'll just pick *some* number here. + _region = 0xffff + + @contextlib.contextmanager + def _locked( + fileno: int, + blocked_cb: Callable[[], None], + ) -> Generator[None, None, None]: + try: + # TODO: https://github.com/python/typeshed/pull/3607 + msvcrt.locking(fileno, msvcrt.LK_NBLCK, _region) # type: ignore + except OSError: + blocked_cb() + while True: + try: + # TODO: https://github.com/python/typeshed/pull/3607 + msvcrt.locking(fileno, msvcrt.LK_LOCK, _region) # type: ignore # noqa: E501 + except OSError as e: + # Locking violation. Returned when the _LK_LOCK or _LK_RLCK + # flag is specified and the file cannot be locked after 10 + # attempts. + if e.errno != errno.EDEADLOCK: + raise + else: + break + + try: + yield + finally: + # From cursory testing, it seems to get unlocked when the file is + # closed so this may not be necessary. + # The documentation however states: + # "Regions should be locked only briefly and should be unlocked + # before closing a file or exiting the program." + # TODO: https://github.com/python/typeshed/pull/3607 + msvcrt.locking(fileno, msvcrt.LK_UNLCK, _region) # type: ignore +else: # pragma: win32 no cover + import fcntl + + @contextlib.contextmanager + def _locked( + fileno: int, + blocked_cb: Callable[[], None], + ) -> Generator[None, None, None]: + try: + fcntl.flock(fileno, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: # pragma: no cover (tests are single-threaded) + blocked_cb() + fcntl.flock(fileno, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(fileno, fcntl.LOCK_UN) + + +@contextlib.contextmanager +def lock( + path: str, + blocked_cb: Callable[[], None], +) -> Generator[None, None, None]: + with open(path, 'a+') as f: + with _locked(f.fileno(), blocked_cb): + yield diff --git a/pre_commit/git.py b/pre_commit/git.py new file mode 100644 index 0000000..7e757f2 --- /dev/null +++ b/pre_commit/git.py @@ -0,0 +1,196 @@ +import logging +import os.path +import sys +from typing import Dict +from typing import List +from typing import Optional +from typing import Set + +from pre_commit.util import cmd_output +from pre_commit.util import cmd_output_b +from pre_commit.util import EnvironT + + +logger = logging.getLogger(__name__) + + +def zsplit(s: str) -> List[str]: + s = s.strip('\0') + if s: + return s.split('\0') + else: + return [] + + +def no_git_env(_env: Optional[EnvironT] = None) -> Dict[str, str]: + # Too many bugs dealing with environment variables and GIT: + # https://github.com/pre-commit/pre-commit/issues/300 + # In git 2.6.3 (maybe others), git exports GIT_WORK_TREE while running + # pre-commit hooks + # In git 1.9.1 (maybe others), git exports GIT_DIR and GIT_INDEX_FILE + # while running pre-commit hooks in submodules. + # GIT_DIR: Causes git clone to clone wrong thing + # GIT_INDEX_FILE: Causes 'error invalid object ...' during commit + _env = _env if _env is not None else os.environ + return { + k: v for k, v in _env.items() + if not k.startswith('GIT_') or + k in { + 'GIT_EXEC_PATH', 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_SSL_CAINFO', + 'GIT_SSL_NO_VERIFY', + } + } + + +def get_root() -> str: + return cmd_output('git', 'rev-parse', '--show-toplevel')[1].strip() + + +def get_git_dir(git_root: str = '.') -> str: + opts = ('--git-common-dir', '--git-dir') + _, out, _ = cmd_output('git', 'rev-parse', *opts, cwd=git_root) + for line, opt in zip(out.splitlines(), opts): + if line != opt: # pragma: no branch (git < 2.5) + return os.path.normpath(os.path.join(git_root, line)) + else: + raise AssertionError('unreachable: no git dir') + + +def get_remote_url(git_root: str) -> str: + _, out, _ = cmd_output('git', 'config', 'remote.origin.url', cwd=git_root) + return out.strip() + + +def is_in_merge_conflict() -> bool: + git_dir = get_git_dir('.') + return ( + os.path.exists(os.path.join(git_dir, 'MERGE_MSG')) and + os.path.exists(os.path.join(git_dir, 'MERGE_HEAD')) + ) + + +def parse_merge_msg_for_conflicts(merge_msg: bytes) -> List[str]: + # Conflicted files start with tabs + return [ + line.lstrip(b'#').strip().decode() + for line in merge_msg.splitlines() + # '#\t' for git 2.4.1 + if line.startswith((b'\t', b'#\t')) + ] + + +def get_conflicted_files() -> Set[str]: + logger.info('Checking merge-conflict files only.') + # Need to get the conflicted files from the MERGE_MSG because they could + # have resolved the conflict by choosing one side or the other + with open(os.path.join(get_git_dir('.'), 'MERGE_MSG'), 'rb') as f: + merge_msg = f.read() + merge_conflict_filenames = parse_merge_msg_for_conflicts(merge_msg) + + # This will get the rest of the changes made after the merge. + # If they resolved the merge conflict by choosing a mesh of both sides + # this will also include the conflicted files + tree_hash = cmd_output('git', 'write-tree')[1].strip() + merge_diff_filenames = zsplit( + cmd_output( + 'git', 'diff', '--name-only', '--no-ext-diff', '-z', + '-m', tree_hash, 'HEAD', 'MERGE_HEAD', + )[1], + ) + return set(merge_conflict_filenames) | set(merge_diff_filenames) + + +def get_staged_files(cwd: Optional[str] = None) -> List[str]: + return zsplit( + cmd_output( + 'git', 'diff', '--staged', '--name-only', '--no-ext-diff', '-z', + # Everything except for D + '--diff-filter=ACMRTUXB', + cwd=cwd, + )[1], + ) + + +def intent_to_add_files() -> List[str]: + _, stdout, _ = cmd_output('git', 'status', '--porcelain', '-z') + parts = list(reversed(zsplit(stdout))) + intent_to_add = [] + while parts: + line = parts.pop() + status, filename = line[:3], line[3:] + if status[0] in {'C', 'R'}: # renames / moves have an additional arg + parts.pop() + if status[1] == 'A': + intent_to_add.append(filename) + return intent_to_add + + +def get_all_files() -> List[str]: + return zsplit(cmd_output('git', 'ls-files', '-z')[1]) + + +def get_changed_files(old: str, new: str) -> List[str]: + return zsplit( + cmd_output( + 'git', 'diff', '--name-only', '--no-ext-diff', '-z', + f'{old}...{new}', + )[1], + ) + + +def head_rev(remote: str) -> str: + _, out, _ = cmd_output('git', 'ls-remote', '--exit-code', remote, 'HEAD') + return out.split()[0] + + +def has_diff(*args: str, repo: str = '.') -> bool: + cmd = ('git', 'diff', '--quiet', '--no-ext-diff', *args) + return cmd_output_b(*cmd, cwd=repo, retcode=None)[0] == 1 + + +def has_core_hookpaths_set() -> bool: + _, out, _ = cmd_output_b('git', 'config', 'core.hooksPath', retcode=None) + return bool(out.strip()) + + +def init_repo(path: str, remote: str) -> None: + if os.path.isdir(remote): + remote = os.path.abspath(remote) + + env = no_git_env() + cmd_output_b('git', 'init', path, env=env) + cmd_output_b('git', 'remote', 'add', 'origin', remote, cwd=path, env=env) + + +def commit(repo: str = '.') -> None: + env = no_git_env() + name, email = 'pre-commit', 'asottile+pre-commit@umich.edu' + env['GIT_AUTHOR_NAME'] = env['GIT_COMMITTER_NAME'] = name + env['GIT_AUTHOR_EMAIL'] = env['GIT_COMMITTER_EMAIL'] = email + cmd = ('git', 'commit', '--no-edit', '--no-gpg-sign', '-n', '-minit') + cmd_output_b(*cmd, cwd=repo, env=env) + + +def git_path(name: str, repo: str = '.') -> str: + _, out, _ = cmd_output('git', 'rev-parse', '--git-path', name, cwd=repo) + return os.path.join(repo, out.strip()) + + +def check_for_cygwin_mismatch() -> None: + """See https://github.com/pre-commit/pre-commit/issues/354""" + if sys.platform in ('cygwin', 'win32'): # pragma: no cover (windows) + is_cygwin_python = sys.platform == 'cygwin' + toplevel = cmd_output('git', 'rev-parse', '--show-toplevel')[1] + is_cygwin_git = toplevel.startswith('/') + + if is_cygwin_python ^ is_cygwin_git: + exe_type = {True: '(cygwin)', False: '(windows)'} + logger.warn( + f'pre-commit has detected a mix of cygwin python / git\n' + f'This combination is not supported, it is likely you will ' + f'receive an error later in the program.\n' + f'Make sure to use cygwin git+python while using cygwin\n' + f'These can be installed through the cygwin installer.\n' + f' - python {exe_type[is_cygwin_python]}\n' + f' - git {exe_type[is_cygwin_git]}\n', + ) diff --git a/pre_commit/hook.py b/pre_commit/hook.py new file mode 100644 index 0000000..b65ac42 --- /dev/null +++ b/pre_commit/hook.py @@ -0,0 +1,63 @@ +import logging +import shlex +from typing import Any +from typing import Dict +from typing import NamedTuple +from typing import Sequence +from typing import Tuple + +from pre_commit.prefix import Prefix + +logger = logging.getLogger('pre_commit') + + +class Hook(NamedTuple): + src: str + prefix: Prefix + id: str + name: str + entry: str + language: str + alias: str + files: str + exclude: str + types: Sequence[str] + exclude_types: Sequence[str] + additional_dependencies: Sequence[str] + args: Sequence[str] + always_run: bool + pass_filenames: bool + description: str + language_version: str + log_file: str + minimum_pre_commit_version: str + require_serial: bool + stages: Sequence[str] + verbose: bool + + @property + def cmd(self) -> Tuple[str, ...]: + return (*shlex.split(self.entry), *self.args) + + @property + def install_key(self) -> Tuple[Prefix, str, str, Tuple[str, ...]]: + return ( + self.prefix, + self.language, + self.language_version, + tuple(self.additional_dependencies), + ) + + @classmethod + def create(cls, src: str, prefix: Prefix, dct: Dict[str, Any]) -> 'Hook': + # TODO: have cfgv do this (?) + extra_keys = set(dct) - _KEYS + if extra_keys: + logger.warning( + f'Unexpected key(s) present on {src} => {dct["id"]}: ' + f'{", ".join(sorted(extra_keys))}', + ) + return cls(src=src, prefix=prefix, **{k: dct[k] for k in _KEYS}) + + +_KEYS = frozenset(set(Hook._fields) - {'src', 'prefix'}) diff --git a/pre_commit/languages/__init__.py b/pre_commit/languages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pre_commit/languages/all.py b/pre_commit/languages/all.py new file mode 100644 index 0000000..8f4ffa8 --- /dev/null +++ b/pre_commit/languages/all.py @@ -0,0 +1,60 @@ +from typing import Callable +from typing import NamedTuple +from typing import Optional +from typing import Sequence +from typing import Tuple + +from pre_commit.hook import Hook +from pre_commit.languages import conda +from pre_commit.languages import docker +from pre_commit.languages import docker_image +from pre_commit.languages import fail +from pre_commit.languages import golang +from pre_commit.languages import node +from pre_commit.languages import perl +from pre_commit.languages import pygrep +from pre_commit.languages import python +from pre_commit.languages import python_venv +from pre_commit.languages import ruby +from pre_commit.languages import rust +from pre_commit.languages import script +from pre_commit.languages import swift +from pre_commit.languages import system +from pre_commit.prefix import Prefix + + +class Language(NamedTuple): + name: str + # Use `None` for no installation / environment + ENVIRONMENT_DIR: Optional[str] + # return a value to replace `'default` for `language_version` + get_default_version: Callable[[], str] + # return whether the environment is healthy (or should be rebuilt) + healthy: Callable[[Prefix, str], bool] + # install a repository for the given language and language_version + install_environment: Callable[[Prefix, str, Sequence[str]], None] + # execute a hook and return the exit code and output + run_hook: 'Callable[[Hook, Sequence[str], bool], Tuple[int, bytes]]' + + +# TODO: back to modules + Protocol: https://github.com/python/mypy/issues/5018 +languages = { + # BEGIN GENERATED (testing/gen-languages-all) + 'conda': Language(name='conda', ENVIRONMENT_DIR=conda.ENVIRONMENT_DIR, get_default_version=conda.get_default_version, healthy=conda.healthy, install_environment=conda.install_environment, run_hook=conda.run_hook), # noqa: E501 + 'docker': Language(name='docker', ENVIRONMENT_DIR=docker.ENVIRONMENT_DIR, get_default_version=docker.get_default_version, healthy=docker.healthy, install_environment=docker.install_environment, run_hook=docker.run_hook), # noqa: E501 + 'docker_image': Language(name='docker_image', ENVIRONMENT_DIR=docker_image.ENVIRONMENT_DIR, get_default_version=docker_image.get_default_version, healthy=docker_image.healthy, install_environment=docker_image.install_environment, run_hook=docker_image.run_hook), # noqa: E501 + 'fail': Language(name='fail', ENVIRONMENT_DIR=fail.ENVIRONMENT_DIR, get_default_version=fail.get_default_version, healthy=fail.healthy, install_environment=fail.install_environment, run_hook=fail.run_hook), # noqa: E501 + 'golang': Language(name='golang', ENVIRONMENT_DIR=golang.ENVIRONMENT_DIR, get_default_version=golang.get_default_version, healthy=golang.healthy, install_environment=golang.install_environment, run_hook=golang.run_hook), # noqa: E501 + 'node': Language(name='node', ENVIRONMENT_DIR=node.ENVIRONMENT_DIR, get_default_version=node.get_default_version, healthy=node.healthy, install_environment=node.install_environment, run_hook=node.run_hook), # noqa: E501 + 'perl': Language(name='perl', ENVIRONMENT_DIR=perl.ENVIRONMENT_DIR, get_default_version=perl.get_default_version, healthy=perl.healthy, install_environment=perl.install_environment, run_hook=perl.run_hook), # noqa: E501 + 'pygrep': Language(name='pygrep', ENVIRONMENT_DIR=pygrep.ENVIRONMENT_DIR, get_default_version=pygrep.get_default_version, healthy=pygrep.healthy, install_environment=pygrep.install_environment, run_hook=pygrep.run_hook), # noqa: E501 + 'python': Language(name='python', ENVIRONMENT_DIR=python.ENVIRONMENT_DIR, get_default_version=python.get_default_version, healthy=python.healthy, install_environment=python.install_environment, run_hook=python.run_hook), # noqa: E501 + 'python_venv': Language(name='python_venv', ENVIRONMENT_DIR=python_venv.ENVIRONMENT_DIR, get_default_version=python_venv.get_default_version, healthy=python_venv.healthy, install_environment=python_venv.install_environment, run_hook=python_venv.run_hook), # noqa: E501 + 'ruby': Language(name='ruby', ENVIRONMENT_DIR=ruby.ENVIRONMENT_DIR, get_default_version=ruby.get_default_version, healthy=ruby.healthy, install_environment=ruby.install_environment, run_hook=ruby.run_hook), # noqa: E501 + 'rust': Language(name='rust', ENVIRONMENT_DIR=rust.ENVIRONMENT_DIR, get_default_version=rust.get_default_version, healthy=rust.healthy, install_environment=rust.install_environment, run_hook=rust.run_hook), # noqa: E501 + 'script': Language(name='script', ENVIRONMENT_DIR=script.ENVIRONMENT_DIR, get_default_version=script.get_default_version, healthy=script.healthy, install_environment=script.install_environment, run_hook=script.run_hook), # noqa: E501 + 'swift': Language(name='swift', ENVIRONMENT_DIR=swift.ENVIRONMENT_DIR, get_default_version=swift.get_default_version, healthy=swift.healthy, install_environment=swift.install_environment, run_hook=swift.run_hook), # noqa: E501 + 'system': Language(name='system', ENVIRONMENT_DIR=system.ENVIRONMENT_DIR, get_default_version=system.get_default_version, healthy=system.healthy, install_environment=system.install_environment, run_hook=system.run_hook), # noqa: E501 + # END GENERATED +} +all_languages = sorted(languages) diff --git a/pre_commit/languages/conda.py b/pre_commit/languages/conda.py new file mode 100644 index 0000000..071757a --- /dev/null +++ b/pre_commit/languages/conda.py @@ -0,0 +1,84 @@ +import contextlib +import os +from typing import Generator +from typing import Sequence +from typing import Tuple + +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT +from pre_commit.envcontext import SubstitutionT +from pre_commit.envcontext import UNSET +from pre_commit.envcontext import Var +from pre_commit.hook import Hook +from pre_commit.languages import helpers +from pre_commit.prefix import Prefix +from pre_commit.util import clean_path_on_failure +from pre_commit.util import cmd_output_b + +ENVIRONMENT_DIR = 'conda' +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy + + +def get_env_patch(env: str) -> PatchesT: + # On non-windows systems executable live in $CONDA_PREFIX/bin, on Windows + # they can be in $CONDA_PREFIX/bin, $CONDA_PREFIX/Library/bin, + # $CONDA_PREFIX/Scripts and $CONDA_PREFIX. Whereas the latter only + # seems to be used for python.exe. + path: SubstitutionT = (os.path.join(env, 'bin'), os.pathsep, Var('PATH')) + if os.name == 'nt': # pragma: no cover (platform specific) + path = (env, os.pathsep, *path) + path = (os.path.join(env, 'Scripts'), os.pathsep, *path) + path = (os.path.join(env, 'Library', 'bin'), os.pathsep, *path) + + return ( + ('PYTHONHOME', UNSET), + ('VIRTUAL_ENV', UNSET), + ('CONDA_PREFIX', env), + ('PATH', path), + ) + + +@contextlib.contextmanager +def in_env( + prefix: Prefix, + language_version: str, +) -> Generator[None, None, None]: + directory = helpers.environment_dir(ENVIRONMENT_DIR, language_version) + envdir = prefix.path(directory) + with envcontext(get_env_patch(envdir)): + yield + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + helpers.assert_version_default('conda', version) + directory = helpers.environment_dir(ENVIRONMENT_DIR, version) + + env_dir = prefix.path(directory) + with clean_path_on_failure(env_dir): + cmd_output_b( + 'conda', 'env', 'create', '-p', env_dir, '--file', + 'environment.yml', cwd=prefix.prefix_dir, + ) + if additional_dependencies: + cmd_output_b( + 'conda', 'install', '-p', env_dir, *additional_dependencies, + cwd=prefix.prefix_dir, + ) + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: + # TODO: Some rare commands need to be run using `conda run` but mostly we + # can run them withot which is much quicker and produces a better + # output. + # cmd = ('conda', 'run', '-p', env_dir) + hook.cmd + with in_env(hook.prefix, hook.language_version): + return helpers.run_xargs(hook, hook.cmd, file_args, color=color) diff --git a/pre_commit/languages/docker.py b/pre_commit/languages/docker.py new file mode 100644 index 0000000..f449584 --- /dev/null +++ b/pre_commit/languages/docker.py @@ -0,0 +1,114 @@ +import hashlib +import os +from typing import Sequence +from typing import Tuple + +import pre_commit.constants as C +from pre_commit.hook import Hook +from pre_commit.languages import helpers +from pre_commit.prefix import Prefix +from pre_commit.util import CalledProcessError +from pre_commit.util import clean_path_on_failure +from pre_commit.util import cmd_output_b + +ENVIRONMENT_DIR = 'docker' +PRE_COMMIT_LABEL = 'PRE_COMMIT' +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy + + +def md5(s: str) -> str: # pragma: win32 no cover + return hashlib.md5(s.encode()).hexdigest() + + +def docker_tag(prefix: Prefix) -> str: # pragma: win32 no cover + md5sum = md5(os.path.basename(prefix.prefix_dir)).lower() + return f'pre-commit-{md5sum}' + + +def docker_is_running() -> bool: # pragma: win32 no cover + try: + cmd_output_b('docker', 'ps') + except CalledProcessError: + return False + else: + return True + + +def assert_docker_available() -> None: # pragma: win32 no cover + assert docker_is_running(), ( + 'Docker is either not running or not configured in this environment' + ) + + +def build_docker_image( + prefix: Prefix, + *, + pull: bool, +) -> None: # pragma: win32 no cover + cmd: Tuple[str, ...] = ( + 'docker', 'build', + '--tag', docker_tag(prefix), + '--label', PRE_COMMIT_LABEL, + ) + if pull: + cmd += ('--pull',) + # This must come last for old versions of docker. See #477 + cmd += ('.',) + helpers.run_setup_cmd(prefix, cmd) + + +def install_environment( + prefix: Prefix, version: str, additional_dependencies: Sequence[str], +) -> None: # pragma: win32 no cover + helpers.assert_version_default('docker', version) + helpers.assert_no_additional_deps('docker', additional_dependencies) + assert_docker_available() + + directory = prefix.path( + helpers.environment_dir(ENVIRONMENT_DIR, C.DEFAULT), + ) + + # Docker doesn't really have relevant disk environment, but pre-commit + # still needs to cleanup its state files on failure + with clean_path_on_failure(directory): + build_docker_image(prefix, pull=True) + os.mkdir(directory) + + +def get_docker_user() -> str: # pragma: win32 no cover + try: + return f'{os.getuid()}:{os.getgid()}' + except AttributeError: + return '1000:1000' + + +def docker_cmd() -> Tuple[str, ...]: # pragma: win32 no cover + return ( + 'docker', 'run', + '--rm', + '-u', get_docker_user(), + # https://docs.docker.com/engine/reference/commandline/run/#mount-volumes-from-container-volumes-from + # The `Z` option tells Docker to label the content with a private + # unshared label. Only the current container can use a private volume. + '-v', f'{os.getcwd()}:/src:rw,Z', + '--workdir', '/src', + ) + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: # pragma: win32 no cover + assert_docker_available() + # Rebuild the docker image in case it has gone missing, as many people do + # automated cleanup of docker images. + build_docker_image(hook.prefix, pull=False) + + hook_cmd = hook.cmd + entry_exe, cmd_rest = hook.cmd[0], hook_cmd[1:] + + entry_tag = ('--entrypoint', entry_exe, docker_tag(hook.prefix)) + cmd = docker_cmd() + entry_tag + cmd_rest + return helpers.run_xargs(hook, cmd, file_args, color=color) diff --git a/pre_commit/languages/docker_image.py b/pre_commit/languages/docker_image.py new file mode 100644 index 0000000..0c51df6 --- /dev/null +++ b/pre_commit/languages/docker_image.py @@ -0,0 +1,22 @@ +from typing import Sequence +from typing import Tuple + +from pre_commit.hook import Hook +from pre_commit.languages import helpers +from pre_commit.languages.docker import assert_docker_available +from pre_commit.languages.docker import docker_cmd + +ENVIRONMENT_DIR = None +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy +install_environment = helpers.no_install + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: # pragma: win32 no cover + assert_docker_available() + cmd = docker_cmd() + hook.cmd + return helpers.run_xargs(hook, cmd, file_args, color=color) diff --git a/pre_commit/languages/fail.py b/pre_commit/languages/fail.py new file mode 100644 index 0000000..d2b02d2 --- /dev/null +++ b/pre_commit/languages/fail.py @@ -0,0 +1,20 @@ +from typing import Sequence +from typing import Tuple + +from pre_commit.hook import Hook +from pre_commit.languages import helpers + +ENVIRONMENT_DIR = None +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy +install_environment = helpers.no_install + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: + out = f'{hook.entry}\n\n'.encode() + out += b'\n'.join(f.encode() for f in file_args) + b'\n' + return 1, out diff --git a/pre_commit/languages/golang.py b/pre_commit/languages/golang.py new file mode 100644 index 0000000..91ade1e --- /dev/null +++ b/pre_commit/languages/golang.py @@ -0,0 +1,97 @@ +import contextlib +import os.path +import sys +from typing import Generator +from typing import Sequence +from typing import Tuple + +import pre_commit.constants as C +from pre_commit import git +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT +from pre_commit.envcontext import Var +from pre_commit.hook import Hook +from pre_commit.languages import helpers +from pre_commit.prefix import Prefix +from pre_commit.util import clean_path_on_failure +from pre_commit.util import cmd_output +from pre_commit.util import cmd_output_b +from pre_commit.util import rmtree + +ENVIRONMENT_DIR = 'golangenv' +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy + + +def get_env_patch(venv: str) -> PatchesT: + return ( + ('PATH', (os.path.join(venv, 'bin'), os.pathsep, Var('PATH'))), + ) + + +@contextlib.contextmanager +def in_env(prefix: Prefix) -> Generator[None, None, None]: + envdir = prefix.path( + helpers.environment_dir(ENVIRONMENT_DIR, C.DEFAULT), + ) + with envcontext(get_env_patch(envdir)): + yield + + +def guess_go_dir(remote_url: str) -> str: + if remote_url.endswith('.git'): + remote_url = remote_url[:-1 * len('.git')] + looks_like_url = ( + not remote_url.startswith('file://') and + ('//' in remote_url or '@' in remote_url) + ) + remote_url = remote_url.replace(':', '/') + if looks_like_url: + _, _, remote_url = remote_url.rpartition('//') + _, _, remote_url = remote_url.rpartition('@') + return remote_url + else: + return 'unknown_src_dir' + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + helpers.assert_version_default('golang', version) + directory = prefix.path( + helpers.environment_dir(ENVIRONMENT_DIR, C.DEFAULT), + ) + + with clean_path_on_failure(directory): + remote = git.get_remote_url(prefix.prefix_dir) + repo_src_dir = os.path.join(directory, 'src', guess_go_dir(remote)) + + # Clone into the goenv we'll create + helpers.run_setup_cmd(prefix, ('git', 'clone', '.', repo_src_dir)) + + if sys.platform == 'cygwin': # pragma: no cover + _, gopath, _ = cmd_output('cygpath', '-w', directory) + gopath = gopath.strip() + else: + gopath = directory + env = dict(os.environ, GOPATH=gopath) + env.pop('GOBIN', None) + cmd_output_b('go', 'get', './...', cwd=repo_src_dir, env=env) + for dependency in additional_dependencies: + cmd_output_b('go', 'get', dependency, cwd=repo_src_dir, env=env) + # Same some disk space, we don't need these after installation + rmtree(prefix.path(directory, 'src')) + pkgdir = prefix.path(directory, 'pkg') + if os.path.exists(pkgdir): # pragma: no cover (go<1.10) + rmtree(pkgdir) + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: + with in_env(hook.prefix): + return helpers.run_xargs(hook, hook.cmd, file_args, color=color) diff --git a/pre_commit/languages/helpers.py b/pre_commit/languages/helpers.py new file mode 100644 index 0000000..b5c95e5 --- /dev/null +++ b/pre_commit/languages/helpers.py @@ -0,0 +1,109 @@ +import multiprocessing +import os +import random +from typing import Any +from typing import List +from typing import Optional +from typing import overload +from typing import Sequence +from typing import Tuple +from typing import TYPE_CHECKING + +import pre_commit.constants as C +from pre_commit.hook import Hook +from pre_commit.prefix import Prefix +from pre_commit.util import cmd_output_b +from pre_commit.xargs import xargs + +if TYPE_CHECKING: + from typing import NoReturn + +FIXED_RANDOM_SEED = 1542676186 + + +def run_setup_cmd(prefix: Prefix, cmd: Tuple[str, ...]) -> None: + cmd_output_b(*cmd, cwd=prefix.prefix_dir) + + +@overload +def environment_dir(d: None, language_version: str) -> None: ... +@overload +def environment_dir(d: str, language_version: str) -> str: ... + + +def environment_dir(d: Optional[str], language_version: str) -> Optional[str]: + if d is None: + return None + else: + return f'{d}-{language_version}' + + +def assert_version_default(binary: str, version: str) -> None: + if version != C.DEFAULT: + raise AssertionError( + f'For now, pre-commit requires system-installed {binary}', + ) + + +def assert_no_additional_deps( + lang: str, + additional_deps: Sequence[str], +) -> None: + if additional_deps: + raise AssertionError( + f'For now, pre-commit does not support ' + f'additional_dependencies for {lang}', + ) + + +def basic_get_default_version() -> str: + return C.DEFAULT + + +def basic_healthy(prefix: Prefix, language_version: str) -> bool: + return True + + +def no_install( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> 'NoReturn': + raise AssertionError('This type is not installable') + + +def target_concurrency(hook: Hook) -> int: + if hook.require_serial or 'PRE_COMMIT_NO_CONCURRENCY' in os.environ: + return 1 + else: + # Travis appears to have a bunch of CPUs, but we can't use them all. + if 'TRAVIS' in os.environ: + return 2 + else: + try: + return multiprocessing.cpu_count() + except NotImplementedError: + return 1 + + +def _shuffled(seq: Sequence[str]) -> List[str]: + """Deterministically shuffle""" + fixed_random = random.Random() + fixed_random.seed(FIXED_RANDOM_SEED, version=1) + + seq = list(seq) + random.shuffle(seq, random=fixed_random.random) + return seq + + +def run_xargs( + hook: Hook, + cmd: Tuple[str, ...], + file_args: Sequence[str], + **kwargs: Any, +) -> Tuple[int, bytes]: + # Shuffle the files so that they more evenly fill out the xargs partitions, + # but do it deterministically in case a hook cares about ordering. + file_args = _shuffled(file_args) + kwargs['target_concurrency'] = target_concurrency(hook) + return xargs(cmd, file_args, **kwargs) diff --git a/pre_commit/languages/node.py b/pre_commit/languages/node.py new file mode 100644 index 0000000..79ff807 --- /dev/null +++ b/pre_commit/languages/node.py @@ -0,0 +1,93 @@ +import contextlib +import os +import sys +from typing import Generator +from typing import Sequence +from typing import Tuple + +import pre_commit.constants as C +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT +from pre_commit.envcontext import Var +from pre_commit.hook import Hook +from pre_commit.languages import helpers +from pre_commit.languages.python import bin_dir +from pre_commit.prefix import Prefix +from pre_commit.util import clean_path_on_failure +from pre_commit.util import cmd_output +from pre_commit.util import cmd_output_b + +ENVIRONMENT_DIR = 'node_env' +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy + + +def _envdir(prefix: Prefix, version: str) -> str: + directory = helpers.environment_dir(ENVIRONMENT_DIR, version) + return prefix.path(directory) + + +def get_env_patch(venv: str) -> PatchesT: + if sys.platform == 'cygwin': # pragma: no cover + _, win_venv, _ = cmd_output('cygpath', '-w', venv) + install_prefix = fr'{win_venv.strip()}\bin' + lib_dir = 'lib' + elif sys.platform == 'win32': # pragma: no cover + install_prefix = bin_dir(venv) + lib_dir = 'Scripts' + else: # pragma: win32 no cover + install_prefix = venv + lib_dir = 'lib' + return ( + ('NODE_VIRTUAL_ENV', venv), + ('NPM_CONFIG_PREFIX', install_prefix), + ('npm_config_prefix', install_prefix), + ('NODE_PATH', os.path.join(venv, lib_dir, 'node_modules')), + ('PATH', (bin_dir(venv), os.pathsep, Var('PATH'))), + ) + + +@contextlib.contextmanager +def in_env( + prefix: Prefix, + language_version: str, +) -> Generator[None, None, None]: + with envcontext(get_env_patch(_envdir(prefix, language_version))): + yield + + +def install_environment( + prefix: Prefix, version: str, additional_dependencies: Sequence[str], +) -> None: + additional_dependencies = tuple(additional_dependencies) + assert prefix.exists('package.json') + envdir = _envdir(prefix, version) + + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx?f=255&MSPPError=-2147217396#maxpath + if sys.platform == 'win32': # pragma: no cover + envdir = f'\\\\?\\{os.path.normpath(envdir)}' + with clean_path_on_failure(envdir): + cmd = [ + sys.executable, '-mnodeenv', '--prebuilt', '--clean-src', envdir, + ] + if version != C.DEFAULT: + cmd.extend(['-n', version]) + cmd_output_b(*cmd) + + with in_env(prefix, version): + # https://npm.community/t/npm-install-g-git-vs-git-clone-cd-npm-install-g/5449 + # install as if we installed from git + helpers.run_setup_cmd(prefix, ('npm', 'install')) + helpers.run_setup_cmd( + prefix, + ('npm', 'install', '-g', '.', *additional_dependencies), + ) + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: + with in_env(hook.prefix, hook.language_version): + return helpers.run_xargs(hook, hook.cmd, file_args, color=color) diff --git a/pre_commit/languages/perl.py b/pre_commit/languages/perl.py new file mode 100644 index 0000000..bbf5504 --- /dev/null +++ b/pre_commit/languages/perl.py @@ -0,0 +1,67 @@ +import contextlib +import os +import shlex +from typing import Generator +from typing import Sequence +from typing import Tuple + +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT +from pre_commit.envcontext import Var +from pre_commit.hook import Hook +from pre_commit.languages import helpers +from pre_commit.prefix import Prefix +from pre_commit.util import clean_path_on_failure + +ENVIRONMENT_DIR = 'perl_env' +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy + + +def _envdir(prefix: Prefix, version: str) -> str: + directory = helpers.environment_dir(ENVIRONMENT_DIR, version) + return prefix.path(directory) + + +def get_env_patch(venv: str) -> PatchesT: + return ( + ('PATH', (os.path.join(venv, 'bin'), os.pathsep, Var('PATH'))), + ('PERL5LIB', os.path.join(venv, 'lib', 'perl5')), + ('PERL_MB_OPT', f'--install_base {shlex.quote(venv)}'), + ( + 'PERL_MM_OPT', ( + f'INSTALL_BASE={shlex.quote(venv)} ' + f'INSTALLSITEMAN1DIR=none INSTALLSITEMAN3DIR=none' + ), + ), + ) + + +@contextlib.contextmanager +def in_env( + prefix: Prefix, + language_version: str, +) -> Generator[None, None, None]: + with envcontext(get_env_patch(_envdir(prefix, language_version))): + yield + + +def install_environment( + prefix: Prefix, version: str, additional_dependencies: Sequence[str], +) -> None: + helpers.assert_version_default('perl', version) + + with clean_path_on_failure(_envdir(prefix, version)): + with in_env(prefix, version): + helpers.run_setup_cmd( + prefix, ('cpan', '-T', '.', *additional_dependencies), + ) + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: + with in_env(hook.prefix, hook.language_version): + return helpers.run_xargs(hook, hook.cmd, file_args, color=color) diff --git a/pre_commit/languages/pygrep.py b/pre_commit/languages/pygrep.py new file mode 100644 index 0000000..40adba0 --- /dev/null +++ b/pre_commit/languages/pygrep.py @@ -0,0 +1,87 @@ +import argparse +import re +import sys +from typing import Optional +from typing import Pattern +from typing import Sequence +from typing import Tuple + +from pre_commit import output +from pre_commit.hook import Hook +from pre_commit.languages import helpers +from pre_commit.xargs import xargs + +ENVIRONMENT_DIR = None +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy +install_environment = helpers.no_install + + +def _process_filename_by_line(pattern: Pattern[bytes], filename: str) -> int: + retv = 0 + with open(filename, 'rb') as f: + for line_no, line in enumerate(f, start=1): + if pattern.search(line): + retv = 1 + output.write(f'{filename}:{line_no}:') + output.write_line_b(line.rstrip(b'\r\n')) + return retv + + +def _process_filename_at_once(pattern: Pattern[bytes], filename: str) -> int: + retv = 0 + with open(filename, 'rb') as f: + contents = f.read() + match = pattern.search(contents) + if match: + retv = 1 + line_no = contents[:match.start()].count(b'\n') + output.write(f'{filename}:{line_no + 1}:') + + matched_lines = match[0].split(b'\n') + matched_lines[0] = contents.split(b'\n')[line_no] + + output.write_line_b(b'\n'.join(matched_lines)) + return retv + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: + exe = (sys.executable, '-m', __name__) + tuple(hook.args) + (hook.entry,) + return xargs(exe, file_args, color=color) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser( + description=( + 'grep-like finder using python regexes. Unlike grep, this tool ' + 'returns nonzero when it finds a match and zero otherwise. The ' + 'idea here being that matches are "problems".' + ), + ) + parser.add_argument('-i', '--ignore-case', action='store_true') + parser.add_argument('--multiline', action='store_true') + parser.add_argument('pattern', help='python regex pattern.') + parser.add_argument('filenames', nargs='*') + args = parser.parse_args(argv) + + flags = re.IGNORECASE if args.ignore_case else 0 + if args.multiline: + flags |= re.MULTILINE | re.DOTALL + + pattern = re.compile(args.pattern.encode(), flags) + + retv = 0 + for filename in args.filenames: + if args.multiline: + retv |= _process_filename_at_once(pattern, filename) + else: + retv |= _process_filename_by_line(pattern, filename) + return retv + + +if __name__ == '__main__': + exit(main()) diff --git a/pre_commit/languages/python.py b/pre_commit/languages/python.py new file mode 100644 index 0000000..5073a8b --- /dev/null +++ b/pre_commit/languages/python.py @@ -0,0 +1,210 @@ +import contextlib +import functools +import os +import sys +from typing import Callable +from typing import ContextManager +from typing import Generator +from typing import Optional +from typing import Sequence +from typing import Tuple + +import pre_commit.constants as C +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT +from pre_commit.envcontext import UNSET +from pre_commit.envcontext import Var +from pre_commit.hook import Hook +from pre_commit.languages import helpers +from pre_commit.parse_shebang import find_executable +from pre_commit.prefix import Prefix +from pre_commit.util import CalledProcessError +from pre_commit.util import clean_path_on_failure +from pre_commit.util import cmd_output +from pre_commit.util import cmd_output_b + +ENVIRONMENT_DIR = 'py_env' + + +def bin_dir(venv: str) -> str: + """On windows there's a different directory for the virtualenv""" + bin_part = 'Scripts' if os.name == 'nt' else 'bin' + return os.path.join(venv, bin_part) + + +def get_env_patch(venv: str) -> PatchesT: + return ( + ('PYTHONHOME', UNSET), + ('VIRTUAL_ENV', venv), + ('PATH', (bin_dir(venv), os.pathsep, Var('PATH'))), + ) + + +def _find_by_py_launcher( + version: str, +) -> Optional[str]: # pragma: no cover (windows only) + if version.startswith('python'): + num = version[len('python'):] + try: + cmd = ('py', f'-{num}', '-c', 'import sys; print(sys.executable)') + return cmd_output(*cmd)[1].strip() + except CalledProcessError: + pass + return None + + +def _find_by_sys_executable() -> Optional[str]: + def _norm(path: str) -> Optional[str]: + _, exe = os.path.split(path.lower()) + exe, _, _ = exe.partition('.exe') + if exe not in {'python', 'pythonw'} and find_executable(exe): + return exe + return None + + # On linux, I see these common sys.executables: + # + # system `python`: /usr/bin/python -> python2.7 + # system `python2`: /usr/bin/python2 -> python2.7 + # virtualenv v: v/bin/python (will not return from this loop) + # virtualenv v -ppython2: v/bin/python -> python2 + # virtualenv v -ppython2.7: v/bin/python -> python2.7 + # virtualenv v -ppypy: v/bin/python -> v/bin/pypy + for path in (sys.executable, os.path.realpath(sys.executable)): + exe = _norm(path) + if exe: + return exe + return None + + +@functools.lru_cache(maxsize=1) +def get_default_version() -> str: # pragma: no cover (platform dependent) + # First attempt from `sys.executable` (or the realpath) + exe = _find_by_sys_executable() + if exe: + return exe + + # Next try the `pythonX.X` executable + exe = f'python{sys.version_info[0]}.{sys.version_info[1]}' + if find_executable(exe): + return exe + + if _find_by_py_launcher(exe): + return exe + + # Give a best-effort try for windows + default_folder_name = exe.replace('.', '') + if os.path.exists(fr'C:\{default_folder_name}\python.exe'): + return exe + + # We tried! + return C.DEFAULT + + +def _sys_executable_matches(version: str) -> bool: + if version == 'python': + return True + elif not version.startswith('python'): + return False + + try: + info = tuple(int(p) for p in version[len('python'):].split('.')) + except ValueError: + return False + + return sys.version_info[:len(info)] == info + + +def norm_version(version: str) -> str: + # first see if our current executable is appropriate + if _sys_executable_matches(version): + return sys.executable + + if os.name == 'nt': # pragma: no cover (windows) + version_exec = _find_by_py_launcher(version) + if version_exec: + return version_exec + + # Try looking up by name + version_exec = find_executable(version) + if version_exec and version_exec != version: + return version_exec + + # If it is in the form pythonx.x search in the default + # place on windows + if version.startswith('python'): + default_folder_name = version.replace('.', '') + return fr'C:\{default_folder_name}\python.exe' + + # Otherwise assume it is a path + return os.path.expanduser(version) + + +def py_interface( + _dir: str, + _make_venv: Callable[[str, str], None], +) -> Tuple[ + Callable[[Prefix, str], ContextManager[None]], + Callable[[Prefix, str], bool], + Callable[[Hook, Sequence[str], bool], Tuple[int, bytes]], + Callable[[Prefix, str, Sequence[str]], None], +]: + @contextlib.contextmanager + def in_env( + prefix: Prefix, + language_version: str, + ) -> Generator[None, None, None]: + envdir = prefix.path(helpers.environment_dir(_dir, language_version)) + with envcontext(get_env_patch(envdir)): + yield + + def healthy(prefix: Prefix, language_version: str) -> bool: + envdir = helpers.environment_dir(_dir, language_version) + exe_name = 'python.exe' if sys.platform == 'win32' else 'python' + py_exe = prefix.path(bin_dir(envdir), exe_name) + with in_env(prefix, language_version): + retcode, _, _ = cmd_output_b( + py_exe, '-c', 'import ctypes, datetime, io, os, ssl, weakref', + cwd='/', + retcode=None, + ) + return retcode == 0 + + def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, + ) -> Tuple[int, bytes]: + with in_env(hook.prefix, hook.language_version): + return helpers.run_xargs(hook, hook.cmd, file_args, color=color) + + def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], + ) -> None: + additional_dependencies = tuple(additional_dependencies) + directory = helpers.environment_dir(_dir, version) + + env_dir = prefix.path(directory) + with clean_path_on_failure(env_dir): + if version != C.DEFAULT: + python = norm_version(version) + else: + python = os.path.realpath(sys.executable) + _make_venv(env_dir, python) + with in_env(prefix, version): + helpers.run_setup_cmd( + prefix, ('pip', 'install', '.') + additional_dependencies, + ) + + return in_env, healthy, run_hook, install_environment + + +def make_venv(envdir: str, python: str) -> None: + env = dict(os.environ, VIRTUALENV_NO_DOWNLOAD='1') + cmd = (sys.executable, '-mvirtualenv', envdir, '-p', python) + cmd_output_b(*cmd, env=env, cwd='/') + + +_interface = py_interface(ENVIRONMENT_DIR, make_venv) +in_env, healthy, run_hook, install_environment = _interface diff --git a/pre_commit/languages/python_venv.py b/pre_commit/languages/python_venv.py new file mode 100644 index 0000000..5404c8b --- /dev/null +++ b/pre_commit/languages/python_venv.py @@ -0,0 +1,46 @@ +import os.path + +from pre_commit.languages import python +from pre_commit.util import CalledProcessError +from pre_commit.util import cmd_output +from pre_commit.util import cmd_output_b + +ENVIRONMENT_DIR = 'py_venv' +get_default_version = python.get_default_version + + +def orig_py_exe(exe: str) -> str: # pragma: no cover (platform specific) + """A -mvenv virtualenv made from a -mvirtualenv virtualenv installs + packages to the incorrect location. Attempt to find the _original_ exe + and invoke `-mvenv` from there. + + See: + - https://github.com/pre-commit/pre-commit/issues/755 + - https://github.com/pypa/virtualenv/issues/1095 + - https://bugs.python.org/issue30811 + """ + try: + prefix_script = 'import sys; print(sys.real_prefix)' + _, prefix, _ = cmd_output(exe, '-c', prefix_script) + prefix = prefix.strip() + except CalledProcessError: + # not created from -mvirtualenv + return exe + + if os.name == 'nt': + expected = os.path.join(prefix, 'python.exe') + else: + expected = os.path.join(prefix, 'bin', os.path.basename(exe)) + + if os.path.exists(expected): + return expected + else: + return exe + + +def make_venv(envdir: str, python: str) -> None: + cmd_output_b(orig_py_exe(python), '-mvenv', envdir, cwd='/') + + +_interface = python.py_interface(ENVIRONMENT_DIR, make_venv) +in_env, healthy, run_hook, install_environment = _interface diff --git a/pre_commit/languages/ruby.py b/pre_commit/languages/ruby.py new file mode 100644 index 0000000..61241f8 --- /dev/null +++ b/pre_commit/languages/ruby.py @@ -0,0 +1,126 @@ +import contextlib +import os.path +import shutil +import tarfile +from typing import Generator +from typing import Sequence +from typing import Tuple + +import pre_commit.constants as C +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT +from pre_commit.envcontext import Var +from pre_commit.hook import Hook +from pre_commit.languages import helpers +from pre_commit.prefix import Prefix +from pre_commit.util import CalledProcessError +from pre_commit.util import clean_path_on_failure +from pre_commit.util import resource_bytesio + +ENVIRONMENT_DIR = 'rbenv' +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy + + +def get_env_patch( + venv: str, + language_version: str, +) -> PatchesT: # pragma: win32 no cover + patches: PatchesT = ( + ('GEM_HOME', os.path.join(venv, 'gems')), + ('RBENV_ROOT', venv), + ('BUNDLE_IGNORE_CONFIG', '1'), + ( + 'PATH', ( + os.path.join(venv, 'gems', 'bin'), os.pathsep, + os.path.join(venv, 'shims'), os.pathsep, + os.path.join(venv, 'bin'), os.pathsep, Var('PATH'), + ), + ), + ) + if language_version != C.DEFAULT: + patches += (('RBENV_VERSION', language_version),) + return patches + + +@contextlib.contextmanager # pragma: win32 no cover +def in_env( + prefix: Prefix, + language_version: str, +) -> Generator[None, None, None]: + envdir = prefix.path( + helpers.environment_dir(ENVIRONMENT_DIR, language_version), + ) + with envcontext(get_env_patch(envdir, language_version)): + yield + + +def _extract_resource(filename: str, dest: str) -> None: + with resource_bytesio(filename) as bio: + with tarfile.open(fileobj=bio) as tf: + tf.extractall(dest) + + +def _install_rbenv( + prefix: Prefix, + version: str = C.DEFAULT, +) -> None: # pragma: win32 no cover + directory = helpers.environment_dir(ENVIRONMENT_DIR, version) + + _extract_resource('rbenv.tar.gz', prefix.path('.')) + shutil.move(prefix.path('rbenv'), prefix.path(directory)) + + # Only install ruby-build if the version is specified + if version != C.DEFAULT: + plugins_dir = prefix.path(directory, 'plugins') + _extract_resource('ruby-download.tar.gz', plugins_dir) + _extract_resource('ruby-build.tar.gz', plugins_dir) + + +def _install_ruby( + prefix: Prefix, + version: str, +) -> None: # pragma: win32 no cover + try: + helpers.run_setup_cmd(prefix, ('rbenv', 'download', version)) + except CalledProcessError: # pragma: no cover (usually find with download) + # Failed to download from mirror for some reason, build it instead + helpers.run_setup_cmd(prefix, ('rbenv', 'install', version)) + + +def install_environment( + prefix: Prefix, version: str, additional_dependencies: Sequence[str], +) -> None: # pragma: win32 no cover + additional_dependencies = tuple(additional_dependencies) + directory = helpers.environment_dir(ENVIRONMENT_DIR, version) + with clean_path_on_failure(prefix.path(directory)): + # TODO: this currently will fail if there's no version specified and + # there's no system ruby installed. Is this ok? + _install_rbenv(prefix, version=version) + with in_env(prefix, version): + # Need to call this before installing so rbenv's directories are + # set up + helpers.run_setup_cmd(prefix, ('rbenv', 'init', '-')) + if version != C.DEFAULT: + _install_ruby(prefix, version) + # Need to call this after installing to set up the shims + helpers.run_setup_cmd(prefix, ('rbenv', 'rehash')) + helpers.run_setup_cmd( + prefix, ('gem', 'build', *prefix.star('.gemspec')), + ) + helpers.run_setup_cmd( + prefix, + ( + 'gem', 'install', '--no-document', + *prefix.star('.gem'), *additional_dependencies, + ), + ) + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: # pragma: win32 no cover + with in_env(hook.prefix, hook.language_version): + return helpers.run_xargs(hook, hook.cmd, file_args, color=color) diff --git a/pre_commit/languages/rust.py b/pre_commit/languages/rust.py new file mode 100644 index 0000000..7ea3f54 --- /dev/null +++ b/pre_commit/languages/rust.py @@ -0,0 +1,106 @@ +import contextlib +import os.path +from typing import Generator +from typing import Sequence +from typing import Set +from typing import Tuple + +import toml + +import pre_commit.constants as C +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT +from pre_commit.envcontext import Var +from pre_commit.hook import Hook +from pre_commit.languages import helpers +from pre_commit.prefix import Prefix +from pre_commit.util import clean_path_on_failure +from pre_commit.util import cmd_output_b + +ENVIRONMENT_DIR = 'rustenv' +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy + + +def get_env_patch(target_dir: str) -> PatchesT: + return ( + ('PATH', (os.path.join(target_dir, 'bin'), os.pathsep, Var('PATH'))), + ) + + +@contextlib.contextmanager +def in_env(prefix: Prefix) -> Generator[None, None, None]: + target_dir = prefix.path( + helpers.environment_dir(ENVIRONMENT_DIR, C.DEFAULT), + ) + with envcontext(get_env_patch(target_dir)): + yield + + +def _add_dependencies( + cargo_toml_path: str, + additional_dependencies: Set[str], +) -> None: + with open(cargo_toml_path, 'r+') as f: + cargo_toml = toml.load(f) + cargo_toml.setdefault('dependencies', {}) + for dep in additional_dependencies: + name, _, spec = dep.partition(':') + cargo_toml['dependencies'][name] = spec or '*' + f.seek(0) + toml.dump(cargo_toml, f) + f.truncate() + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + helpers.assert_version_default('rust', version) + directory = prefix.path( + helpers.environment_dir(ENVIRONMENT_DIR, C.DEFAULT), + ) + + # There are two cases where we might want to specify more dependencies: + # as dependencies for the library being built, and as binary packages + # to be `cargo install`'d. + # + # Unlike e.g. Python, if we just `cargo install` a library, it won't be + # used for compilation. And if we add a crate providing a binary to the + # `Cargo.toml`, the binary won't be built. + # + # Because of this, we allow specifying "cli" dependencies by prefixing + # with 'cli:'. + cli_deps = { + dep for dep in additional_dependencies if dep.startswith('cli:') + } + lib_deps = set(additional_dependencies) - cli_deps + + if len(lib_deps) > 0: + _add_dependencies(prefix.path('Cargo.toml'), lib_deps) + + with clean_path_on_failure(directory): + packages_to_install: Set[Tuple[str, ...]] = {('--path', '.')} + for cli_dep in cli_deps: + cli_dep = cli_dep[len('cli:'):] + package, _, version = cli_dep.partition(':') + if version != '': + packages_to_install.add((package, '--version', version)) + else: + packages_to_install.add((package,)) + + for args in packages_to_install: + cmd_output_b( + 'cargo', 'install', '--bins', '--root', directory, *args, + cwd=prefix.prefix_dir, + ) + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: + with in_env(hook.prefix): + return helpers.run_xargs(hook, hook.cmd, file_args, color=color) diff --git a/pre_commit/languages/script.py b/pre_commit/languages/script.py new file mode 100644 index 0000000..a5e1365 --- /dev/null +++ b/pre_commit/languages/script.py @@ -0,0 +1,19 @@ +from typing import Sequence +from typing import Tuple + +from pre_commit.hook import Hook +from pre_commit.languages import helpers + +ENVIRONMENT_DIR = None +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy +install_environment = helpers.no_install + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: + cmd = (hook.prefix.path(hook.cmd[0]), *hook.cmd[1:]) + return helpers.run_xargs(hook, cmd, file_args, color=color) diff --git a/pre_commit/languages/swift.py b/pre_commit/languages/swift.py new file mode 100644 index 0000000..66aadc8 --- /dev/null +++ b/pre_commit/languages/swift.py @@ -0,0 +1,64 @@ +import contextlib +import os +from typing import Generator +from typing import Sequence +from typing import Tuple + +import pre_commit.constants as C +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT +from pre_commit.envcontext import Var +from pre_commit.hook import Hook +from pre_commit.languages import helpers +from pre_commit.prefix import Prefix +from pre_commit.util import clean_path_on_failure +from pre_commit.util import cmd_output_b + +ENVIRONMENT_DIR = 'swift_env' +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy +BUILD_DIR = '.build' +BUILD_CONFIG = 'release' + + +def get_env_patch(venv: str) -> PatchesT: # pragma: win32 no cover + bin_path = os.path.join(venv, BUILD_DIR, BUILD_CONFIG) + return (('PATH', (bin_path, os.pathsep, Var('PATH'))),) + + +@contextlib.contextmanager # pragma: win32 no cover +def in_env(prefix: Prefix) -> Generator[None, None, None]: + envdir = prefix.path( + helpers.environment_dir(ENVIRONMENT_DIR, C.DEFAULT), + ) + with envcontext(get_env_patch(envdir)): + yield + + +def install_environment( + prefix: Prefix, version: str, additional_dependencies: Sequence[str], +) -> None: # pragma: win32 no cover + helpers.assert_version_default('swift', version) + helpers.assert_no_additional_deps('swift', additional_dependencies) + directory = prefix.path( + helpers.environment_dir(ENVIRONMENT_DIR, C.DEFAULT), + ) + + # Build the swift package + with clean_path_on_failure(directory): + os.mkdir(directory) + cmd_output_b( + 'swift', 'build', + '-C', prefix.prefix_dir, + '-c', BUILD_CONFIG, + '--build-path', os.path.join(directory, BUILD_DIR), + ) + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: # pragma: win32 no cover + with in_env(hook.prefix): + return helpers.run_xargs(hook, hook.cmd, file_args, color=color) diff --git a/pre_commit/languages/system.py b/pre_commit/languages/system.py new file mode 100644 index 0000000..139f45d --- /dev/null +++ b/pre_commit/languages/system.py @@ -0,0 +1,19 @@ +from typing import Sequence +from typing import Tuple + +from pre_commit.hook import Hook +from pre_commit.languages import helpers + + +ENVIRONMENT_DIR = None +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy +install_environment = helpers.no_install + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: + return helpers.run_xargs(hook, hook.cmd, file_args, color=color) diff --git a/pre_commit/logging_handler.py b/pre_commit/logging_handler.py new file mode 100644 index 0000000..ba05295 --- /dev/null +++ b/pre_commit/logging_handler.py @@ -0,0 +1,40 @@ +import contextlib +import logging +from typing import Generator + +from pre_commit import color +from pre_commit import output + +logger = logging.getLogger('pre_commit') + +LOG_LEVEL_COLORS = { + 'DEBUG': '', + 'INFO': '', + 'WARNING': color.YELLOW, + 'ERROR': color.RED, +} + + +class LoggingHandler(logging.Handler): + def __init__(self, use_color: bool) -> None: + super().__init__() + self.use_color = use_color + + def emit(self, record: logging.LogRecord) -> None: + level_msg = color.format_color( + f'[{record.levelname}]', + LOG_LEVEL_COLORS[record.levelname], + self.use_color, + ) + output.write_line(f'{level_msg} {record.getMessage()}') + + +@contextlib.contextmanager +def logging_handler(use_color: bool) -> Generator[None, None, None]: + handler = LoggingHandler(use_color) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + try: + yield + finally: + logger.removeHandler(handler) diff --git a/pre_commit/main.py b/pre_commit/main.py new file mode 100644 index 0000000..790b347 --- /dev/null +++ b/pre_commit/main.py @@ -0,0 +1,410 @@ +import argparse +import logging +import os +import sys +from typing import Any +from typing import Optional +from typing import Sequence +from typing import Union + +import pre_commit.constants as C +from pre_commit import color +from pre_commit import git +from pre_commit.commands.autoupdate import autoupdate +from pre_commit.commands.clean import clean +from pre_commit.commands.gc import gc +from pre_commit.commands.hook_impl import hook_impl +from pre_commit.commands.init_templatedir import init_templatedir +from pre_commit.commands.install_uninstall import install +from pre_commit.commands.install_uninstall import install_hooks +from pre_commit.commands.install_uninstall import uninstall +from pre_commit.commands.migrate_config import migrate_config +from pre_commit.commands.run import run +from pre_commit.commands.sample_config import sample_config +from pre_commit.commands.try_repo import try_repo +from pre_commit.error_handler import error_handler +from pre_commit.error_handler import FatalError +from pre_commit.logging_handler import logging_handler +from pre_commit.store import Store +from pre_commit.util import CalledProcessError + + +logger = logging.getLogger('pre_commit') + +# https://github.com/pre-commit/pre-commit/issues/217 +# On OSX, making a virtualenv using pyvenv at . causes `virtualenv` and `pip` +# to install packages to the wrong place. We don't want anything to deal with +# pyvenv +os.environ.pop('__PYVENV_LAUNCHER__', None) + + +COMMANDS_NO_GIT = {'clean', 'gc', 'init-templatedir', 'sample-config'} + + +def _add_color_option(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + '--color', default=os.environ.get('PRE_COMMIT_COLOR', 'auto'), + type=color.use_color, + metavar='{' + ','.join(color.COLOR_CHOICES) + '}', + help='Whether to use color in output. Defaults to `%(default)s`.', + ) + + +def _add_config_option(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + '-c', '--config', default=C.CONFIG_FILE, + help='Path to alternate config file', + ) + + +class AppendReplaceDefault(argparse.Action): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.appended = False + + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: Union[str, Sequence[str], None], + option_string: Optional[str] = None, + ) -> None: + if not self.appended: + setattr(namespace, self.dest, []) + self.appended = True + getattr(namespace, self.dest).append(values) + + +def _add_hook_type_option(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + '-t', '--hook-type', choices=( + 'pre-commit', 'pre-merge-commit', 'pre-push', + 'prepare-commit-msg', 'commit-msg', 'post-checkout', + ), + action=AppendReplaceDefault, + default=['pre-commit'], + dest='hook_types', + ) + + +def _add_run_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument('hook', nargs='?', help='A single hook-id to run') + parser.add_argument('--verbose', '-v', action='store_true', default=False) + mutex_group = parser.add_mutually_exclusive_group(required=False) + mutex_group.add_argument( + '--all-files', '-a', action='store_true', default=False, + help='Run on all the files in the repo.', + ) + mutex_group.add_argument( + '--files', nargs='*', default=[], + help='Specific filenames to run hooks on.', + ) + parser.add_argument( + '--show-diff-on-failure', action='store_true', + help='When hooks fail, run `git diff` directly afterward.', + ) + parser.add_argument( + '--hook-stage', choices=C.STAGES, default='commit', + help='The stage during which the hook is fired. One of %(choices)s', + ) + parser.add_argument( + '--from-ref', '--source', '-s', + help=( + '(for usage with `--from-ref`) -- this option represents the ' + 'original ref in a `from_ref...to_ref` diff expression. ' + 'For `pre-push` hooks, this represents the branch you are pushing ' + 'to. ' + 'For `post-checkout` hooks, this represents the branch that was ' + 'previously checked out.' + ), + ) + parser.add_argument( + '--to-ref', '--origin', '-o', + help=( + '(for usage with `--to-ref`) -- this option represents the ' + 'destination ref in a `from_ref...to_ref` diff expression. ' + 'For `pre-push` hooks, this represents the branch being pushed. ' + 'For `post-checkout` hooks, this represents the branch that is ' + 'now checked out.' + ), + ) + parser.add_argument( + '--commit-msg-filename', + help='Filename to check when running during `commit-msg`', + ) + parser.add_argument( + '--remote-name', help='Remote name used by `git push`.', + ) + parser.add_argument('--remote-url', help='Remote url used by `git push`.') + parser.add_argument( + '--checkout-type', + help=( + 'Indicates whether the checkout was a branch checkout ' + '(changing branches, flag=1) or a file checkout (retrieving a ' + 'file from the index, flag=0).' + ), + ) + + +def _adjust_args_and_chdir(args: argparse.Namespace) -> None: + # `--config` was specified relative to the non-root working directory + if os.path.exists(args.config): + args.config = os.path.abspath(args.config) + if args.command in {'run', 'try-repo'}: + args.files = [os.path.abspath(filename) for filename in args.files] + if args.command == 'try-repo' and os.path.exists(args.repo): + args.repo = os.path.abspath(args.repo) + + try: + toplevel = git.get_root() + except CalledProcessError: + raise FatalError( + 'git failed. Is it installed, and are you in a Git repository ' + 'directory?', + ) + else: + if toplevel == '': # pragma: no cover (old git) + raise FatalError( + 'git toplevel unexpectedly empty! make sure you are not ' + 'inside the `.git` directory of your repository.', + ) + else: + os.chdir(toplevel) + + args.config = os.path.relpath(args.config) + if args.command in {'run', 'try-repo'}: + args.files = [os.path.relpath(filename) for filename in args.files] + if args.command == 'try-repo' and os.path.exists(args.repo): + args.repo = os.path.relpath(args.repo) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + argv = argv if argv is not None else sys.argv[1:] + parser = argparse.ArgumentParser(prog='pre-commit') + + # https://stackoverflow.com/a/8521644/812183 + parser.add_argument( + '-V', '--version', + action='version', + version=f'%(prog)s {C.VERSION}', + ) + + subparsers = parser.add_subparsers(dest='command') + + autoupdate_parser = subparsers.add_parser( + 'autoupdate', + help="Auto-update pre-commit config to the latest repos' versions.", + ) + _add_color_option(autoupdate_parser) + _add_config_option(autoupdate_parser) + autoupdate_parser.add_argument( + '--bleeding-edge', action='store_true', + help=( + 'Update to the bleeding edge of `master` instead of the latest ' + 'tagged version (the default behavior).' + ), + ) + autoupdate_parser.add_argument( + '--freeze', action='store_true', + help='Store "frozen" hashes in `rev` instead of tag names', + ) + autoupdate_parser.add_argument( + '--repo', dest='repos', action='append', metavar='REPO', + help='Only update this repository -- may be specified multiple times.', + ) + + clean_parser = subparsers.add_parser( + 'clean', help='Clean out pre-commit files.', + ) + _add_color_option(clean_parser) + _add_config_option(clean_parser) + + hook_impl_parser = subparsers.add_parser('hook-impl') + _add_color_option(hook_impl_parser) + _add_config_option(hook_impl_parser) + hook_impl_parser.add_argument('--hook-type') + hook_impl_parser.add_argument('--hook-dir') + hook_impl_parser.add_argument( + '--skip-on-missing-config', action='store_true', + ) + hook_impl_parser.add_argument(dest='rest', nargs=argparse.REMAINDER) + + gc_parser = subparsers.add_parser('gc', help='Clean unused cached repos.') + _add_color_option(gc_parser) + _add_config_option(gc_parser) + + init_templatedir_parser = subparsers.add_parser( + 'init-templatedir', + help=( + 'Install hook script in a directory intended for use with ' + '`git config init.templateDir`.' + ), + ) + _add_color_option(init_templatedir_parser) + _add_config_option(init_templatedir_parser) + init_templatedir_parser.add_argument( + 'directory', help='The directory in which to write the hook script.', + ) + _add_hook_type_option(init_templatedir_parser) + + install_parser = subparsers.add_parser( + 'install', help='Install the pre-commit script.', + ) + _add_color_option(install_parser) + _add_config_option(install_parser) + install_parser.add_argument( + '-f', '--overwrite', action='store_true', + help='Overwrite existing hooks / remove migration mode.', + ) + install_parser.add_argument( + '--install-hooks', action='store_true', + help=( + 'Whether to install hook environments for all environments ' + 'in the config file.' + ), + ) + _add_hook_type_option(install_parser) + install_parser.add_argument( + '--allow-missing-config', action='store_true', default=False, + help=( + 'Whether to allow a missing `pre-commit` configuration file ' + 'or exit with a failure code.' + ), + ) + + install_hooks_parser = subparsers.add_parser( + 'install-hooks', + help=( + 'Install hook environments for all environments in the config ' + 'file. You may find `pre-commit install --install-hooks` more ' + 'useful.' + ), + ) + _add_color_option(install_hooks_parser) + _add_config_option(install_hooks_parser) + + migrate_config_parser = subparsers.add_parser( + 'migrate-config', + help='Migrate list configuration to new map configuration.', + ) + _add_color_option(migrate_config_parser) + _add_config_option(migrate_config_parser) + + run_parser = subparsers.add_parser('run', help='Run hooks.') + _add_color_option(run_parser) + _add_config_option(run_parser) + _add_run_options(run_parser) + + sample_config_parser = subparsers.add_parser( + 'sample-config', help=f'Produce a sample {C.CONFIG_FILE} file', + ) + _add_color_option(sample_config_parser) + _add_config_option(sample_config_parser) + + try_repo_parser = subparsers.add_parser( + 'try-repo', + help='Try the hooks in a repository, useful for developing new hooks.', + ) + _add_color_option(try_repo_parser) + _add_config_option(try_repo_parser) + try_repo_parser.add_argument( + 'repo', help='Repository to source hooks from.', + ) + try_repo_parser.add_argument( + '--ref', '--rev', + help=( + 'Manually select a rev to run against, otherwise the `HEAD` ' + 'revision will be used.' + ), + ) + _add_run_options(try_repo_parser) + + uninstall_parser = subparsers.add_parser( + 'uninstall', help='Uninstall the pre-commit script.', + ) + _add_color_option(uninstall_parser) + _add_config_option(uninstall_parser) + _add_hook_type_option(uninstall_parser) + + help = subparsers.add_parser( + 'help', help='Show help for a specific command.', + ) + help.add_argument('help_cmd', nargs='?', help='Command to show help for.') + + # argparse doesn't really provide a way to use a `default` subparser + if len(argv) == 0: + argv = ['run'] + args = parser.parse_args(argv) + + if args.command == 'help' and args.help_cmd: + parser.parse_args([args.help_cmd, '--help']) + elif args.command == 'help': + parser.parse_args(['--help']) + + with error_handler(), logging_handler(args.color): + if args.command not in COMMANDS_NO_GIT: + _adjust_args_and_chdir(args) + + git.check_for_cygwin_mismatch() + + store = Store() + store.mark_config_used(args.config) + + if args.command == 'autoupdate': + return autoupdate( + args.config, store, + tags_only=not args.bleeding_edge, + freeze=args.freeze, + repos=args.repos, + ) + elif args.command == 'clean': + return clean(store) + elif args.command == 'gc': + return gc(store) + elif args.command == 'hook-impl': + return hook_impl( + store, + config=args.config, + color=args.color, + hook_type=args.hook_type, + hook_dir=args.hook_dir, + skip_on_missing_config=args.skip_on_missing_config, + args=args.rest[1:], + ) + elif args.command == 'install': + return install( + args.config, store, + hook_types=args.hook_types, + overwrite=args.overwrite, + hooks=args.install_hooks, + skip_on_missing_config=args.allow_missing_config, + ) + elif args.command == 'init-templatedir': + return init_templatedir( + args.config, store, args.directory, + hook_types=args.hook_types, + ) + elif args.command == 'install-hooks': + return install_hooks(args.config, store) + elif args.command == 'migrate-config': + return migrate_config(args.config) + elif args.command == 'run': + return run(args.config, store, args) + elif args.command == 'sample-config': + return sample_config() + elif args.command == 'try-repo': + return try_repo(args) + elif args.command == 'uninstall': + return uninstall(hook_types=args.hook_types) + else: + raise NotImplementedError( + f'Command {args.command} not implemented.', + ) + + raise AssertionError( + f'Command {args.command} failed to exit with a returncode', + ) + + +if __name__ == '__main__': + exit(main()) diff --git a/pre_commit/make_archives.py b/pre_commit/make_archives.py new file mode 100644 index 0000000..c31bcd7 --- /dev/null +++ b/pre_commit/make_archives.py @@ -0,0 +1,65 @@ +import argparse +import os.path +import tarfile +from typing import Optional +from typing import Sequence + +from pre_commit import output +from pre_commit.util import cmd_output_b +from pre_commit.util import rmtree +from pre_commit.util import tmpdir + + +# This is a script for generating the tarred resources for git repo +# dependencies. Currently it's just for "vendoring" ruby support packages. + + +REPOS = ( + ('rbenv', 'git://github.com/rbenv/rbenv', 'a3fa9b7'), + ('ruby-build', 'git://github.com/rbenv/ruby-build', '1a902f3'), + ( + 'ruby-download', + 'git://github.com/garnieretienne/rvm-download', + '09bd7c6', + ), +) + + +def make_archive(name: str, repo: str, ref: str, destdir: str) -> str: + """Makes an archive of a repository in the given destdir. + + :param text name: Name to give the archive. For instance foo. The file + that is created will be called foo.tar.gz. + :param text repo: Repository to clone. + :param text ref: Tag/SHA/branch to check out. + :param text destdir: Directory to place archives in. + """ + output_path = os.path.join(destdir, f'{name}.tar.gz') + with tmpdir() as tempdir: + # Clone the repository to the temporary directory + cmd_output_b('git', 'clone', repo, tempdir) + cmd_output_b('git', 'checkout', ref, cwd=tempdir) + + # We don't want the '.git' directory + # It adds a bunch of size to the archive and we don't use it at + # runtime + rmtree(os.path.join(tempdir, '.git')) + + with tarfile.open(output_path, 'w|gz') as tf: + tf.add(tempdir, name) + + return output_path + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument('--dest', default='pre_commit/resources') + args = parser.parse_args(argv) + for archive_name, repo, ref in REPOS: + output.write_line(f'Making {archive_name}.tar.gz for {repo}@{ref}') + make_archive(archive_name, repo, ref, args.dest) + return 0 + + +if __name__ == '__main__': + exit(main()) diff --git a/pre_commit/meta_hooks/__init__.py b/pre_commit/meta_hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pre_commit/meta_hooks/check_hooks_apply.py b/pre_commit/meta_hooks/check_hooks_apply.py new file mode 100644 index 0000000..d0244a9 --- /dev/null +++ b/pre_commit/meta_hooks/check_hooks_apply.py @@ -0,0 +1,39 @@ +import argparse +from typing import Optional +from typing import Sequence + +import pre_commit.constants as C +from pre_commit import git +from pre_commit.clientlib import load_config +from pre_commit.commands.run import Classifier +from pre_commit.repository import all_hooks +from pre_commit.store import Store + + +def check_all_hooks_match_files(config_file: str) -> int: + classifier = Classifier(git.get_all_files()) + retv = 0 + + for hook in all_hooks(load_config(config_file), Store()): + if hook.always_run or hook.language == 'fail': + continue + elif not classifier.filenames_for_hook(hook): + print(f'{hook.id} does not apply to this repository') + retv = 1 + + return retv + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument('filenames', nargs='*', default=[C.CONFIG_FILE]) + args = parser.parse_args(argv) + + retv = 0 + for filename in args.filenames: + retv |= check_all_hooks_match_files(filename) + return retv + + +if __name__ == '__main__': + exit(main()) diff --git a/pre_commit/meta_hooks/check_useless_excludes.py b/pre_commit/meta_hooks/check_useless_excludes.py new file mode 100644 index 0000000..30b8d81 --- /dev/null +++ b/pre_commit/meta_hooks/check_useless_excludes.py @@ -0,0 +1,72 @@ +import argparse +import re +from typing import Optional +from typing import Sequence + +from cfgv import apply_defaults + +import pre_commit.constants as C +from pre_commit import git +from pre_commit.clientlib import load_config +from pre_commit.clientlib import MANIFEST_HOOK_DICT +from pre_commit.commands.run import Classifier + + +def exclude_matches_any( + filenames: Sequence[str], + include: str, + exclude: str, +) -> bool: + if exclude == '^$': + return True + include_re, exclude_re = re.compile(include), re.compile(exclude) + for filename in filenames: + if include_re.search(filename) and exclude_re.search(filename): + return True + return False + + +def check_useless_excludes(config_file: str) -> int: + config = load_config(config_file) + classifier = Classifier(git.get_all_files()) + retv = 0 + + exclude = config['exclude'] + if not exclude_matches_any(classifier.filenames, '', exclude): + print( + f'The global exclude pattern {exclude!r} does not match any files', + ) + retv = 1 + + for repo in config['repos']: + for hook in repo['hooks']: + # Not actually a manifest dict, but this more accurately reflects + # the defaults applied during runtime + hook = apply_defaults(hook, MANIFEST_HOOK_DICT) + names = classifier.filenames + types, exclude_types = hook['types'], hook['exclude_types'] + names = classifier.by_types(names, types, exclude_types) + include, exclude = hook['files'], hook['exclude'] + if not exclude_matches_any(names, include, exclude): + print( + f'The exclude pattern {exclude!r} for {hook["id"]} does ' + f'not match any files', + ) + retv = 1 + + return retv + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument('filenames', nargs='*', default=[C.CONFIG_FILE]) + args = parser.parse_args(argv) + + retv = 0 + for filename in args.filenames: + retv |= check_useless_excludes(filename) + return retv + + +if __name__ == '__main__': + exit(main()) diff --git a/pre_commit/meta_hooks/identity.py b/pre_commit/meta_hooks/identity.py new file mode 100644 index 0000000..730d0ec --- /dev/null +++ b/pre_commit/meta_hooks/identity.py @@ -0,0 +1,16 @@ +import sys +from typing import Optional +from typing import Sequence + +from pre_commit import output + + +def main(argv: Optional[Sequence[str]] = None) -> int: + argv = argv if argv is not None else sys.argv[1:] + for arg in argv: + output.write_line(arg) + return 0 + + +if __name__ == '__main__': + exit(main()) diff --git a/pre_commit/output.py b/pre_commit/output.py new file mode 100644 index 0000000..24f9d84 --- /dev/null +++ b/pre_commit/output.py @@ -0,0 +1,32 @@ +import contextlib +import sys +from typing import Any +from typing import IO +from typing import Optional + + +def write(s: str, stream: IO[bytes] = sys.stdout.buffer) -> None: + stream.write(s.encode()) + stream.flush() + + +def write_line_b( + s: Optional[bytes] = None, + stream: IO[bytes] = sys.stdout.buffer, + logfile_name: Optional[str] = None, +) -> None: + with contextlib.ExitStack() as exit_stack: + output_streams = [stream] + if logfile_name: + stream = exit_stack.enter_context(open(logfile_name, 'ab')) + output_streams.append(stream) + + for output_stream in output_streams: + if s is not None: + output_stream.write(s) + output_stream.write(b'\n') + output_stream.flush() + + +def write_line(s: Optional[str] = None, **kwargs: Any) -> None: + write_line_b(s.encode() if s is not None else s, **kwargs) diff --git a/pre_commit/parse_shebang.py b/pre_commit/parse_shebang.py new file mode 100644 index 0000000..d344a1d --- /dev/null +++ b/pre_commit/parse_shebang.py @@ -0,0 +1,84 @@ +import os.path +from typing import Mapping +from typing import Optional +from typing import Tuple +from typing import TYPE_CHECKING + +from identify.identify import parse_shebang_from_file + +if TYPE_CHECKING: + from typing import NoReturn + + +class ExecutableNotFoundError(OSError): + def to_output(self) -> Tuple[int, bytes, None]: + return (1, self.args[0].encode(), None) + + +def parse_filename(filename: str) -> Tuple[str, ...]: + if not os.path.exists(filename): + return () + else: + return parse_shebang_from_file(filename) + + +def find_executable( + exe: str, _environ: Optional[Mapping[str, str]] = None, +) -> Optional[str]: + exe = os.path.normpath(exe) + if os.sep in exe: + return exe + + environ = _environ if _environ is not None else os.environ + + if 'PATHEXT' in environ: + exts = environ['PATHEXT'].split(os.pathsep) + possible_exe_names = tuple(f'{exe}{ext}' for ext in exts) + (exe,) + else: + possible_exe_names = (exe,) + + for path in environ.get('PATH', '').split(os.pathsep): + for possible_exe_name in possible_exe_names: + joined = os.path.join(path, possible_exe_name) + if os.path.isfile(joined) and os.access(joined, os.X_OK): + return joined + else: + return None + + +def normexe(orig: str) -> str: + def _error(msg: str) -> 'NoReturn': + raise ExecutableNotFoundError(f'Executable `{orig}` {msg}') + + if os.sep not in orig and (not os.altsep or os.altsep not in orig): + exe = find_executable(orig) + if exe is None: + _error('not found') + return exe + elif os.path.isdir(orig): + _error('is a directory') + elif not os.path.isfile(orig): + _error('not found') + elif not os.access(orig, os.X_OK): # pragma: win32 no cover + _error('is not executable') + else: + return orig + + +def normalize_cmd(cmd: Tuple[str, ...]) -> Tuple[str, ...]: + """Fixes for the following issues on windows + - https://bugs.python.org/issue8557 + - windows does not parse shebangs + + This function also makes deep-path shebangs work just fine + """ + # Use PATH to determine the executable + exe = normexe(cmd[0]) + + # Figure out the shebang from the resulting command + cmd = parse_filename(exe) + (exe,) + cmd[1:] + + # This could have given us back another bare executable + exe = normexe(cmd[0]) + + return (exe,) + cmd[1:] diff --git a/pre_commit/prefix.py b/pre_commit/prefix.py new file mode 100644 index 0000000..0e3ebbd --- /dev/null +++ b/pre_commit/prefix.py @@ -0,0 +1,17 @@ +import os.path +from typing import NamedTuple +from typing import Tuple + + +class Prefix(NamedTuple): + prefix_dir: str + + def path(self, *parts: str) -> str: + return os.path.normpath(os.path.join(self.prefix_dir, *parts)) + + def exists(self, *parts: str) -> bool: + return os.path.exists(self.path(*parts)) + + def star(self, end: str) -> Tuple[str, ...]: + paths = os.listdir(self.prefix_dir) + return tuple(path for path in paths if path.endswith(end)) diff --git a/pre_commit/repository.py b/pre_commit/repository.py new file mode 100644 index 0000000..77734ee --- /dev/null +++ b/pre_commit/repository.py @@ -0,0 +1,208 @@ +import json +import logging +import os +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Sequence +from typing import Set +from typing import Tuple + +import pre_commit.constants as C +from pre_commit.clientlib import load_manifest +from pre_commit.clientlib import LOCAL +from pre_commit.clientlib import META +from pre_commit.hook import Hook +from pre_commit.languages.all import languages +from pre_commit.languages.helpers import environment_dir +from pre_commit.prefix import Prefix +from pre_commit.store import Store +from pre_commit.util import parse_version +from pre_commit.util import rmtree + + +logger = logging.getLogger('pre_commit') + + +def _state(additional_deps: Sequence[str]) -> object: + return {'additional_dependencies': sorted(additional_deps)} + + +def _state_filename(prefix: Prefix, venv: str) -> str: + return prefix.path(venv, f'.install_state_v{C.INSTALLED_STATE_VERSION}') + + +def _read_state(prefix: Prefix, venv: str) -> Optional[object]: + filename = _state_filename(prefix, venv) + if not os.path.exists(filename): + return None + else: + with open(filename) as f: + return json.load(f) + + +def _write_state(prefix: Prefix, venv: str, state: object) -> None: + state_filename = _state_filename(prefix, venv) + staging = f'{state_filename}staging' + with open(staging, 'w') as state_file: + state_file.write(json.dumps(state)) + # Move the file into place atomically to indicate we've installed + os.rename(staging, state_filename) + + +def _hook_installed(hook: Hook) -> bool: + lang = languages[hook.language] + venv = environment_dir(lang.ENVIRONMENT_DIR, hook.language_version) + return ( + venv is None or ( + ( + _read_state(hook.prefix, venv) == + _state(hook.additional_dependencies) + ) and + lang.healthy(hook.prefix, hook.language_version) + ) + ) + + +def _hook_install(hook: Hook) -> None: + logger.info(f'Installing environment for {hook.src}.') + logger.info('Once installed this environment will be reused.') + logger.info('This may take a few minutes...') + + lang = languages[hook.language] + assert lang.ENVIRONMENT_DIR is not None + venv = environment_dir(lang.ENVIRONMENT_DIR, hook.language_version) + + # There's potentially incomplete cleanup from previous runs + # Clean it up! + if hook.prefix.exists(venv): + rmtree(hook.prefix.path(venv)) + + lang.install_environment( + hook.prefix, hook.language_version, hook.additional_dependencies, + ) + # Write our state to indicate we're installed + _write_state(hook.prefix, venv, _state(hook.additional_dependencies)) + + +def _hook( + *hook_dicts: Dict[str, Any], + root_config: Dict[str, Any], +) -> Dict[str, Any]: + ret, rest = dict(hook_dicts[0]), hook_dicts[1:] + for dct in rest: + ret.update(dct) + + version = ret['minimum_pre_commit_version'] + if parse_version(version) > parse_version(C.VERSION): + logger.error( + f'The hook `{ret["id"]}` requires pre-commit version {version} ' + f'but version {C.VERSION} is installed. ' + f'Perhaps run `pip install --upgrade pre-commit`.', + ) + exit(1) + + lang = ret['language'] + if ret['language_version'] == C.DEFAULT: + ret['language_version'] = root_config['default_language_version'][lang] + if ret['language_version'] == C.DEFAULT: + ret['language_version'] = languages[lang].get_default_version() + + if not ret['stages']: + ret['stages'] = root_config['default_stages'] + + return ret + + +def _non_cloned_repository_hooks( + repo_config: Dict[str, Any], + store: Store, + root_config: Dict[str, Any], +) -> Tuple[Hook, ...]: + def _prefix(language_name: str, deps: Sequence[str]) -> Prefix: + language = languages[language_name] + # pygrep / script / system / docker_image do not have + # environments so they work out of the current directory + if language.ENVIRONMENT_DIR is None: + return Prefix(os.getcwd()) + else: + return Prefix(store.make_local(deps)) + + return tuple( + Hook.create( + repo_config['repo'], + _prefix(hook['language'], hook['additional_dependencies']), + _hook(hook, root_config=root_config), + ) + for hook in repo_config['hooks'] + ) + + +def _cloned_repository_hooks( + repo_config: Dict[str, Any], + store: Store, + root_config: Dict[str, Any], +) -> Tuple[Hook, ...]: + repo, rev = repo_config['repo'], repo_config['rev'] + manifest_path = os.path.join(store.clone(repo, rev), C.MANIFEST_FILE) + by_id = {hook['id']: hook for hook in load_manifest(manifest_path)} + + for hook in repo_config['hooks']: + if hook['id'] not in by_id: + logger.error( + f'`{hook["id"]}` is not present in repository {repo}. ' + f'Typo? Perhaps it is introduced in a newer version? ' + f'Often `pre-commit autoupdate` fixes this.', + ) + exit(1) + + hook_dcts = [ + _hook(by_id[hook['id']], hook, root_config=root_config) + for hook in repo_config['hooks'] + ] + return tuple( + Hook.create( + repo_config['repo'], + Prefix(store.clone(repo, rev, hook['additional_dependencies'])), + hook, + ) + for hook in hook_dcts + ) + + +def _repository_hooks( + repo_config: Dict[str, Any], + store: Store, + root_config: Dict[str, Any], +) -> Tuple[Hook, ...]: + if repo_config['repo'] in {LOCAL, META}: + return _non_cloned_repository_hooks(repo_config, store, root_config) + else: + return _cloned_repository_hooks(repo_config, store, root_config) + + +def install_hook_envs(hooks: Sequence[Hook], store: Store) -> None: + def _need_installed() -> List[Hook]: + seen: Set[Tuple[Prefix, str, str, Tuple[str, ...]]] = set() + ret = [] + for hook in hooks: + if hook.install_key not in seen and not _hook_installed(hook): + ret.append(hook) + seen.add(hook.install_key) + return ret + + if not _need_installed(): + return + with store.exclusive_lock(): + # Another process may have already completed this work + for hook in _need_installed(): + _hook_install(hook) + + +def all_hooks(root_config: Dict[str, Any], store: Store) -> Tuple[Hook, ...]: + return tuple( + hook + for repo in root_config['repos'] + for hook in _repository_hooks(repo, store, root_config) + ) diff --git a/pre_commit/resources/__init__.py b/pre_commit/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pre_commit/resources/empty_template_.npmignore b/pre_commit/resources/empty_template_.npmignore new file mode 100644 index 0000000..72e8ffc --- /dev/null +++ b/pre_commit/resources/empty_template_.npmignore @@ -0,0 +1 @@ +* diff --git a/pre_commit/resources/empty_template_Cargo.toml b/pre_commit/resources/empty_template_Cargo.toml new file mode 100644 index 0000000..3dfeffa --- /dev/null +++ b/pre_commit/resources/empty_template_Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "__fake_crate" +version = "0.0.0" + +[[bin]] +name = "__fake_cmd" +path = "main.rs" diff --git a/pre_commit/resources/empty_template_Makefile.PL b/pre_commit/resources/empty_template_Makefile.PL new file mode 100644 index 0000000..ac75fe5 --- /dev/null +++ b/pre_commit/resources/empty_template_Makefile.PL @@ -0,0 +1,6 @@ +use ExtUtils::MakeMaker; + +WriteMakefile( + NAME => "PreCommitDummy", + VERSION => "0.0.1", +); diff --git a/pre_commit/resources/empty_template_environment.yml b/pre_commit/resources/empty_template_environment.yml new file mode 100644 index 0000000..0f29f0c --- /dev/null +++ b/pre_commit/resources/empty_template_environment.yml @@ -0,0 +1,9 @@ +channels: + - conda-forge + - defaults +dependencies: + # This cannot be empty as otherwise no environment will be created. + # We're using openssl here as it is available on all system and will + # most likely be always installed anyways. + # See https://github.com/conda/conda/issues/9487 + - openssl diff --git a/pre_commit/resources/empty_template_main.go b/pre_commit/resources/empty_template_main.go new file mode 100644 index 0000000..38dd16d --- /dev/null +++ b/pre_commit/resources/empty_template_main.go @@ -0,0 +1,3 @@ +package main + +func main() {} diff --git a/pre_commit/resources/empty_template_main.rs b/pre_commit/resources/empty_template_main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/pre_commit/resources/empty_template_main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/pre_commit/resources/empty_template_package.json b/pre_commit/resources/empty_template_package.json new file mode 100644 index 0000000..ac7b725 --- /dev/null +++ b/pre_commit/resources/empty_template_package.json @@ -0,0 +1,4 @@ +{ + "name": "pre_commit_dummy_package", + "version": "0.0.0" +} diff --git a/pre_commit/resources/empty_template_pre_commit_dummy_package.gemspec b/pre_commit/resources/empty_template_pre_commit_dummy_package.gemspec new file mode 100644 index 0000000..8bfb40c --- /dev/null +++ b/pre_commit/resources/empty_template_pre_commit_dummy_package.gemspec @@ -0,0 +1,6 @@ +Gem::Specification.new do |s| + s.name = 'pre_commit_dummy_package' + s.version = '0.0.0' + s.summary = 'dummy gem for pre-commit hooks' + s.authors = ['Anthony Sottile'] +end diff --git a/pre_commit/resources/empty_template_setup.py b/pre_commit/resources/empty_template_setup.py new file mode 100644 index 0000000..6886064 --- /dev/null +++ b/pre_commit/resources/empty_template_setup.py @@ -0,0 +1,4 @@ +from setuptools import setup + + +setup(name='pre-commit-dummy-package', version='0.0.0') diff --git a/pre_commit/resources/hook-tmpl b/pre_commit/resources/hook-tmpl new file mode 100755 index 0000000..299144e --- /dev/null +++ b/pre_commit/resources/hook-tmpl @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# File generated by pre-commit: https://pre-commit.com +# ID: 138fd403232d2ddd5efb44317e38bf03 +import os +import sys + +# we try our best, but the shebang of this script is difficult to determine: +# - macos doesn't ship with python3 +# - windows executables are almost always `python.exe` +# therefore we continue to support python2 for this small script +if sys.version_info < (3, 3): + from distutils.spawn import find_executable as which +else: + from shutil import which + +# work around https://github.com/Homebrew/homebrew-core/issues/30445 +os.environ.pop('__PYVENV_LAUNCHER__', None) + +# start templated +INSTALL_PYTHON = '' +ARGS = ['hook-impl'] +# end templated +ARGS.extend(('--hook-dir', os.path.realpath(os.path.dirname(__file__)))) +ARGS.append('--') +ARGS.extend(sys.argv[1:]) + +DNE = '`pre-commit` not found. Did you forget to activate your virtualenv?' +if os.access(INSTALL_PYTHON, os.X_OK): + CMD = [INSTALL_PYTHON, '-mpre_commit'] +elif which('pre-commit'): + CMD = ['pre-commit'] +else: + raise SystemExit(DNE) + +CMD.extend(ARGS) +if sys.platform == 'win32': # https://bugs.python.org/issue19124 + import subprocess + + if sys.version_info < (3, 7): # https://bugs.python.org/issue25942 + raise SystemExit(subprocess.Popen(CMD).wait()) + else: + raise SystemExit(subprocess.call(CMD)) +else: + os.execvp(CMD[0], CMD) diff --git a/pre_commit/resources/rbenv.tar.gz b/pre_commit/resources/rbenv.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..5307b19d63ef650032717a5ebbf667198809dfee GIT binary patch literal 31781 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 BOpgEn literal 0 HcmV?d00001 diff --git a/pre_commit/resources/ruby-download.tar.gz b/pre_commit/resources/ruby-download.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..7ccfb6c812b884e1d2df48cb31df9e0169c812d7 GIT binary patch literal 5343 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 literal 0 HcmV?d00001 diff --git a/pre_commit/staged_files_only.py b/pre_commit/staged_files_only.py new file mode 100644 index 0000000..09d323d --- /dev/null +++ b/pre_commit/staged_files_only.py @@ -0,0 +1,90 @@ +import contextlib +import logging +import os.path +import time +from typing import Generator + +from pre_commit import git +from pre_commit.util import CalledProcessError +from pre_commit.util import cmd_output +from pre_commit.util import cmd_output_b +from pre_commit.xargs import xargs + + +logger = logging.getLogger('pre_commit') + + +def _git_apply(patch: str) -> None: + args = ('apply', '--whitespace=nowarn', patch) + try: + cmd_output_b('git', *args) + except CalledProcessError: + # Retry with autocrlf=false -- see #570 + cmd_output_b('git', '-c', 'core.autocrlf=false', *args) + + +@contextlib.contextmanager +def _intent_to_add_cleared() -> Generator[None, None, None]: + intent_to_add = git.intent_to_add_files() + if intent_to_add: + logger.warning('Unstaged intent-to-add files detected.') + + xargs(('git', 'rm', '--cached', '--'), intent_to_add) + try: + yield + finally: + xargs(('git', 'add', '--intent-to-add', '--'), intent_to_add) + else: + yield + + +@contextlib.contextmanager +def _unstaged_changes_cleared(patch_dir: str) -> Generator[None, None, None]: + tree = cmd_output('git', 'write-tree')[1].strip() + retcode, diff_stdout_binary, _ = cmd_output_b( + 'git', 'diff-index', '--ignore-submodules', '--binary', + '--exit-code', '--no-color', '--no-ext-diff', tree, '--', + retcode=None, + ) + if retcode and diff_stdout_binary.strip(): + patch_filename = f'patch{int(time.time())}' + patch_filename = os.path.join(patch_dir, patch_filename) + logger.warning('Unstaged files detected.') + logger.info(f'Stashing unstaged files to {patch_filename}.') + # Save the current unstaged changes as a patch + os.makedirs(patch_dir, exist_ok=True) + with open(patch_filename, 'wb') as patch_file: + patch_file.write(diff_stdout_binary) + + # Clear the working directory of unstaged changes + cmd_output_b('git', 'checkout', '--', '.') + try: + yield + finally: + # Try to apply the patch we saved + try: + _git_apply(patch_filename) + except CalledProcessError: + logger.warning( + 'Stashed changes conflicted with hook auto-fixes... ' + 'Rolling back fixes...', + ) + # We failed to apply the patch, presumably due to fixes made + # by hooks. + # Roll back the changes made by hooks. + cmd_output_b('git', 'checkout', '--', '.') + _git_apply(patch_filename) + logger.info(f'Restored changes from {patch_filename}.') + else: + # There weren't any staged files so we don't need to do anything + # special + yield + + +@contextlib.contextmanager +def staged_files_only(patch_dir: str) -> Generator[None, None, None]: + """Clear any unstaged changes from the git working directory inside this + context. + """ + with _intent_to_add_cleared(), _unstaged_changes_cleared(patch_dir): + yield diff --git a/pre_commit/store.py b/pre_commit/store.py new file mode 100644 index 0000000..760b37a --- /dev/null +++ b/pre_commit/store.py @@ -0,0 +1,250 @@ +import contextlib +import logging +import os.path +import sqlite3 +import tempfile +from typing import Callable +from typing import Generator +from typing import List +from typing import Optional +from typing import Sequence +from typing import Tuple + +import pre_commit.constants as C +from pre_commit import file_lock +from pre_commit import git +from pre_commit.util import CalledProcessError +from pre_commit.util import clean_path_on_failure +from pre_commit.util import cmd_output_b +from pre_commit.util import resource_text +from pre_commit.util import rmtree + + +logger = logging.getLogger('pre_commit') + + +def _get_default_directory() -> str: + """Returns the default directory for the Store. This is intentionally + underscored to indicate that `Store.get_default_directory` is the intended + way to get this information. This is also done so + `Store.get_default_directory` can be mocked in tests and + `_get_default_directory` can be tested. + """ + return os.environ.get('PRE_COMMIT_HOME') or os.path.join( + os.environ.get('XDG_CACHE_HOME') or os.path.expanduser('~/.cache'), + 'pre-commit', + ) + + +class Store: + get_default_directory = staticmethod(_get_default_directory) + + def __init__(self, directory: Optional[str] = None) -> None: + self.directory = directory or Store.get_default_directory() + self.db_path = os.path.join(self.directory, 'db.db') + + if not os.path.exists(self.directory): + os.makedirs(self.directory, exist_ok=True) + with open(os.path.join(self.directory, 'README'), 'w') as f: + f.write( + 'This directory is maintained by the pre-commit project.\n' + 'Learn more: https://github.com/pre-commit/pre-commit\n', + ) + + if os.path.exists(self.db_path): + return + with self.exclusive_lock(): + # Another process may have already completed this work + if os.path.exists(self.db_path): # pragma: no cover (race) + return + # To avoid a race where someone ^Cs between db creation and + # execution of the CREATE TABLE statement + fd, tmpfile = tempfile.mkstemp(dir=self.directory) + # We'll be managing this file ourselves + os.close(fd) + with self.connect(db_path=tmpfile) as db: + db.executescript( + 'CREATE TABLE repos (' + ' repo TEXT NOT NULL,' + ' ref TEXT NOT NULL,' + ' path TEXT NOT NULL,' + ' PRIMARY KEY (repo, ref)' + ');', + ) + self._create_config_table(db) + + # Atomic file move + os.rename(tmpfile, self.db_path) + + @contextlib.contextmanager + def exclusive_lock(self) -> Generator[None, None, None]: + def blocked_cb() -> None: # pragma: no cover (tests are in-process) + logger.info('Locking pre-commit directory') + + with file_lock.lock(os.path.join(self.directory, '.lock'), blocked_cb): + yield + + @contextlib.contextmanager + def connect( + self, + db_path: Optional[str] = None, + ) -> Generator[sqlite3.Connection, None, None]: + db_path = db_path or self.db_path + # sqlite doesn't close its fd with its contextmanager >.< + # contextlib.closing fixes this. + # See: https://stackoverflow.com/a/28032829/812183 + with contextlib.closing(sqlite3.connect(db_path)) as db: + # this creates a transaction + with db: + yield db + + @classmethod + def db_repo_name(cls, repo: str, deps: Sequence[str]) -> str: + if deps: + return f'{repo}:{",".join(sorted(deps))}' + else: + return repo + + def _new_repo( + self, + repo: str, + ref: str, + deps: Sequence[str], + make_strategy: Callable[[str], None], + ) -> str: + repo = self.db_repo_name(repo, deps) + + def _get_result() -> Optional[str]: + # Check if we already exist + with self.connect() as db: + result = db.execute( + 'SELECT path FROM repos WHERE repo = ? AND ref = ?', + (repo, ref), + ).fetchone() + return result[0] if result else None + + result = _get_result() + if result: + return result + with self.exclusive_lock(): + # Another process may have already completed this work + result = _get_result() + if result: # pragma: no cover (race) + return result + + logger.info(f'Initializing environment for {repo}.') + + directory = tempfile.mkdtemp(prefix='repo', dir=self.directory) + with clean_path_on_failure(directory): + make_strategy(directory) + + # Update our db with the created repo + with self.connect() as db: + db.execute( + 'INSERT INTO repos (repo, ref, path) VALUES (?, ?, ?)', + [repo, ref, directory], + ) + return directory + + def _complete_clone(self, ref: str, git_cmd: Callable[..., None]) -> None: + """Perform a complete clone of a repository and its submodules """ + + git_cmd('fetch', 'origin', '--tags') + git_cmd('checkout', ref) + git_cmd('submodule', 'update', '--init', '--recursive') + + def _shallow_clone(self, ref: str, git_cmd: Callable[..., None]) -> None: + """Perform a shallow clone of a repository and its submodules """ + + git_config = 'protocol.version=2' + git_cmd('-c', git_config, 'fetch', 'origin', ref, '--depth=1') + git_cmd('checkout', 'FETCH_HEAD') + git_cmd( + '-c', git_config, 'submodule', 'update', '--init', '--recursive', + '--depth=1', + ) + + def clone(self, repo: str, ref: str, deps: Sequence[str] = ()) -> str: + """Clone the given url and checkout the specific ref.""" + + def clone_strategy(directory: str) -> None: + git.init_repo(directory, repo) + env = git.no_git_env() + + def _git_cmd(*args: str) -> None: + cmd_output_b('git', *args, cwd=directory, env=env) + + try: + self._shallow_clone(ref, _git_cmd) + except CalledProcessError: + self._complete_clone(ref, _git_cmd) + + return self._new_repo(repo, ref, deps, clone_strategy) + + LOCAL_RESOURCES = ( + 'Cargo.toml', 'main.go', 'main.rs', '.npmignore', 'package.json', + 'pre_commit_dummy_package.gemspec', 'setup.py', 'environment.yml', + 'Makefile.PL', + ) + + def make_local(self, deps: Sequence[str]) -> str: + def make_local_strategy(directory: str) -> None: + for resource in self.LOCAL_RESOURCES: + contents = resource_text(f'empty_template_{resource}') + with open(os.path.join(directory, resource), 'w') as f: + f.write(contents) + + env = git.no_git_env() + + # initialize the git repository so it looks more like cloned repos + def _git_cmd(*args: str) -> None: + cmd_output_b('git', *args, cwd=directory, env=env) + + git.init_repo(directory, '<>') + _git_cmd('add', '.') + git.commit(repo=directory) + + return self._new_repo( + 'local', C.LOCAL_REPO_VERSION, deps, make_local_strategy, + ) + + def _create_config_table(self, db: sqlite3.Connection) -> None: + db.executescript( + 'CREATE TABLE IF NOT EXISTS configs (' + ' path TEXT NOT NULL,' + ' PRIMARY KEY (path)' + ');', + ) + + def mark_config_used(self, path: str) -> None: + path = os.path.realpath(path) + # don't insert config files that do not exist + if not os.path.exists(path): + return + with self.connect() as db: + # TODO: eventually remove this and only create in _create + self._create_config_table(db) + db.execute('INSERT OR IGNORE INTO configs VALUES (?)', (path,)) + + def select_all_configs(self) -> List[str]: + with self.connect() as db: + self._create_config_table(db) + rows = db.execute('SELECT path FROM configs').fetchall() + return [path for path, in rows] + + def delete_configs(self, configs: List[str]) -> None: + with self.connect() as db: + rows = [(path,) for path in configs] + db.executemany('DELETE FROM configs WHERE path = ?', rows) + + def select_all_repos(self) -> List[Tuple[str, str, str]]: + with self.connect() as db: + return db.execute('SELECT repo, ref, path from repos').fetchall() + + def delete_repo(self, db_repo_name: str, ref: str, path: str) -> None: + with self.connect() as db: + db.execute( + 'DELETE FROM repos WHERE repo = ? and ref = ?', + (db_repo_name, ref), + ) + rmtree(path) diff --git a/pre_commit/util.py b/pre_commit/util.py new file mode 100644 index 0000000..2db579a --- /dev/null +++ b/pre_commit/util.py @@ -0,0 +1,272 @@ +import contextlib +import errno +import functools +import os.path +import shutil +import stat +import subprocess +import sys +import tempfile +from types import TracebackType +from typing import Any +from typing import Callable +from typing import Dict +from typing import Generator +from typing import IO +from typing import Optional +from typing import Tuple +from typing import Type +from typing import Union + +import yaml + +from pre_commit import parse_shebang + +if sys.version_info >= (3, 7): # pragma: no cover (PY37+) + from importlib.resources import open_binary + from importlib.resources import read_text +else: # pragma: no cover ( str: + # when python/mypy#1484 is solved, this can be `functools.partial` + return yaml.dump( + o, Dumper=Dumper, default_flow_style=False, indent=4, sort_keys=False, + ) + + +def force_bytes(exc: Any) -> bytes: + with contextlib.suppress(TypeError): + return bytes(exc) + with contextlib.suppress(Exception): + return str(exc).encode() + return f''.encode() + + +@contextlib.contextmanager +def clean_path_on_failure(path: str) -> Generator[None, None, None]: + """Cleans up the directory on an exceptional failure.""" + try: + yield + except BaseException: + if os.path.exists(path): + rmtree(path) + raise + + +@contextlib.contextmanager +def tmpdir() -> Generator[str, None, None]: + """Contextmanager to create a temporary directory. It will be cleaned up + afterwards. + """ + tempdir = tempfile.mkdtemp() + try: + yield tempdir + finally: + rmtree(tempdir) + + +def resource_bytesio(filename: str) -> IO[bytes]: + return open_binary('pre_commit.resources', filename) + + +def resource_text(filename: str) -> str: + return read_text('pre_commit.resources', filename) + + +def make_executable(filename: str) -> None: + original_mode = os.stat(filename).st_mode + new_mode = original_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + os.chmod(filename, new_mode) + + +class CalledProcessError(RuntimeError): + def __init__( + self, + returncode: int, + cmd: Tuple[str, ...], + expected_returncode: int, + stdout: bytes, + stderr: Optional[bytes], + ) -> None: + super().__init__(returncode, cmd, expected_returncode, stdout, stderr) + self.returncode = returncode + self.cmd = cmd + self.expected_returncode = expected_returncode + self.stdout = stdout + self.stderr = stderr + + def __bytes__(self) -> bytes: + def _indent_or_none(part: Optional[bytes]) -> bytes: + if part: + return b'\n ' + part.replace(b'\n', b'\n ') + else: + return b' (none)' + + return b''.join(( + f'command: {self.cmd!r}\n'.encode(), + f'return code: {self.returncode}\n'.encode(), + f'expected return code: {self.expected_returncode}\n'.encode(), + b'stdout:', _indent_or_none(self.stdout), b'\n', + b'stderr:', _indent_or_none(self.stderr), + )) + + def __str__(self) -> str: + return self.__bytes__().decode() + + +def _setdefault_kwargs(kwargs: Dict[str, Any]) -> None: + for arg in ('stdin', 'stdout', 'stderr'): + kwargs.setdefault(arg, subprocess.PIPE) + + +def _oserror_to_output(e: OSError) -> Tuple[int, bytes, None]: + return 1, force_bytes(e).rstrip(b'\n') + b'\n', None + + +def cmd_output_b( + *cmd: str, + retcode: Optional[int] = 0, + **kwargs: Any, +) -> Tuple[int, bytes, Optional[bytes]]: + _setdefault_kwargs(kwargs) + + try: + cmd = parse_shebang.normalize_cmd(cmd) + except parse_shebang.ExecutableNotFoundError as e: + returncode, stdout_b, stderr_b = e.to_output() + else: + try: + proc = subprocess.Popen(cmd, **kwargs) + except OSError as e: + returncode, stdout_b, stderr_b = _oserror_to_output(e) + else: + stdout_b, stderr_b = proc.communicate() + returncode = proc.returncode + + if retcode is not None and retcode != returncode: + raise CalledProcessError(returncode, cmd, retcode, stdout_b, stderr_b) + + return returncode, stdout_b, stderr_b + + +def cmd_output(*cmd: str, **kwargs: Any) -> Tuple[int, str, Optional[str]]: + returncode, stdout_b, stderr_b = cmd_output_b(*cmd, **kwargs) + stdout = stdout_b.decode() if stdout_b is not None else None + stderr = stderr_b.decode() if stderr_b is not None else None + return returncode, stdout, stderr + + +if os.name != 'nt': # pragma: win32 no cover + from os import openpty + import termios + + class Pty: + def __init__(self) -> None: + self.r: Optional[int] = None + self.w: Optional[int] = None + + def __enter__(self) -> 'Pty': + self.r, self.w = openpty() + + # tty flags normally change \n to \r\n + attrs = termios.tcgetattr(self.r) + assert isinstance(attrs[1], int) + attrs[1] &= ~(termios.ONLCR | termios.OPOST) + termios.tcsetattr(self.r, termios.TCSANOW, attrs) + + return self + + def close_w(self) -> None: + if self.w is not None: + os.close(self.w) + self.w = None + + def close_r(self) -> None: + assert self.r is not None + os.close(self.r) + self.r = None + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + self.close_w() + self.close_r() + + def cmd_output_p( + *cmd: str, + retcode: Optional[int] = 0, + **kwargs: Any, + ) -> Tuple[int, bytes, Optional[bytes]]: + assert retcode is None + assert kwargs['stderr'] == subprocess.STDOUT, kwargs['stderr'] + _setdefault_kwargs(kwargs) + + try: + cmd = parse_shebang.normalize_cmd(cmd) + except parse_shebang.ExecutableNotFoundError as e: + return e.to_output() + + with open(os.devnull) as devnull, Pty() as pty: + assert pty.r is not None + kwargs.update({'stdin': devnull, 'stdout': pty.w, 'stderr': pty.w}) + try: + proc = subprocess.Popen(cmd, **kwargs) + except OSError as e: + return _oserror_to_output(e) + + pty.close_w() + + buf = b'' + while True: + try: + bts = os.read(pty.r, 4096) + except OSError as e: + if e.errno == errno.EIO: + bts = b'' + else: + raise + else: + buf += bts + if not bts: + break + + return proc.wait(), buf, None +else: # pragma: no cover + cmd_output_p = cmd_output_b + + +def rmtree(path: str) -> None: + """On windows, rmtree fails for readonly dirs.""" + def handle_remove_readonly( + func: Callable[..., Any], + path: str, + exc: Tuple[Type[OSError], OSError, TracebackType], + ) -> None: + excvalue = exc[1] + if ( + func in (os.rmdir, os.remove, os.unlink) and + excvalue.errno == errno.EACCES + ): + for p in (path, os.path.dirname(path)): + os.chmod(p, os.stat(p).st_mode | stat.S_IWUSR) + func(path) + else: + raise + shutil.rmtree(path, ignore_errors=False, onerror=handle_remove_readonly) + + +def parse_version(s: str) -> Tuple[int, ...]: + """poor man's version comparison""" + return tuple(int(p) for p in s.split('.')) diff --git a/pre_commit/xargs.py b/pre_commit/xargs.py new file mode 100644 index 0000000..5235dc6 --- /dev/null +++ b/pre_commit/xargs.py @@ -0,0 +1,157 @@ +import concurrent.futures +import contextlib +import math +import os +import subprocess +import sys +from typing import Any +from typing import Callable +from typing import Generator +from typing import Iterable +from typing import List +from typing import Optional +from typing import Sequence +from typing import Tuple +from typing import TypeVar + +from pre_commit import parse_shebang +from pre_commit.util import cmd_output_b +from pre_commit.util import cmd_output_p +from pre_commit.util import EnvironT + +TArg = TypeVar('TArg') +TRet = TypeVar('TRet') + + +def _environ_size(_env: Optional[EnvironT] = None) -> int: + environ = _env if _env is not None else getattr(os, 'environb', os.environ) + size = 8 * len(environ) # number of pointers in `envp` + for k, v in environ.items(): + size += len(k) + len(v) + 2 # c strings in `envp` + return size + + +def _get_platform_max_length() -> int: # pragma: no cover (platform specific) + if os.name == 'posix': + maximum = os.sysconf('SC_ARG_MAX') - 2048 - _environ_size() + maximum = max(min(maximum, 2 ** 17), 2 ** 12) + return maximum + elif os.name == 'nt': + return 2 ** 15 - 2048 # UNICODE_STRING max - headroom + else: + # posix minimum + return 2 ** 12 + + +def _command_length(*cmd: str) -> int: + full_cmd = ' '.join(cmd) + + # win32 uses the amount of characters, more details at: + # https://github.com/pre-commit/pre-commit/pull/839 + if sys.platform == 'win32': + return len(full_cmd.encode('utf-16le')) // 2 + else: + return len(full_cmd.encode(sys.getfilesystemencoding())) + + +class ArgumentTooLongError(RuntimeError): + pass + + +def partition( + cmd: Sequence[str], + varargs: Sequence[str], + target_concurrency: int, + _max_length: Optional[int] = None, +) -> Tuple[Tuple[str, ...], ...]: + _max_length = _max_length or _get_platform_max_length() + + # Generally, we try to partition evenly into at least `target_concurrency` + # partitions, but we don't want a bunch of tiny partitions. + max_args = max(4, math.ceil(len(varargs) / target_concurrency)) + + cmd = tuple(cmd) + ret = [] + + ret_cmd: List[str] = [] + # Reversed so arguments are in order + varargs = list(reversed(varargs)) + + total_length = _command_length(*cmd) + 1 + while varargs: + arg = varargs.pop() + + arg_length = _command_length(arg) + 1 + if ( + total_length + arg_length <= _max_length and + len(ret_cmd) < max_args + ): + ret_cmd.append(arg) + total_length += arg_length + elif not ret_cmd: + raise ArgumentTooLongError(arg) + else: + # We've exceeded the length, yield a command + ret.append(cmd + tuple(ret_cmd)) + ret_cmd = [] + total_length = _command_length(*cmd) + 1 + varargs.append(arg) + + ret.append(cmd + tuple(ret_cmd)) + + return tuple(ret) + + +@contextlib.contextmanager +def _thread_mapper(maxsize: int) -> Generator[ + Callable[[Callable[[TArg], TRet], Iterable[TArg]], Iterable[TRet]], + None, None, +]: + if maxsize == 1: + yield map + else: + with concurrent.futures.ThreadPoolExecutor(maxsize) as ex: + yield ex.map + + +def xargs( + cmd: Tuple[str, ...], + varargs: Sequence[str], + *, + color: bool = False, + target_concurrency: int = 1, + _max_length: int = _get_platform_max_length(), + **kwargs: Any, +) -> Tuple[int, bytes]: + """A simplified implementation of xargs. + + color: Make a pty if on a platform that supports it + target_concurrency: Target number of partitions to run concurrently + """ + cmd_fn = cmd_output_p if color else cmd_output_b + retcode = 0 + stdout = b'' + + try: + cmd = parse_shebang.normalize_cmd(cmd) + except parse_shebang.ExecutableNotFoundError as e: + return e.to_output()[:2] + + partitions = partition(cmd, varargs, target_concurrency, _max_length) + + def run_cmd_partition( + run_cmd: Tuple[str, ...], + ) -> Tuple[int, bytes, Optional[bytes]]: + return cmd_fn( + *run_cmd, retcode=None, stderr=subprocess.STDOUT, **kwargs, + ) + + threads = min(len(partitions), target_concurrency) + with _thread_mapper(threads) as thread_map: + results = thread_map(run_cmd_partition, partitions) + + for proc_retcode, proc_out, _ in results: + retcode = max(retcode, proc_retcode) + stdout += proc_out + + return retcode, stdout diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..d6a13dc --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +covdefaults +coverage +pytest +pytest-env diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..a02fab1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,70 @@ +[metadata] +name = pre_commit +version = 2.2.0 +description = A framework for managing and maintaining multi-language pre-commit hooks. +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/pre-commit/pre-commit +author = Anthony Sottile +author_email = asottile@umich.edu +license = MIT +license_file = LICENSE +classifiers = + License :: OSI Approved :: MIT License + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: Implementation :: CPython + Programming Language :: Python :: Implementation :: PyPy + +[options] +packages = find: +install_requires = + cfgv>=2.0.0 + identify>=1.0.0 + nodeenv>=0.11.1 + pyyaml>=5.1 + toml + virtualenv>=15.2 + importlib-metadata;python_version<"3.8" + importlib-resources;python_version<"3.7" +python_requires = >=3.6.1 + +[options.entry_points] +console_scripts = + pre-commit = pre_commit.main:main + pre-commit-validate-config = pre_commit.clientlib:validate_config_main + pre-commit-validate-manifest = pre_commit.clientlib:validate_manifest_main + +[options.package_data] +pre_commit.resources = + *.tar.gz + empty_template_* + hook-tmpl + +[options.packages.find] +exclude = + tests* + testing* + +[bdist_wheel] +universal = True + +[coverage:run] +plugins = covdefaults +omit = pre_commit/resources/* + +[mypy] +check_untyped_defs = true +disallow_any_generics = true +disallow_incomplete_defs = true +disallow_untyped_defs = true +no_implicit_optional = true + +[mypy-testing.*] +disallow_untyped_defs = false + +[mypy-tests.*] +disallow_untyped_defs = false diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..8bf1ba9 --- /dev/null +++ b/setup.py @@ -0,0 +1,2 @@ +from setuptools import setup +setup() diff --git a/testing/__init__.py b/testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testing/auto_namedtuple.py b/testing/auto_namedtuple.py new file mode 100644 index 0000000..0841094 --- /dev/null +++ b/testing/auto_namedtuple.py @@ -0,0 +1,11 @@ +import collections + + +def auto_namedtuple(classname='auto_namedtuple', **kwargs): + """Returns an automatic namedtuple object. + + Args: + classname - The class name for the returned object. + **kwargs - Properties to give the returned object. + """ + return (collections.namedtuple(classname, kwargs.keys())(**kwargs)) diff --git a/testing/fixtures.py b/testing/fixtures.py new file mode 100644 index 0000000..f7def08 --- /dev/null +++ b/testing/fixtures.py @@ -0,0 +1,146 @@ +import contextlib +import os.path +import shutil + +from cfgv import apply_defaults +from cfgv import validate + +import pre_commit.constants as C +from pre_commit import git +from pre_commit.clientlib import CONFIG_SCHEMA +from pre_commit.clientlib import load_manifest +from pre_commit.util import cmd_output +from pre_commit.util import yaml_dump +from pre_commit.util import yaml_load +from testing.util import get_resource_path +from testing.util import git_commit + + +def copy_tree_to_path(src_dir, dest_dir): + """Copies all of the things inside src_dir to an already existing dest_dir. + + This looks eerily similar to shutil.copytree, but copytree has no option + for not creating dest_dir. + """ + names = os.listdir(src_dir) + + for name in names: + srcname = os.path.join(src_dir, name) + destname = os.path.join(dest_dir, name) + + if os.path.isdir(srcname): + shutil.copytree(srcname, destname) + else: + shutil.copy(srcname, destname) + + +def git_dir(tempdir_factory): + path = tempdir_factory.get() + cmd_output('git', 'init', path) + return path + + +def make_repo(tempdir_factory, repo_source): + path = git_dir(tempdir_factory) + copy_tree_to_path(get_resource_path(repo_source), path) + cmd_output('git', 'add', '.', cwd=path) + git_commit(msg=make_repo.__name__, cwd=path) + return path + + +@contextlib.contextmanager +def modify_manifest(path, commit=True): + """Modify the manifest yielded by this context to write to + .pre-commit-hooks.yaml. + """ + manifest_path = os.path.join(path, C.MANIFEST_FILE) + with open(manifest_path) as f: + manifest = yaml_load(f.read()) + yield manifest + with open(manifest_path, 'w') as manifest_file: + manifest_file.write(yaml_dump(manifest)) + if commit: + git_commit(msg=modify_manifest.__name__, cwd=path) + + +@contextlib.contextmanager +def modify_config(path='.', commit=True): + """Modify the config yielded by this context to write to + .pre-commit-config.yaml + """ + config_path = os.path.join(path, C.CONFIG_FILE) + with open(config_path) as f: + config = yaml_load(f.read()) + yield config + with open(config_path, 'w', encoding='UTF-8') as config_file: + config_file.write(yaml_dump(config)) + if commit: + git_commit(msg=modify_config.__name__, cwd=path) + + +def sample_local_config(): + return { + 'repo': 'local', + 'hooks': [{ + 'id': 'do_not_commit', + 'name': 'Block if "DO NOT COMMIT" is found', + 'entry': 'DO NOT COMMIT', + 'language': 'pygrep', + }], + } + + +def sample_meta_config(): + return {'repo': 'meta', 'hooks': [{'id': 'check-useless-excludes'}]} + + +def make_config_from_repo(repo_path, rev=None, hooks=None, check=True): + manifest = load_manifest(os.path.join(repo_path, C.MANIFEST_FILE)) + config = { + 'repo': f'file://{repo_path}', + 'rev': rev or git.head_rev(repo_path), + 'hooks': hooks or [{'id': hook['id']} for hook in manifest], + } + + if check: + wrapped = validate({'repos': [config]}, CONFIG_SCHEMA) + wrapped = apply_defaults(wrapped, CONFIG_SCHEMA) + config, = wrapped['repos'] + return config + else: + return config + + +def read_config(directory, config_file=C.CONFIG_FILE): + config_path = os.path.join(directory, config_file) + with open(config_path) as f: + config = yaml_load(f.read()) + return config + + +def write_config(directory, config, config_file=C.CONFIG_FILE): + if type(config) is not list and 'repos' not in config: + assert isinstance(config, dict), config + config = {'repos': [config]} + with open(os.path.join(directory, config_file), 'w') as outfile: + outfile.write(yaml_dump(config)) + + +def add_config_to_repo(git_path, config, config_file=C.CONFIG_FILE): + write_config(git_path, config, config_file=config_file) + cmd_output('git', 'add', config_file, cwd=git_path) + git_commit(msg=add_config_to_repo.__name__, cwd=git_path) + return git_path + + +def remove_config_from_repo(git_path, config_file=C.CONFIG_FILE): + cmd_output('git', 'rm', config_file, cwd=git_path) + git_commit(msg=remove_config_from_repo.__name__, cwd=git_path) + return git_path + + +def make_consuming_repo(tempdir_factory, repo_source): + path = make_repo(tempdir_factory, repo_source) + config = make_config_from_repo(path) + git_path = git_dir(tempdir_factory) + return add_config_to_repo(git_path, config) diff --git a/testing/gen-languages-all b/testing/gen-languages-all new file mode 100755 index 0000000..6d0b26f --- /dev/null +++ b/testing/gen-languages-all @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +import sys + +LANGUAGES = [ + 'conda', 'docker', 'docker_image', 'fail', 'golang', 'node', 'perl', + 'pygrep', 'python', 'python_venv', 'ruby', 'rust', 'script', 'swift', + 'system', +] +FIELDS = [ + 'ENVIRONMENT_DIR', 'get_default_version', 'healthy', 'install_environment', + 'run_hook', +] + + +def main() -> int: + print(f' # BEGIN GENERATED ({sys.argv[0]})') + for lang in LANGUAGES: + parts = [f' {lang!r}: Language(name={lang!r}'] + for k in FIELDS: + parts.append(f', {k}={lang}.{k}') + parts.append('), # noqa: E501') + print(''.join(parts)) + print(' # END GENERATED') + return 0 + + +if __name__ == '__main__': + exit(main()) diff --git a/testing/get-swift.sh b/testing/get-swift.sh new file mode 100755 index 0000000..e205d44 --- /dev/null +++ b/testing/get-swift.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# This is a script used in CI to install swift +set -euxo pipefail + +. /etc/lsb-release +if [ "$DISTRIB_CODENAME" = "bionic" ]; then + SWIFT_URL='https://swift.org/builds/swift-5.1.3-release/ubuntu1804/swift-5.1.3-RELEASE/swift-5.1.3-RELEASE-ubuntu18.04.tar.gz' + SWIFT_HASH='ac82ccd773fe3d586fc340814e31e120da1ff695c6a712f6634e9cc720769610' +else + echo "unknown dist: ${DISTRIB_CODENAME}" 1>&2 + exit 1 +fi + +check() { + echo "$SWIFT_HASH $TGZ" | sha256sum --check +} + +TGZ="$HOME/.swift/swift.tar.gz" +mkdir -p "$(dirname "$TGZ")" +if ! check >& /dev/null; then + rm -f "$TGZ" + curl --location --silent --output "$TGZ" "$SWIFT_URL" + check +fi + +mkdir -p /tmp/swift +tar -xf "$TGZ" --strip 1 --directory /tmp/swift diff --git a/testing/resources/arbitrary_bytes_repo/.pre-commit-hooks.yaml b/testing/resources/arbitrary_bytes_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..c2aec9b --- /dev/null +++ b/testing/resources/arbitrary_bytes_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: hook + name: hook + entry: ./hook.sh + language: script + files: \.py$ diff --git a/testing/resources/arbitrary_bytes_repo/hook.sh b/testing/resources/arbitrary_bytes_repo/hook.sh new file mode 100755 index 0000000..9df0c5a --- /dev/null +++ b/testing/resources/arbitrary_bytes_repo/hook.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Intentionally write mixed encoding to the output. This should not crash +# pre-commit and should write bytes to the output. +# 'β˜ƒ'.encode() + 'Β²'.encode('latin1') +echo -e '\xe2\x98\x83\xb2' +# exit 1 to trigger printing +exit 1 diff --git a/testing/resources/arg_per_line_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/arg_per_line_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..4c101db --- /dev/null +++ b/testing/resources/arg_per_line_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: arg-per-line + name: Args per line hook + entry: bin/hook.sh + language: script + files: '' + args: [hello, world] diff --git a/testing/resources/arg_per_line_hooks_repo/bin/hook.sh b/testing/resources/arg_per_line_hooks_repo/bin/hook.sh new file mode 100755 index 0000000..47fd21d --- /dev/null +++ b/testing/resources/arg_per_line_hooks_repo/bin/hook.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +for i in "$@"; do + echo "arg: $i" +done diff --git a/testing/resources/conda_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/conda_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..a0d274c --- /dev/null +++ b/testing/resources/conda_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,10 @@ +- id: sys-exec + name: sys-exec + entry: python -c 'import os; import sys; print(sys.executable.split(os.path.sep)[-2]) if os.name == "nt" else print(sys.executable.split(os.path.sep)[-3])' + language: conda + files: \.py$ +- id: additional-deps + name: additional-deps + entry: python + language: conda + files: \.py$ diff --git a/testing/resources/conda_hooks_repo/environment.yml b/testing/resources/conda_hooks_repo/environment.yml new file mode 100644 index 0000000..e23c079 --- /dev/null +++ b/testing/resources/conda_hooks_repo/environment.yml @@ -0,0 +1,6 @@ +channels: + - conda-forge + - defaults +dependencies: + - python + - pip diff --git a/testing/resources/docker_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/docker_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..5295739 --- /dev/null +++ b/testing/resources/docker_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,17 @@ +- id: docker-hook + name: Docker test hook + entry: echo + language: docker + files: \.txt$ + +- id: docker-hook-arg + name: Docker test hook + entry: echo -n + language: docker + files: \.txt$ + +- id: docker-hook-failing + name: Docker test hook with nonzero exit code + entry: bork + language: docker + files: \.txt$ diff --git a/testing/resources/docker_hooks_repo/Dockerfile b/testing/resources/docker_hooks_repo/Dockerfile new file mode 100644 index 0000000..841b151 --- /dev/null +++ b/testing/resources/docker_hooks_repo/Dockerfile @@ -0,0 +1,3 @@ +FROM cogniteev/echo + +CMD ["echo", "This is overwritten by the .pre-commit-hooks.yaml 'entry'"] diff --git a/testing/resources/docker_image_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/docker_image_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..1b385aa --- /dev/null +++ b/testing/resources/docker_image_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,8 @@ +- id: echo-entrypoint + name: echo (via --entrypoint) + language: docker_image + entry: --entrypoint echo cogniteev/echo +- id: echo-cmd + name: echo (via cmd) + language: docker_image + entry: cogniteev/echo echo diff --git a/testing/resources/exclude_types_repo/.pre-commit-hooks.yaml b/testing/resources/exclude_types_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..ed8794f --- /dev/null +++ b/testing/resources/exclude_types_repo/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: python-files + name: Python files + entry: bin/hook.sh + language: script + types: [python] + exclude_types: [python3] diff --git a/testing/resources/exclude_types_repo/bin/hook.sh b/testing/resources/exclude_types_repo/bin/hook.sh new file mode 100755 index 0000000..bdade51 --- /dev/null +++ b/testing/resources/exclude_types_repo/bin/hook.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +echo $@ +exit 1 diff --git a/testing/resources/failing_hook_repo/.pre-commit-hooks.yaml b/testing/resources/failing_hook_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..118cc8b --- /dev/null +++ b/testing/resources/failing_hook_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: failing_hook + name: Failing hook + entry: bin/hook.sh + language: script + files: . diff --git a/testing/resources/failing_hook_repo/bin/hook.sh b/testing/resources/failing_hook_repo/bin/hook.sh new file mode 100755 index 0000000..229ccaf --- /dev/null +++ b/testing/resources/failing_hook_repo/bin/hook.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +echo 'Fail' +echo $@ +exit 1 diff --git a/testing/resources/golang_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/golang_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..206733b --- /dev/null +++ b/testing/resources/golang_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: golang-hook + name: golang example hook + entry: golang-hello-world + language: golang + files: '' diff --git a/testing/resources/golang_hooks_repo/golang-hello-world/main.go b/testing/resources/golang_hooks_repo/golang-hello-world/main.go new file mode 100644 index 0000000..1e3c591 --- /dev/null +++ b/testing/resources/golang_hooks_repo/golang-hello-world/main.go @@ -0,0 +1,17 @@ +package main + + +import ( + "fmt" + "github.com/BurntSushi/toml" +) + +type Config struct { + What string +} + +func main() { + var conf Config + toml.Decode("What = 'world'\n", &conf) + fmt.Printf("hello %v\n", conf.What) +} diff --git a/testing/resources/img1.jpg b/testing/resources/img1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dea42627ad9f5eb8061bf5068117faa3ba22591c GIT binary patch literal 843 zcmex=_1P|rX?qqI0P zFI~aY%U!`Mz|~!$%*;qrN1?DZF(6Oj-S5fuR$!pIEN!@|nR z%E~Fi%grl7GWdUhL6Cz%fI)znQHg;`kdaxC@&6G9d7vj*8Nq-73K*GyZe(NU;N;>4 zD%dK(z{JSR%*4VBay3wOEl{3;MUYiU(a@1iI53f2sZhkIapFP_Wv7h?MT0JWP%%y_ zYU1P)6PJ*bQdLve(9|+9H8Z!cv~qTFb#wRd^a>6M4GWKmj7m;PO-s+n%qlJ^Ei136 ztZHs)ZENr7?3y%r%G7DoXUv?nXz`Mz%a*TLxoXqqEnBy3-?4Mop~FXx9y@;G&P778mFHFAhJO^@zmKAh{eZ~f|M{x9gm=v-l>-n?(>?Mmen~E6EX;=IIShdgcqTAxqLYrGVE+%f;HR<7| pm^B+2ZXP)2P=A_1P|rX?qqI0P zFI~aY%U!`Mz|~!$%*;qrN1?DZF(6Oj-S5fuR$!pIEN!@|nR z%E~Fi%grl7GWdUhL6Cz%fI)znQHg;`kdaxC@&6G9d7vj*8Nq-73K*GyZe(NU;N;>4 zD%dK(z{JSR%*4VBay3wOEl{3;MUYiU(a@1iI53f2sZhkIapFP_Wv7h?MT0JWP%%y_ zYU1P)6PJ*bQdLve(9|+9H8Z!cv~qTFb#wRd^a>6M4GWKmj7m;PO-s+n%qlJ^Ei136 ztZHs)ZENr7?3y%r%G7DoXUv?nXz`Mz%a*TLxoXqqEnBy3-?4Mop~FXx9y@;G&P778mFHFAhJO?e(whs7_!gH(4Ta?h)hPo9fEnntq5sTHhvT{?TpsLwUYeb_$nl zoHehfd}N)x_|+`YTc^ys(>|$8?|3rf`1P4{{jSE9?BlNy`?&sye%CMmj;*%pNA~f4 paGm<5POIg$xc9f)cU|vnUbEs%T8F`*EirCp8v}nIVqpJ&697dxJ@Nnm literal 0 HcmV?d00001 diff --git a/testing/resources/img3.jpg b/testing/resources/img3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..392d2cf22882b17530452c023ea6f12e885a895b GIT binary patch literal 859 zcmex=_1P|rX?qqI0P zFI~aY%U!`Mz|~!$%*;qrN1?DZF(6Oj-S5fuR$!pIEN!@|nR z%E~Fi%grl7GWdUhL6Cz%fI)znQHg;`kdaxC@&6G9d7vj*8Nq-73K*GyZe(NU;N;>4 zD%dK(z{JSR%*4VBay3wOEl{3;MUYiU(a@1iI53f2sZhkIapFP_Wv7h?MT0JWP%%y_ zYU1P)6PJ*bQdLve(9|+9H8Z!cv~qTFb#wRd^a>6M4GWKmj7m;PO-s+n%qlJ^Ei136 ztZHs)ZENr7?3y%r%G7DoXUv?nXz`Mz%a*TLxoXqqEnBy3-?4Mop~FXx9y@;G&P778mFHFAhJO`IkyR&u>X z7d%cWx@S5q&{v*&*Wm;+v&^LXZL{XBUR$I0F>LbsUZ;o`IAe2n(aS3P?2jo!Y3TzV{??RthC`e^g(U% zBkj4&N3Ko2VUzSoQe|6PIor!)Hzr83c|1Rr#9&io9X?Sqq)+wWUdj7GBFh_tdrIB^ G-vj_m&2 + +exit 1 diff --git a/testing/resources/modified_file_returns_zero_repo/.pre-commit-hooks.yaml b/testing/resources/modified_file_returns_zero_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..8d79ef3 --- /dev/null +++ b/testing/resources/modified_file_returns_zero_repo/.pre-commit-hooks.yaml @@ -0,0 +1,15 @@ +- id: bash_hook + name: Bash hook + entry: bin/hook.sh + language: script + files: 'foo.py' +- id: bash_hook2 + name: Bash hook + entry: bin/hook2.sh + language: script + files: '' +- id: bash_hook3 + name: Bash hook + entry: bin/hook3.sh + language: script + files: 'bar.py' diff --git a/testing/resources/modified_file_returns_zero_repo/bin/hook.sh b/testing/resources/modified_file_returns_zero_repo/bin/hook.sh new file mode 100755 index 0000000..98b05f9 --- /dev/null +++ b/testing/resources/modified_file_returns_zero_repo/bin/hook.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +for f in $@; do + # Non UTF-8 bytes + echo -e '\x01\x97' > "$f" + echo "Modified: $f!" +done diff --git a/testing/resources/modified_file_returns_zero_repo/bin/hook2.sh b/testing/resources/modified_file_returns_zero_repo/bin/hook2.sh new file mode 100755 index 0000000..5af177a --- /dev/null +++ b/testing/resources/modified_file_returns_zero_repo/bin/hook2.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo $@ diff --git a/testing/resources/modified_file_returns_zero_repo/bin/hook3.sh b/testing/resources/modified_file_returns_zero_repo/bin/hook3.sh new file mode 100755 index 0000000..3180eb3 --- /dev/null +++ b/testing/resources/modified_file_returns_zero_repo/bin/hook3.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +for f in $@; do + # Non UTF-8 bytes + echo -e '\x01\x97' > "$f" +done diff --git a/testing/resources/node_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/node_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..257698a --- /dev/null +++ b/testing/resources/node_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: foo + name: Foo + entry: foo + language: node + files: \.js$ diff --git a/testing/resources/node_hooks_repo/bin/main.js b/testing/resources/node_hooks_repo/bin/main.js new file mode 100644 index 0000000..8e0f025 --- /dev/null +++ b/testing/resources/node_hooks_repo/bin/main.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +console.log('Hello World'); diff --git a/testing/resources/node_hooks_repo/package.json b/testing/resources/node_hooks_repo/package.json new file mode 100644 index 0000000..050b630 --- /dev/null +++ b/testing/resources/node_hooks_repo/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.1", + "bin": {"foo": "./bin/main.js"} +} diff --git a/testing/resources/node_versioned_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/node_versioned_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..e7ad5ea --- /dev/null +++ b/testing/resources/node_versioned_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: versioned-node-hook + name: Versioned node hook + entry: versioned-node-hook + language: node + language_version: 9.3.0 + files: \.js$ diff --git a/testing/resources/node_versioned_hooks_repo/bin/main.js b/testing/resources/node_versioned_hooks_repo/bin/main.js new file mode 100644 index 0000000..df12cbe --- /dev/null +++ b/testing/resources/node_versioned_hooks_repo/bin/main.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node + +console.log(process.version); +console.log('Hello World'); diff --git a/testing/resources/node_versioned_hooks_repo/package.json b/testing/resources/node_versioned_hooks_repo/package.json new file mode 100644 index 0000000..18c7787 --- /dev/null +++ b/testing/resources/node_versioned_hooks_repo/package.json @@ -0,0 +1,5 @@ +{ + "name": "versioned-node-hook", + "version": "0.0.1", + "bin": {"versioned-node-hook": "./bin/main.js"} +} diff --git a/testing/resources/not_found_exe/.pre-commit-hooks.yaml b/testing/resources/not_found_exe/.pre-commit-hooks.yaml new file mode 100644 index 0000000..566f3c1 --- /dev/null +++ b/testing/resources/not_found_exe/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: not-found-exe + name: Not found exe + entry: i-dont-exist-lol + language: system + files: '' diff --git a/testing/resources/perl_hooks_repo/.gitignore b/testing/resources/perl_hooks_repo/.gitignore new file mode 100644 index 0000000..7af9940 --- /dev/null +++ b/testing/resources/perl_hooks_repo/.gitignore @@ -0,0 +1,7 @@ +/MYMETA.json +/MYMETA.yml +/Makefile +/PreCommitHello-*.tar.* +/PreCommitHello-*/ +/blib/ +/pm_to_blib diff --git a/testing/resources/perl_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/perl_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..11e6f6c --- /dev/null +++ b/testing/resources/perl_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: perl-hook + name: perl example hook + entry: pre-commit-perl-hello + language: perl + files: '' diff --git a/testing/resources/perl_hooks_repo/MANIFEST b/testing/resources/perl_hooks_repo/MANIFEST new file mode 100644 index 0000000..4a20084 --- /dev/null +++ b/testing/resources/perl_hooks_repo/MANIFEST @@ -0,0 +1,4 @@ +MANIFEST +Makefile.PL +bin/pre-commit-perl-hello +lib/PreCommitHello.pm diff --git a/testing/resources/perl_hooks_repo/Makefile.PL b/testing/resources/perl_hooks_repo/Makefile.PL new file mode 100644 index 0000000..6c70e10 --- /dev/null +++ b/testing/resources/perl_hooks_repo/Makefile.PL @@ -0,0 +1,10 @@ +use strict; +use warnings; + +use ExtUtils::MakeMaker; + +WriteMakefile( + NAME => "PreCommitHello", + VERSION_FROM => "lib/PreCommitHello.pm", + EXE_FILES => [qw(bin/pre-commit-perl-hello)], +); diff --git a/testing/resources/perl_hooks_repo/bin/pre-commit-perl-hello b/testing/resources/perl_hooks_repo/bin/pre-commit-perl-hello new file mode 100755 index 0000000..9474009 --- /dev/null +++ b/testing/resources/perl_hooks_repo/bin/pre-commit-perl-hello @@ -0,0 +1,7 @@ +#!/usr/bin/env perl + +use strict; +use warnings; +use PreCommitHello; + +PreCommitHello::hello(); diff --git a/testing/resources/perl_hooks_repo/lib/PreCommitHello.pm b/testing/resources/perl_hooks_repo/lib/PreCommitHello.pm new file mode 100644 index 0000000..c76521c --- /dev/null +++ b/testing/resources/perl_hooks_repo/lib/PreCommitHello.pm @@ -0,0 +1,12 @@ +package PreCommitHello; + +use strict; +use warnings; + +our $VERSION = "0.1.0"; + +sub hello { + print "Hello from perl-commit Perl!\n"; +} + +1; diff --git a/testing/resources/prints_cwd_repo/.pre-commit-hooks.yaml b/testing/resources/prints_cwd_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..7092379 --- /dev/null +++ b/testing/resources/prints_cwd_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: prints_cwd + name: Prints Cwd + entry: pwd + language: system + files: \.sh$ diff --git a/testing/resources/python3_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/python3_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..2c23700 --- /dev/null +++ b/testing/resources/python3_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: python3-hook + name: Python 3 Hook + entry: python3-hook + language: python + language_version: python3 + files: \.py$ diff --git a/testing/resources/python3_hooks_repo/py3_hook.py b/testing/resources/python3_hooks_repo/py3_hook.py new file mode 100644 index 0000000..8c9cda4 --- /dev/null +++ b/testing/resources/python3_hooks_repo/py3_hook.py @@ -0,0 +1,8 @@ +import sys + + +def main(): + print(sys.version_info[0]) + print(repr(sys.argv[1:])) + print('Hello World') + return 0 diff --git a/testing/resources/python3_hooks_repo/setup.py b/testing/resources/python3_hooks_repo/setup.py new file mode 100644 index 0000000..9125dc1 --- /dev/null +++ b/testing/resources/python3_hooks_repo/setup.py @@ -0,0 +1,8 @@ +from setuptools import setup + +setup( + name='python3_hook', + version='0.0.0', + py_modules=['py3_hook'], + entry_points={'console_scripts': ['python3-hook = py3_hook:main']}, +) diff --git a/testing/resources/python_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/python_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..e10ad50 --- /dev/null +++ b/testing/resources/python_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: foo + name: Foo + entry: foo + language: python + files: \.py$ diff --git a/testing/resources/python_hooks_repo/foo.py b/testing/resources/python_hooks_repo/foo.py new file mode 100644 index 0000000..9c4368e --- /dev/null +++ b/testing/resources/python_hooks_repo/foo.py @@ -0,0 +1,7 @@ +import sys + + +def main(): + print(repr(sys.argv[1:])) + print('Hello World') + return 0 diff --git a/testing/resources/python_hooks_repo/setup.py b/testing/resources/python_hooks_repo/setup.py new file mode 100644 index 0000000..0559271 --- /dev/null +++ b/testing/resources/python_hooks_repo/setup.py @@ -0,0 +1,8 @@ +from setuptools import setup + +setup( + name='foo', + version='0.0.0', + py_modules=['foo'], + entry_points={'console_scripts': ['foo = foo:main']}, +) diff --git a/testing/resources/python_venv_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/python_venv_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..a666ed8 --- /dev/null +++ b/testing/resources/python_venv_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: foo + name: Foo + entry: foo + language: python_venv + files: \.py$ diff --git a/testing/resources/python_venv_hooks_repo/foo.py b/testing/resources/python_venv_hooks_repo/foo.py new file mode 100644 index 0000000..9c4368e --- /dev/null +++ b/testing/resources/python_venv_hooks_repo/foo.py @@ -0,0 +1,7 @@ +import sys + + +def main(): + print(repr(sys.argv[1:])) + print('Hello World') + return 0 diff --git a/testing/resources/python_venv_hooks_repo/setup.py b/testing/resources/python_venv_hooks_repo/setup.py new file mode 100644 index 0000000..0559271 --- /dev/null +++ b/testing/resources/python_venv_hooks_repo/setup.py @@ -0,0 +1,8 @@ +from setuptools import setup + +setup( + name='foo', + version='0.0.0', + py_modules=['foo'], + entry_points={'console_scripts': ['foo = foo:main']}, +) diff --git a/testing/resources/ruby_hooks_repo/.gitignore b/testing/resources/ruby_hooks_repo/.gitignore new file mode 100644 index 0000000..c111b33 --- /dev/null +++ b/testing/resources/ruby_hooks_repo/.gitignore @@ -0,0 +1 @@ +*.gem diff --git a/testing/resources/ruby_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/ruby_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..aa15872 --- /dev/null +++ b/testing/resources/ruby_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: ruby_hook + name: Ruby Hook + entry: ruby_hook + language: ruby + files: \.rb$ diff --git a/testing/resources/ruby_hooks_repo/bin/ruby_hook b/testing/resources/ruby_hooks_repo/bin/ruby_hook new file mode 100755 index 0000000..5a7e5ed --- /dev/null +++ b/testing/resources/ruby_hooks_repo/bin/ruby_hook @@ -0,0 +1,3 @@ +#!/usr/bin/env ruby + +puts 'Hello world from a ruby hook' diff --git a/testing/resources/ruby_hooks_repo/lib/.gitignore b/testing/resources/ruby_hooks_repo/lib/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/testing/resources/ruby_hooks_repo/ruby_hook.gemspec b/testing/resources/ruby_hooks_repo/ruby_hook.gemspec new file mode 100644 index 0000000..75f4e8f --- /dev/null +++ b/testing/resources/ruby_hooks_repo/ruby_hook.gemspec @@ -0,0 +1,9 @@ +Gem::Specification.new do |s| + s.name = 'ruby_hook' + s.version = '0.1.0' + s.authors = ['Anthony Sottile'] + s.summary = 'A ruby hook!' + s.description = 'A ruby hook!' + s.files = ['bin/ruby_hook'] + s.executables = ['ruby_hook'] +end diff --git a/testing/resources/ruby_versioned_hooks_repo/.gitignore b/testing/resources/ruby_versioned_hooks_repo/.gitignore new file mode 100644 index 0000000..c111b33 --- /dev/null +++ b/testing/resources/ruby_versioned_hooks_repo/.gitignore @@ -0,0 +1 @@ +*.gem diff --git a/testing/resources/ruby_versioned_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/ruby_versioned_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..63e1dd4 --- /dev/null +++ b/testing/resources/ruby_versioned_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: ruby_hook + name: Ruby Hook + entry: ruby_hook + language: ruby + language_version: 2.5.1 + files: \.rb$ diff --git a/testing/resources/ruby_versioned_hooks_repo/bin/ruby_hook b/testing/resources/ruby_versioned_hooks_repo/bin/ruby_hook new file mode 100755 index 0000000..2406f04 --- /dev/null +++ b/testing/resources/ruby_versioned_hooks_repo/bin/ruby_hook @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +puts RUBY_VERSION +puts 'Hello world from a ruby hook' diff --git a/testing/resources/ruby_versioned_hooks_repo/lib/.gitignore b/testing/resources/ruby_versioned_hooks_repo/lib/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/testing/resources/ruby_versioned_hooks_repo/ruby_hook.gemspec b/testing/resources/ruby_versioned_hooks_repo/ruby_hook.gemspec new file mode 100644 index 0000000..75f4e8f --- /dev/null +++ b/testing/resources/ruby_versioned_hooks_repo/ruby_hook.gemspec @@ -0,0 +1,9 @@ +Gem::Specification.new do |s| + s.name = 'ruby_hook' + s.version = '0.1.0' + s.authors = ['Anthony Sottile'] + s.summary = 'A ruby hook!' + s.description = 'A ruby hook!' + s.files = ['bin/ruby_hook'] + s.executables = ['ruby_hook'] +end diff --git a/testing/resources/rust_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/rust_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..df1269f --- /dev/null +++ b/testing/resources/rust_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: rust-hook + name: rust example hook + entry: rust-hello-world + language: rust + files: '' diff --git a/testing/resources/rust_hooks_repo/Cargo.lock b/testing/resources/rust_hooks_repo/Cargo.lock new file mode 100644 index 0000000..36fbfda --- /dev/null +++ b/testing/resources/rust_hooks_repo/Cargo.lock @@ -0,0 +1,3 @@ +[[package]] +name = "rust-hello-world" +version = "0.1.0" diff --git a/testing/resources/rust_hooks_repo/Cargo.toml b/testing/resources/rust_hooks_repo/Cargo.toml new file mode 100644 index 0000000..cd83b43 --- /dev/null +++ b/testing/resources/rust_hooks_repo/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "rust-hello-world" +version = "0.1.0" diff --git a/testing/resources/rust_hooks_repo/src/main.rs b/testing/resources/rust_hooks_repo/src/main.rs new file mode 100644 index 0000000..ad379d6 --- /dev/null +++ b/testing/resources/rust_hooks_repo/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("hello world"); +} diff --git a/testing/resources/script_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/script_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..21cad4a --- /dev/null +++ b/testing/resources/script_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: bash_hook + name: Bash hook + entry: bin/hook.sh + language: script + files: '' diff --git a/testing/resources/script_hooks_repo/bin/hook.sh b/testing/resources/script_hooks_repo/bin/hook.sh new file mode 100755 index 0000000..6565ee4 --- /dev/null +++ b/testing/resources/script_hooks_repo/bin/hook.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +echo $@ +echo 'Hello World' diff --git a/testing/resources/stdout_stderr_repo/.pre-commit-hooks.yaml b/testing/resources/stdout_stderr_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..6800d25 --- /dev/null +++ b/testing/resources/stdout_stderr_repo/.pre-commit-hooks.yaml @@ -0,0 +1,8 @@ +- id: stdout-stderr + name: stdout-stderr + language: script + entry: ./stdout-stderr-entry +- id: tty-check + name: tty-check + language: script + entry: ./tty-check-entry diff --git a/testing/resources/stdout_stderr_repo/stdout-stderr-entry b/testing/resources/stdout_stderr_repo/stdout-stderr-entry new file mode 100755 index 0000000..7563df5 --- /dev/null +++ b/testing/resources/stdout_stderr_repo/stdout-stderr-entry @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +echo 0 +echo 1 1>&2 +echo 2 +echo 3 1>&2 +echo 4 +echo 5 1>&2 diff --git a/testing/resources/stdout_stderr_repo/tty-check-entry b/testing/resources/stdout_stderr_repo/tty-check-entry new file mode 100755 index 0000000..01a9d38 --- /dev/null +++ b/testing/resources/stdout_stderr_repo/tty-check-entry @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +t() { + if [ -t "$1" ]; then + echo "$2: True" + else + echo "$2: False" + fi +} +t 0 stdin +t 1 stdout +t 2 stderr diff --git a/testing/resources/swift_hooks_repo/.gitignore b/testing/resources/swift_hooks_repo/.gitignore new file mode 100644 index 0000000..02c0875 --- /dev/null +++ b/testing/resources/swift_hooks_repo/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj diff --git a/testing/resources/swift_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/swift_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..c08df87 --- /dev/null +++ b/testing/resources/swift_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: swift-hooks-repo + name: Swift hooks repo example + description: Runs the hello world app generated by swift package init --type executable (binary called swift_hooks_repo here) + entry: swift_hooks_repo + language: swift + files: \.(swift)$ diff --git a/testing/resources/swift_hooks_repo/Package.swift b/testing/resources/swift_hooks_repo/Package.swift new file mode 100644 index 0000000..04976d3 --- /dev/null +++ b/testing/resources/swift_hooks_repo/Package.swift @@ -0,0 +1,7 @@ +// swift-tools-version:5.0 +import PackageDescription + +let package = Package( + name: "swift_hooks_repo", + targets: [.target(name: "swift_hooks_repo")] +) diff --git a/testing/resources/swift_hooks_repo/Sources/swift_hooks_repo/main.swift b/testing/resources/swift_hooks_repo/Sources/swift_hooks_repo/main.swift new file mode 100644 index 0000000..f7cf60e --- /dev/null +++ b/testing/resources/swift_hooks_repo/Sources/swift_hooks_repo/main.swift @@ -0,0 +1 @@ +print("Hello, world!") diff --git a/testing/resources/system_hook_with_spaces_repo/.pre-commit-hooks.yaml b/testing/resources/system_hook_with_spaces_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..b2c347c --- /dev/null +++ b/testing/resources/system_hook_with_spaces_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: system-hook-with-spaces + name: System hook with spaces + entry: bash -c 'echo "Hello World"' + language: system + files: \.sh$ diff --git a/testing/resources/types_repo/.pre-commit-hooks.yaml b/testing/resources/types_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..2e5e4a6 --- /dev/null +++ b/testing/resources/types_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: python-files + name: Python files + entry: bin/hook.sh + language: script + types: [python] diff --git a/testing/resources/types_repo/bin/hook.sh b/testing/resources/types_repo/bin/hook.sh new file mode 100755 index 0000000..bdade51 --- /dev/null +++ b/testing/resources/types_repo/bin/hook.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +echo $@ +exit 1 diff --git a/testing/util.py b/testing/util.py new file mode 100644 index 0000000..439bee7 --- /dev/null +++ b/testing/util.py @@ -0,0 +1,113 @@ +import contextlib +import os.path +import subprocess + +import pytest + +from pre_commit import parse_shebang +from pre_commit.languages.docker import docker_is_running +from pre_commit.util import cmd_output +from testing.auto_namedtuple import auto_namedtuple + + +TESTING_DIR = os.path.abspath(os.path.dirname(__file__)) + + +def get_resource_path(path): + return os.path.join(TESTING_DIR, 'resources', path) + + +def cmd_output_mocked_pre_commit_home( + *args, tempdir_factory, pre_commit_home=None, env=None, **kwargs, +): + if pre_commit_home is None: + pre_commit_home = tempdir_factory.get() + env = env if env is not None else os.environ + kwargs.setdefault('stderr', subprocess.STDOUT) + # Don't want to write to the home directory + env = dict(env, PRE_COMMIT_HOME=pre_commit_home) + ret, out, _ = cmd_output(*args, env=env, **kwargs) + return ret, out.replace('\r\n', '\n'), None + + +skipif_cant_run_docker = pytest.mark.skipif( + os.name == 'nt' or not docker_is_running(), + reason="Docker isn't running or can't be accessed", +) +skipif_cant_run_swift = pytest.mark.skipif( + parse_shebang.find_executable('swift') is None, + reason="swift isn't installed or can't be found", +) +xfailif_windows_no_ruby = pytest.mark.xfail( + os.name == 'nt', + reason='Ruby support not yet implemented on windows.', +) +xfailif_windows = pytest.mark.xfail(os.name == 'nt', reason='windows') + + +def supports_venv(): # pragma: no cover (platform specific) + try: + __import__('ensurepip') + __import__('venv') + return True + except ImportError: + return False + + +xfailif_no_venv = pytest.mark.xfail( + not supports_venv(), reason='Does not support venv module', +) + + +def run_opts( + all_files=False, + files=(), + color=False, + verbose=False, + hook=None, + from_ref='', + to_ref='', + remote_name='', + remote_url='', + hook_stage='commit', + show_diff_on_failure=False, + commit_msg_filename='', + checkout_type='', +): + # These are mutually exclusive + assert not (all_files and files) + return auto_namedtuple( + all_files=all_files, + files=files, + color=color, + verbose=verbose, + hook=hook, + from_ref=from_ref, + to_ref=to_ref, + remote_name=remote_name, + remote_url=remote_url, + hook_stage=hook_stage, + show_diff_on_failure=show_diff_on_failure, + commit_msg_filename=commit_msg_filename, + checkout_type=checkout_type, + ) + + +@contextlib.contextmanager +def cwd(path): + original_cwd = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(original_cwd) + + +def git_commit(*args, fn=cmd_output, msg='commit!', **kwargs): + kwargs.setdefault('stderr', subprocess.STDOUT) + + cmd = ('git', 'commit', '--allow-empty', '--no-gpg-sign', '-a') + args + if msg is not None: # allow skipping `-m` with `msg=None` + cmd += ('-m', msg) + ret, out, _ = fn(*cmd, **kwargs) + return ret, out.replace('\r\n', '\n') diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/clientlib_test.py b/tests/clientlib_test.py new file mode 100644 index 0000000..c48adbd --- /dev/null +++ b/tests/clientlib_test.py @@ -0,0 +1,313 @@ +import logging + +import cfgv +import pytest + +import pre_commit.constants as C +from pre_commit.clientlib import check_type_tag +from pre_commit.clientlib import CONFIG_HOOK_DICT +from pre_commit.clientlib import CONFIG_REPO_DICT +from pre_commit.clientlib import CONFIG_SCHEMA +from pre_commit.clientlib import DEFAULT_LANGUAGE_VERSION +from pre_commit.clientlib import MANIFEST_SCHEMA +from pre_commit.clientlib import MigrateShaToRev +from pre_commit.clientlib import validate_config_main +from pre_commit.clientlib import validate_manifest_main +from testing.fixtures import sample_local_config + + +def is_valid_according_to_schema(obj, obj_schema): + try: + cfgv.validate(obj, obj_schema) + return True + except cfgv.ValidationError: + return False + + +@pytest.mark.parametrize('value', ('definitely-not-a-tag', 'fiel')) +def test_check_type_tag_failures(value): + with pytest.raises(cfgv.ValidationError): + check_type_tag(value) + + +@pytest.mark.parametrize( + ('config_obj', 'expected'), ( + ( + { + 'repos': [{ + 'repo': 'git@github.com:pre-commit/pre-commit-hooks', + 'rev': 'cd74dc150c142c3be70b24eaf0b02cae9d235f37', + 'hooks': [{'id': 'pyflakes', 'files': '\\.py$'}], + }], + }, + True, + ), + ( + { + 'repos': [{ + 'repo': 'git@github.com:pre-commit/pre-commit-hooks', + 'rev': 'cd74dc150c142c3be70b24eaf0b02cae9d235f37', + 'hooks': [ + { + 'id': 'pyflakes', + 'files': '\\.py$', + 'args': ['foo', 'bar', 'baz'], + }, + ], + }], + }, + True, + ), + ( + { + 'repos': [{ + 'repo': 'git@github.com:pre-commit/pre-commit-hooks', + 'rev': 'cd74dc150c142c3be70b24eaf0b02cae9d235f37', + 'hooks': [ + { + 'id': 'pyflakes', + 'files': '\\.py$', + # Exclude pattern must be a string + 'exclude': 0, + 'args': ['foo', 'bar', 'baz'], + }, + ], + }], + }, + False, + ), + ), +) +def test_config_valid(config_obj, expected): + ret = is_valid_according_to_schema(config_obj, CONFIG_SCHEMA) + assert ret is expected + + +def test_local_hooks_with_rev_fails(): + config_obj = {'repos': [dict(sample_local_config(), rev='foo')]} + with pytest.raises(cfgv.ValidationError): + cfgv.validate(config_obj, CONFIG_SCHEMA) + + +def test_config_with_local_hooks_definition_passes(): + config_obj = {'repos': [sample_local_config()]} + cfgv.validate(config_obj, CONFIG_SCHEMA) + + +def test_config_schema_does_not_contain_defaults(): + """Due to the way our merging works, if this schema has any defaults they + will clobber potentially useful values in the backing manifest. #227 + """ + for item in CONFIG_HOOK_DICT.items: + assert not isinstance(item, cfgv.Optional) + + +def test_validate_manifest_main_ok(): + assert not validate_manifest_main(('.pre-commit-hooks.yaml',)) + + +def test_validate_config_main_ok(): + assert not validate_config_main(('.pre-commit-config.yaml',)) + + +def test_validate_config_old_list_format_ok(tmpdir): + f = tmpdir.join('cfg.yaml') + f.write('- {repo: meta, hooks: [{id: identity}]}') + assert not validate_config_main((f.strpath,)) + + +def test_validate_warn_on_unknown_keys_at_repo_level(tmpdir, caplog): + f = tmpdir.join('cfg.yaml') + f.write( + '- 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_main((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_main((f.strpath,)) + assert not ret_val + assert caplog.record_tuples == [ + ( + 'pre_commit', + logging.WARNING, + 'Unexpected key(s) present at root: foo', + ), + ] + + +@pytest.mark.parametrize('fn', (validate_config_main, validate_manifest_main)) +def test_mains_not_ok(tmpdir, fn): + not_yaml = tmpdir.join('f.notyaml') + not_yaml.write('{') + not_schema = tmpdir.join('notconfig.yaml') + not_schema.write('{}') + + assert fn(('does-not-exist',)) + assert fn((not_yaml.strpath,)) + assert fn((not_schema.strpath,)) + + +@pytest.mark.parametrize( + ('manifest_obj', 'expected'), + ( + ( + [{ + 'id': 'a', + 'name': 'b', + 'entry': 'c', + 'language': 'python', + 'files': r'\.py$', + }], + True, + ), + ( + [{ + 'id': 'a', + 'name': 'b', + 'entry': 'c', + 'language': 'python', + 'language_version': 'python3.4', + 'files': r'\.py$', + }], + True, + ), + ( + # A regression in 0.13.5: always_run and files are permissible + [{ + 'id': 'a', + 'name': 'b', + 'entry': 'c', + 'language': 'python', + 'files': '', + 'always_run': True, + }], + True, + ), + ), +) +def test_valid_manifests(manifest_obj, expected): + ret = is_valid_according_to_schema(manifest_obj, MANIFEST_SCHEMA) + assert ret is expected + + +@pytest.mark.parametrize( + 'dct', + ( + {'repo': 'local'}, {'repo': 'meta'}, + {'repo': 'wat', 'sha': 'wat'}, {'repo': 'wat', 'rev': 'wat'}, + ), +) +def test_migrate_sha_to_rev_ok(dct): + MigrateShaToRev().check(dct) + + +def test_migrate_sha_to_rev_dont_specify_both(): + with pytest.raises(cfgv.ValidationError) as excinfo: + MigrateShaToRev().check({'repo': 'a', 'sha': 'b', 'rev': 'c'}) + msg, = excinfo.value.args + assert msg == 'Cannot specify both sha and rev' + + +@pytest.mark.parametrize( + 'dct', + ( + {'repo': 'a'}, + {'repo': 'meta', 'sha': 'a'}, {'repo': 'meta', 'rev': 'a'}, + ), +) +def test_migrate_sha_to_rev_conditional_check_failures(dct): + with pytest.raises(cfgv.ValidationError): + MigrateShaToRev().check(dct) + + +def test_migrate_to_sha_apply_default(): + dct = {'repo': 'a', 'sha': 'b'} + MigrateShaToRev().apply_default(dct) + assert dct == {'repo': 'a', 'rev': 'b'} + + +def test_migrate_to_sha_ok(): + dct = {'repo': 'a', 'rev': 'b'} + MigrateShaToRev().apply_default(dct) + assert dct == {'repo': 'a', 'rev': 'b'} + + +@pytest.mark.parametrize( + 'config_repo', + ( + # i-dont-exist isn't a valid hook + {'repo': 'meta', 'hooks': [{'id': 'i-dont-exist'}]}, + # invalid to set a language for a meta hook + {'repo': 'meta', 'hooks': [{'id': 'identity', 'language': 'python'}]}, + # name override must be string + {'repo': 'meta', 'hooks': [{'id': 'identity', 'name': False}]}, + ), +) +def test_meta_hook_invalid(config_repo): + with pytest.raises(cfgv.ValidationError): + cfgv.validate(config_repo, CONFIG_REPO_DICT) + + +@pytest.mark.parametrize( + 'mapping', + ( + # invalid language key + {'pony': '1.0'}, + # not a string for version + {'python': 3}, + ), +) +def test_default_language_version_invalid(mapping): + with pytest.raises(cfgv.ValidationError): + cfgv.validate(mapping, DEFAULT_LANGUAGE_VERSION) + + +def test_minimum_pre_commit_version_failing(): + with pytest.raises(cfgv.ValidationError) as excinfo: + cfg = {'repos': [], 'minimum_pre_commit_version': '999'} + cfgv.validate(cfg, CONFIG_SCHEMA) + assert str(excinfo.value) == ( + f'\n' + f'==> At Config()\n' + f'==> At key: minimum_pre_commit_version\n' + f'=====> pre-commit version 999 is required but version {C.VERSION} ' + f'is installed. Perhaps run `pip install --upgrade pre-commit`.' + ) + + +def test_minimum_pre_commit_version_passing(): + cfg = {'repos': [], 'minimum_pre_commit_version': '0'} + cfgv.validate(cfg, CONFIG_SCHEMA) + + +@pytest.mark.parametrize('schema', (CONFIG_SCHEMA, CONFIG_REPO_DICT)) +def test_warn_additional(schema): + allowed_keys = {item.key for item in schema.items if hasattr(item, 'key')} + warn_additional, = [ + x for x in schema.items if isinstance(x, cfgv.WarnAdditionalKeys) + ] + assert allowed_keys == set(warn_additional.keys) diff --git a/tests/color_test.py b/tests/color_test.py new file mode 100644 index 0000000..98b39c1 --- /dev/null +++ b/tests/color_test.py @@ -0,0 +1,59 @@ +import sys +from unittest import mock + +import pytest + +from pre_commit import envcontext +from pre_commit.color import format_color +from pre_commit.color import GREEN +from pre_commit.color import use_color + + +@pytest.mark.parametrize( + ('in_text', 'in_color', 'in_use_color', 'expected'), ( + ('foo', GREEN, True, f'{GREEN}foo\033[m'), + ('foo', GREEN, False, 'foo'), + ), +) +def test_format_color(in_text, in_color, in_use_color, expected): + ret = format_color(in_text, in_color, in_use_color) + assert ret == expected + + +def test_use_color_never(): + assert use_color('never') is False + + +def test_use_color_always(): + assert use_color('always') is True + + +def test_use_color_no_tty(): + with mock.patch.object(sys.stdout, 'isatty', return_value=False): + assert use_color('auto') is False + + +def test_use_color_tty_with_color_support(): + with mock.patch.object(sys.stdout, 'isatty', return_value=True): + with mock.patch('pre_commit.color.terminal_supports_color', True): + with envcontext.envcontext((('TERM', envcontext.UNSET),)): + assert use_color('auto') is True + + +def test_use_color_tty_without_color_support(): + with mock.patch.object(sys.stdout, 'isatty', return_value=True): + with mock.patch('pre_commit.color.terminal_supports_color', False): + with envcontext.envcontext((('TERM', envcontext.UNSET),)): + assert use_color('auto') is False + + +def test_use_color_dumb_term(): + with mock.patch.object(sys.stdout, 'isatty', return_value=True): + with mock.patch('pre_commit.color.terminal_supports_color', True): + with envcontext.envcontext((('TERM', 'dumb'),)): + assert use_color('auto') is False + + +def test_use_color_raises_if_given_shenanigans(): + with pytest.raises(ValueError): + use_color('herpaderp') 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..2c7b2f1 --- /dev/null +++ b/tests/commands/autoupdate_test.py @@ -0,0 +1,437 @@ +import shlex + +import pytest + +import pre_commit.constants as C +from pre_commit import git +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) + 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_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, store): + 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), store, freeze=False, tags_only=False) == 0 + assert cfg.read() == contents + + +def test_autoupdate_old_revision_broken(tempdir_factory, in_tmpdir, store): + """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, store, 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, store): + 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), store, freeze=False, tags_only=False) == 0 + assert cfg.read() == fmt.format(out_of_date.path, out_of_date.head_rev) + + +def test_autoupdate_only_one_to_update(up_to_date, out_of_date, tmpdir, store): + 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), store, 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, store, +): + 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, store, 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, store, +): + 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, store, 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, store): + 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), store, freeze=False, tags_only=False) == 0 + expected = fmt.format(out_of_date.path, out_of_date.head_rev) + assert cfg.read() == expected + + +def test_loses_formatting_when_not_detectable(out_of_date, store, 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), store, 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, store): + config = make_config_from_repo(tagged.path, rev=tagged.original_rev) + write_config('.', config) + + assert autoupdate(C.CONFIG_FILE, store, 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, store): + config = make_config_from_repo(tagged.path, rev=tagged.original_rev) + write_config('.', config) + + assert autoupdate(C.CONFIG_FILE, store, 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, store, 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, store): + # 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, store, 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, store): + 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, store, 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, store): + 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, store) + + +def test_autoupdate_hook_disappearing_repo(hook_disappearing, tmpdir, store): + 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), store, freeze=False, tags_only=False) == 1 + assert cfg.read() == contents + + +def test_autoupdate_local_hooks(in_git_dir, store): + config = sample_local_config() + add_config_to_repo('.', config) + assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) == 0 + new_config_writen = read_config('.') + assert len(new_config_writen['repos']) == 1 + assert new_config_writen['repos'][0] == config + + +def test_autoupdate_local_hooks_with_out_of_date_repo( + out_of_date, in_tmpdir, store, +): + 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, store, freeze=False, tags_only=False) == 0 + new_config_writen = read_config('.') + assert len(new_config_writen['repos']) == 2 + assert new_config_writen['repos'][0] == local_config + + +def test_autoupdate_meta_hooks(tmpdir, store): + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write( + 'repos:\n' + '- repo: meta\n' + ' hooks:\n' + ' - id: check-useless-excludes\n', + ) + assert autoupdate(str(cfg), store, 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, store): + 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), store, 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' diff --git a/tests/commands/clean_test.py b/tests/commands/clean_test.py new file mode 100644 index 0000000..955a6bc --- /dev/null +++ b/tests/commands/clean_test.py @@ -0,0 +1,33 @@ +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..02b3694 --- /dev/null +++ b/tests/commands/gc_test.py @@ -0,0 +1,161 @@ +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 + install_hooks(C.CONFIG_FILE, store) + assert not autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) + + 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..032fa8f --- /dev/null +++ b/tests/commands/hook_impl_test.py @@ -0,0 +1,235 @@ +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() + + +def test_run_ns_pre_commit(): + ns = hook_impl._run_ns('pre-commit', True, (), b'') + assert ns is not None + assert ns.hook_stage == 'commit' + assert ns.color is True + + +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_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 == '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_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..d14a171 --- /dev/null +++ b/tests/commands/init_templatedir_test.py @@ -0,0 +1,92 @@ +import os.path +from unittest import mock + +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() diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py new file mode 100644 index 0000000..66b9190 --- /dev/null +++ b/tests/commands/install_uninstall_test.py @@ -0,0 +1,901 @@ +import os.path +import re +import sys +from unittest import mock + +import pre_commit.constants as C +from pre_commit import git +from pre_commit.commands import install_uninstall +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 shebang +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_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]}\n') + assert is_our_script(f.strpath) + + +def patch_platform(platform): + return mock.patch.object(sys, 'platform', platform) + + +def patch_lookup_path(path): + return mock.patch.object(install_uninstall, 'POSIX_SEARCH_PATH', path) + + +def patch_sys_exe(exe): + return mock.patch.object(install_uninstall, 'SYS_EXE', exe) + + +def test_shebang_windows(): + with patch_platform('win32'), patch_sys_exe('python.exe'): + assert shebang() == '#!/usr/bin/env python.exe' + + +def test_shebang_posix_not_on_path(): + with patch_platform('posix'), patch_lookup_path(()): + with patch_sys_exe('python3.6'): + assert shebang() == '#!/usr/bin/env python3.6' + + +def test_shebang_posix_on_path(tmpdir): + exe = tmpdir.join(f'python{sys.version_info[0]}').ensure() + make_executable(exe) + + with patch_platform('posix'), patch_lookup_path((tmpdir.strpath,)): + with patch_sys_exe('python'): + assert shebang() == f'#!/usr/bin/env python{sys.version_info[0]}' + + +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(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(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(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, + retcode=None, + 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.compile( + 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 + assert NORMAL_PRE_COMMIT_RUN.match(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 + assert NORMAL_PRE_COMMIT_RUN.match(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 + assert NORMAL_PRE_COMMIT_RUN.match(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 + assert NORMAL_PRE_COMMIT_RUN.match(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 + assert NORMAL_PRE_COMMIT_RUN.match(output) + + +def _path_without_us(): + # Choose a path which *probably* doesn't include us + env = dict(os.environ) + exe = find_executable('pre-commit', _environ=env) + while exe: + parts = env['PATH'].split(os.pathsep) + after = [x for x in parts if x.lower() != os.path.dirname(exe).lower()] + if parts == after: + raise AssertionError(exe, parts) + env['PATH'] = os.pathsep.join(after) + exe = find_executable('pre-commit', _environ=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() + ret, out = git_commit( + 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'], + }, + retcode=None, + ) + assert ret == 1 + assert out == ( + '`pre-commit` not found. ' + 'Did you forget to activate your virtualenv?\n' + ) + + +FAILING_PRE_COMMIT_RUN = re.compile( + 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 + assert FAILING_PRE_COMMIT_RUN.match(output) + + +EXISTING_COMMIT_RUN = re.compile( + 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(f'{shebang()}\nprint("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 + assert EXISTING_COMMIT_RUN.match(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 + assert output.startswith('legacy hook\n') + assert NORMAL_PRE_COMMIT_RUN.match(output[len('legacy hook\n'):]) + + +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 + assert output.startswith('legacy hook\n') + assert NORMAL_PRE_COMMIT_RUN.match(output[len('legacy hook\n'):]) + + +FAIL_OLD_HOOK = re.compile( + 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 + assert FAIL_OLD_HOOK.match(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 + assert NORMAL_PRE_COMMIT_RUN.match(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 + assert NORMAL_PRE_COMMIT_RUN.match(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(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 + assert EXISTING_COMMIT_RUN.match(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, PRIOR_HASHES[-1], + ) + + 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 + assert NORMAL_PRE_COMMIT_RUN.match(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(hook_types=['pre-commit']) == 0 + + assert pre_commit.exists() + + +PRE_INSTALLED = re.compile( + 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 + assert PRE_INSTALLED.match(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 + assert PRE_INSTALLED.match(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 + assert NORMAL_PRE_COMMIT_RUN.match(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, + retcode=None, + )[: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_checkout_integration(tempdir_factory, store): + path = git_dir(tempdir_factory) + config = [ + { + '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_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): + expected = re.compile( + r'^\[INFO\] Initializing environment for .+\n' + r'Bash hook\.+Passed\n' + r"Merge made by the '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 + assert expected.match(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 diff --git a/tests/commands/migrate_config_test.py b/tests/commands/migrate_config_test.py new file mode 100644 index 0000000..efc0d1c --- /dev/null +++ b/tests/commands/migrate_config_test.py @@ -0,0 +1,156 @@ +import pytest + +import pre_commit.constants as C +from pre_commit.commands.migrate_config import _indent +from pre_commit.commands.migrate_config import migrate_config + + +@pytest.mark.parametrize( + ('s', 'expected'), + ( + ('', ''), + ('a', ' a'), + ('foo\nbar', ' foo\n bar'), + ('foo\n\nbar\n', ' foo\n\n bar\n'), + ('\n\n\n', '\n\n\n'), + ), +) +def test_indent(s, expected): + assert _indent(s) == expected + + +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' + ) + + +@pytest.mark.parametrize('contents', ('', '\n')) +def test_empty_configuration_file_user_error(tmpdir, contents): + cfg = tmpdir.join(C.CONFIG_FILE) + cfg.write(contents) + with tmpdir.as_cwd(): + assert not migrate_config(C.CONFIG_FILE) + # even though the config is invalid, this should be a noop + assert cfg.read() == contents diff --git a/tests/commands/run_test.py b/tests/commands/run_test.py new file mode 100644 index 0000000..f8e8823 --- /dev/null +++ b/tests/commands/run_test.py @@ -0,0 +1,1012 @@ +import os.path +import shlex +import sys +import time +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 EnvironT +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_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_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, 'time', 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 `commit`',), + 1, + True, + ), + ( + {'hook': 'nope', 'hook_stage': 'push'}, + (b'No hook with id `nope` in stage `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_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_checkout_type(cap_out, store, repo_with_passing_hook): + args = run_opts(from_ref='', to_ref='', checkout_type='1') + environ: EnvironT = {} + 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_merge_conflict_modified(cap_out, store, in_merge_conflict): + # Touch another file so we have unstaged non-conflicting things + assert os.path.exists('dummy') + with open('dummy', 'w') as dummy_file: + dummy_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 + + +@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_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', 'β˜ƒ', + retcode=None, 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, + retcode=None, + ) + 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_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(('commit', '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('commit').startswith(b'hook 1...') + assert _run_for_stage('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}, + 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('dummy.py', 'w') as staged_file: + staged_file.write('"""TODO: something"""\n') + cmd_output('git', 'add', 'dummy.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('dummy.py', 'w') as staged_file: + staged_file.write('"""TODO: something"""\n') + cmd_output('git', 'add', 'dummy.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_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((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((r'a/b\c',)) + assert classifier.filenames == [r'a/b\c'] + + +@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 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 ret == ['link'] + + +def test_include_exclude_total_match(some_filenames): + ret = filter_by_include_exclude(some_filenames, r'^.*\.py$', '^$') + assert 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 ret == ['.pre-commit-hooks.yaml'] + + +def test_include_exclude_exclude_removes_files(some_filenames): + ret = filter_by_include_exclude(some_filenames, '', r'\.py$') + assert 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': ['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 diff --git a/tests/commands/sample_config_test.py b/tests/commands/sample_config_test.py new file mode 100644 index 0000000..11c0876 --- /dev/null +++ b/tests/commands/sample_config_test.py @@ -0,0 +1,19 @@ +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: v2.4.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..d3ec3fd --- /dev/null +++ b/tests/commands/try_repo_test.py @@ -0,0 +1,151 @@ +import os.path +import re +import time +from unittest import mock + +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, 'time', return_value=0.0): + _run_try_repo(tempdir_factory, verbose=True) + start, config, rest = _get_out(cap_out) + assert start == '' + assert re.match( + '^repos:\n' + '- repo: .+\n' + ' rev: .+\n' + ' hooks:\n' + ' - id: bash_hook\n' + ' - id: bash_hook2\n' + ' - id: bash_hook3\n$', + 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 == '' + assert re.match( + '^repos:\n' + '- repo: .+\n' + ' rev: .+\n' + ' hooks:\n' + ' - id: bash_hook\n$', + 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 + assert re.match( + '^repos:\n' + '- repo: .+shadow-repo\n' + ' rev: .+\n' + ' hooks:\n' + ' - id: bash_hook\n$', + 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/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..335d261 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,277 @@ +import functools +import io +import logging +import os.path +from unittest import mock + +import pytest + +from pre_commit import output +from pre_commit.envcontext import envcontext +from pre_commit.logging_handler import logging_handler +from pre_commit.store import Store +from pre_commit.util import cmd_output +from pre_commit.util import make_executable +from testing.fixtures import git_dir +from testing.fixtures import make_consuming_repo +from testing.fixtures import write_config +from testing.util import cwd +from testing.util import git_commit + + +@pytest.fixture(autouse=True) +def no_warnings(recwarn): + yield + warnings = [] + for warning in recwarn: # pragma: no cover + message = str(warning.message) + # ImportWarning: Not importing directory '...' missing __init__(.py) + if not ( + isinstance(warning.message, ImportWarning) and + message.startswith('Not importing directory ') and + ' missing __init__' in message + ): + warnings.append( + f'{warning.filename}:{warning.lineno} {message}', + ) + assert not warnings + + +@pytest.fixture +def tempdir_factory(tmpdir): + class TmpdirFactory: + def __init__(self): + self.tmpdir_count = 0 + + def get(self): + path = tmpdir.join(str(self.tmpdir_count)).strpath + self.tmpdir_count += 1 + os.mkdir(path) + return path + + yield TmpdirFactory() + + +@pytest.fixture +def in_tmpdir(tempdir_factory): + path = tempdir_factory.get() + with cwd(path): + yield path + + +@pytest.fixture +def in_git_dir(tmpdir): + repo = tmpdir.join('repo').ensure_dir() + with repo.as_cwd(): + cmd_output('git', 'init') + yield repo + + +def _make_conflict(): + cmd_output('git', 'checkout', 'origin/master', '-b', 'foo') + with open('conflict_file', 'w') as conflict_file: + conflict_file.write('herp\nderp\n') + cmd_output('git', 'add', 'conflict_file') + with open('foo_only_file', 'w') as foo_only_file: + foo_only_file.write('foo') + cmd_output('git', 'add', 'foo_only_file') + git_commit(msg=_make_conflict.__name__) + cmd_output('git', 'checkout', 'origin/master', '-b', 'bar') + with open('conflict_file', 'w') as conflict_file: + conflict_file.write('harp\nddrp\n') + cmd_output('git', 'add', 'conflict_file') + with open('bar_only_file', 'w') as bar_only_file: + bar_only_file.write('bar') + cmd_output('git', 'add', 'bar_only_file') + git_commit(msg=_make_conflict.__name__) + cmd_output('git', 'merge', 'foo', retcode=None) + + +@pytest.fixture +def in_merge_conflict(tempdir_factory): + path = make_consuming_repo(tempdir_factory, 'script_hooks_repo') + open(os.path.join(path, 'dummy'), 'a').close() + cmd_output('git', 'add', 'dummy', cwd=path) + git_commit(msg=in_merge_conflict.__name__, cwd=path) + + conflict_path = tempdir_factory.get() + cmd_output('git', 'clone', path, conflict_path) + with cwd(conflict_path): + _make_conflict() + yield os.path.join(conflict_path) + + +@pytest.fixture +def in_conflicting_submodule(tempdir_factory): + git_dir_1 = git_dir(tempdir_factory) + git_dir_2 = git_dir(tempdir_factory) + git_commit(msg=in_conflicting_submodule.__name__, cwd=git_dir_2) + cmd_output('git', 'submodule', 'add', git_dir_2, 'sub', cwd=git_dir_1) + with cwd(os.path.join(git_dir_1, 'sub')): + _make_conflict() + yield + + +@pytest.fixture +def commit_msg_repo(tempdir_factory): + path = git_dir(tempdir_factory) + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'must-have-signoff', + 'name': 'Must have "Signed off by:"', + 'entry': 'grep -q "Signed off by:"', + 'language': 'system', + 'stages': ['commit-msg'], + }], + } + write_config(path, config) + with cwd(path): + cmd_output('git', 'add', '.') + git_commit(msg=commit_msg_repo.__name__) + yield path + + +@pytest.fixture +def prepare_commit_msg_repo(tempdir_factory): + path = git_dir(tempdir_factory) + script_name = 'add_sign_off.sh' + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'add-signoff', + 'name': 'Add "Signed off by:"', + 'entry': f'./{script_name}', + 'language': 'script', + 'stages': ['prepare-commit-msg'], + }], + } + write_config(path, config) + with cwd(path): + with open(script_name, 'w') as script_file: + script_file.write( + '#!/usr/bin/env bash\n' + 'set -eu\n' + 'echo "\nSigned off by: " >> "$1"\n', + ) + make_executable(script_name) + cmd_output('git', 'add', '.') + git_commit(msg=prepare_commit_msg_repo.__name__) + yield path + + +@pytest.fixture +def failing_prepare_commit_msg_repo(tempdir_factory): + path = git_dir(tempdir_factory) + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'add-signoff', + 'name': 'Add "Signed off by:"', + 'entry': 'bash -c "exit 1"', + 'language': 'system', + 'stages': ['prepare-commit-msg'], + }], + } + write_config(path, config) + with cwd(path): + cmd_output('git', 'add', '.') + git_commit(msg=failing_prepare_commit_msg_repo.__name__) + yield path + + +@pytest.fixture(autouse=True, scope='session') +def dont_write_to_home_directory(): + """pre_commit.store.Store will by default write to the home directory + We'll mock out `Store.get_default_directory` to raise invariantly so we + don't construct a `Store` object that writes to our home directory. + """ + class YouForgotToExplicitlyChooseAStoreDirectory(AssertionError): + pass + + with mock.patch.object( + Store, + 'get_default_directory', + side_effect=YouForgotToExplicitlyChooseAStoreDirectory, + ): + yield + + +@pytest.fixture(autouse=True, scope='session') +def configure_logging(): + with logging_handler(use_color=False): + yield + + +@pytest.fixture +def mock_store_dir(tempdir_factory): + tmpdir = tempdir_factory.get() + with mock.patch.object( + Store, + 'get_default_directory', + return_value=tmpdir, + ): + yield tmpdir + + +@pytest.fixture +def store(tempdir_factory): + yield Store(os.path.join(tempdir_factory.get(), '.pre-commit')) + + +@pytest.fixture +def log_info_mock(): + with mock.patch.object(logging.getLogger('pre_commit'), 'info') as mck: + yield mck + + +class FakeStream: + def __init__(self): + self.data = io.BytesIO() + + def write(self, s): + self.data.write(s) + + def flush(self): + pass + + +class Fixture: + def __init__(self, stream): + self._stream = stream + + def get_bytes(self): + """Get the output as-if no encoding occurred""" + data = self._stream.data.getvalue() + self._stream.data.seek(0) + self._stream.data.truncate() + return data.replace(b'\r\n', b'\n') + + def get(self): + """Get the output assuming it was written as UTF-8 bytes""" + return self.get_bytes().decode() + + +@pytest.fixture +def cap_out(): + stream = FakeStream() + write = functools.partial(output.write, stream=stream) + write_line_b = functools.partial(output.write_line_b, stream=stream) + with mock.patch.multiple(output, write=write, write_line_b=write_line_b): + yield Fixture(stream) + + +@pytest.fixture +def fake_log_handler(): + handler = mock.Mock(level=logging.INFO) + logger = logging.getLogger('pre_commit') + logger.addHandler(handler) + yield handler + logger.removeHandler(handler) + + +@pytest.fixture(scope='session', autouse=True) +def set_git_templatedir(tmpdir_factory): + tdir = str(tmpdir_factory.mktemp('git_template_dir')) + with envcontext((('GIT_TEMPLATE_DIR', tdir),)): + yield diff --git a/tests/envcontext_test.py b/tests/envcontext_test.py new file mode 100644 index 0000000..f9d4dce --- /dev/null +++ b/tests/envcontext_test.py @@ -0,0 +1,101 @@ +import os +from unittest import mock + +import pytest + +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import UNSET +from pre_commit.envcontext import Var + + +def _test(*, before, patch, expected): + env = before.copy() + with envcontext(patch, _env=env): + assert env == expected + assert env == before + + +def test_trivial(): + _test(before={}, patch={}, expected={}) + + +def test_noop(): + _test(before={'foo': 'bar'}, patch=(), expected={'foo': 'bar'}) + + +def test_adds(): + _test(before={}, patch=[('foo', 'bar')], expected={'foo': 'bar'}) + + +def test_overrides(): + _test( + before={'foo': 'baz'}, + patch=[('foo', 'bar')], + expected={'foo': 'bar'}, + ) + + +def test_unset_but_nothing_to_unset(): + _test(before={}, patch=[('foo', UNSET)], expected={}) + + +def test_unset_things_to_remove(): + _test( + before={'PYTHONHOME': ''}, + patch=[('PYTHONHOME', UNSET)], + expected={}, + ) + + +def test_templated_environment_variable_missing(): + _test( + before={}, + patch=[('PATH', ('~/bin:', Var('PATH')))], + expected={'PATH': '~/bin:'}, + ) + + +def test_templated_environment_variable_defaults(): + _test( + before={}, + patch=[('PATH', ('~/bin:', Var('PATH', default='/bin')))], + expected={'PATH': '~/bin:/bin'}, + ) + + +def test_templated_environment_variable_there(): + _test( + before={'PATH': '/usr/local/bin:/usr/bin'}, + patch=[('PATH', ('~/bin:', Var('PATH')))], + expected={'PATH': '~/bin:/usr/local/bin:/usr/bin'}, + ) + + +def test_templated_environ_sources_from_previous(): + _test( + before={'foo': 'bar'}, + patch=( + ('foo', 'baz'), + ('herp', ('foo: ', Var('foo'))), + ), + expected={'foo': 'baz', 'herp': 'foo: bar'}, + ) + + +def test_exception_safety(): + class MyError(RuntimeError): + pass + + env = {'hello': 'world'} + with pytest.raises(MyError): + with envcontext((('foo', 'bar'),), _env=env): + raise MyError() + assert env == {'hello': 'world'} + + +def test_integration_os_environ(): + with mock.patch.dict(os.environ, {'FOO': 'bar'}, clear=True): + assert os.environ == {'FOO': 'bar'} + with envcontext((('HERP', 'derp'),)): + assert os.environ == {'FOO': 'bar', 'HERP': 'derp'} + assert os.environ == {'FOO': 'bar'} diff --git a/tests/error_handler_test.py b/tests/error_handler_test.py new file mode 100644 index 0000000..833bb8f --- /dev/null +++ b/tests/error_handler_test.py @@ -0,0 +1,170 @@ +import os.path +import re +import sys +from unittest import mock + +import pytest + +from pre_commit import error_handler +from pre_commit.util import CalledProcessError +from testing.util import cmd_output_mocked_pre_commit_home + + +@pytest.fixture +def mocked_log_and_exit(): + with mock.patch.object(error_handler, '_log_and_exit') as log_and_exit: + yield log_and_exit + + +def test_error_handler_no_exception(mocked_log_and_exit): + with error_handler.error_handler(): + pass + assert mocked_log_and_exit.call_count == 0 + + +def test_error_handler_fatal_error(mocked_log_and_exit): + exc = error_handler.FatalError('just a test') + with error_handler.error_handler(): + raise exc + + mocked_log_and_exit.assert_called_once_with( + 'An error has occurred', + exc, + # Tested below + mock.ANY, + ) + + assert re.match( + r'Traceback \(most recent call last\):\n' + r' File ".+pre_commit.error_handler.py", line \d+, in error_handler\n' + r' yield\n' + r' File ".+tests.error_handler_test.py", line \d+, ' + r'in test_error_handler_fatal_error\n' + r' raise exc\n' + r'(pre_commit\.error_handler\.)?FatalError: just a test\n', + mocked_log_and_exit.call_args[0][2], + ) + + +def test_error_handler_uncaught_error(mocked_log_and_exit): + exc = ValueError('another test') + with error_handler.error_handler(): + raise exc + + mocked_log_and_exit.assert_called_once_with( + 'An unexpected error has occurred', + exc, + # Tested below + mock.ANY, + ) + assert re.match( + r'Traceback \(most recent call last\):\n' + r' File ".+pre_commit.error_handler.py", line \d+, in error_handler\n' + r' yield\n' + r' File ".+tests.error_handler_test.py", line \d+, ' + r'in test_error_handler_uncaught_error\n' + r' raise exc\n' + r'ValueError: another test\n', + mocked_log_and_exit.call_args[0][2], + ) + + +def test_error_handler_keyboardinterrupt(mocked_log_and_exit): + exc = KeyboardInterrupt() + with error_handler.error_handler(): + raise exc + + mocked_log_and_exit.assert_called_once_with( + 'Interrupted (^C)', + exc, + # Tested below + mock.ANY, + ) + assert re.match( + r'Traceback \(most recent call last\):\n' + r' File ".+pre_commit.error_handler.py", line \d+, in error_handler\n' + r' yield\n' + r' File ".+tests.error_handler_test.py", line \d+, ' + r'in test_error_handler_keyboardinterrupt\n' + r' raise exc\n' + r'KeyboardInterrupt\n', + mocked_log_and_exit.call_args[0][2], + ) + + +def test_log_and_exit(cap_out, mock_store_dir): + with pytest.raises(SystemExit): + error_handler._log_and_exit( + 'msg', error_handler.FatalError('hai'), "I'm a stacktrace", + ) + + printed = cap_out.get() + log_file = os.path.join(mock_store_dir, 'pre-commit.log') + assert printed == f'msg: FatalError: hai\nCheck the log at {log_file}\n' + + assert os.path.exists(log_file) + with open(log_file) as f: + logged = f.read() + expected = ( + r'^### version information\n' + r'\n' + r'```\n' + r'pre-commit version: \d+\.\d+\.\d+\n' + r'sys.version:\n' + r'( .*\n)*' + r'sys.executable: .*\n' + r'os.name: .*\n' + r'sys.platform: .*\n' + r'```\n' + r'\n' + r'### error information\n' + r'\n' + r'```\n' + r'msg: FatalError: hai\n' + r'```\n' + r'\n' + r'```\n' + r"I'm a stacktrace\n" + r'```\n' + ) + assert re.match(expected, logged) + + +def test_error_handler_non_ascii_exception(mock_store_dir): + with pytest.raises(SystemExit): + with error_handler.error_handler(): + raise ValueError('β˜ƒ') + + +def test_error_handler_non_utf8_exception(mock_store_dir): + with pytest.raises(SystemExit): + with error_handler.error_handler(): + raise CalledProcessError(1, ('exe',), 0, b'error: \xa0\xe1', b'') + + +def test_error_handler_non_stringable_exception(mock_store_dir): + class C(Exception): + def __str__(self): + raise RuntimeError('not today!') + + with pytest.raises(SystemExit): + with error_handler.error_handler(): + raise C() + + +def test_error_handler_no_tty(tempdir_factory): + pre_commit_home = tempdir_factory.get() + ret, out, _ = cmd_output_mocked_pre_commit_home( + sys.executable, + '-c', + 'from pre_commit.error_handler import error_handler\n' + 'with error_handler():\n' + ' raise ValueError("\\u2603")\n', + retcode=1, + tempdir_factory=tempdir_factory, + pre_commit_home=pre_commit_home, + ) + log_file = os.path.join(pre_commit_home, 'pre-commit.log') + out_lines = out.splitlines() + assert out_lines[-2] == 'An unexpected error has occurred: ValueError: β˜ƒ' + assert out_lines[-1] == f'Check the log at {log_file}' diff --git a/tests/git_test.py b/tests/git_test.py new file mode 100644 index 0000000..e73a6f2 --- /dev/null +++ b/tests/git_test.py @@ -0,0 +1,188 @@ +import os.path + +import pytest + +from pre_commit import git +from pre_commit.util import cmd_output +from testing.util import git_commit + + +def test_get_root_at_root(in_git_dir): + expected = os.path.normcase(in_git_dir.strpath) + assert os.path.normcase(git.get_root()) == expected + + +def test_get_root_deeper(in_git_dir): + expected = os.path.normcase(in_git_dir.strpath) + with in_git_dir.join('foo').ensure_dir().as_cwd(): + assert os.path.normcase(git.get_root()) == expected + + +def test_get_staged_files_deleted(in_git_dir): + in_git_dir.join('test').ensure() + cmd_output('git', 'add', 'test') + git_commit() + cmd_output('git', 'rm', '--cached', 'test') + assert git.get_staged_files() == [] + + +def test_is_not_in_merge_conflict(in_git_dir): + assert git.is_in_merge_conflict() is False + + +def test_is_in_merge_conflict(in_merge_conflict): + assert git.is_in_merge_conflict() is True + + +def test_is_in_merge_conflict_submodule(in_conflicting_submodule): + assert git.is_in_merge_conflict() is True + + +def test_cherry_pick_conflict(in_merge_conflict): + cmd_output('git', 'merge', '--abort') + foo_ref = cmd_output('git', 'rev-parse', 'foo')[1].strip() + cmd_output('git', 'cherry-pick', foo_ref, retcode=None) + assert git.is_in_merge_conflict() is False + + +def resolve_conflict(): + with open('conflict_file', 'w') as conflicted_file: + conflicted_file.write('herp\nderp\n') + cmd_output('git', 'add', 'conflict_file') + + +def test_get_conflicted_files(in_merge_conflict): + resolve_conflict() + with open('other_file', 'w') as other_file: + other_file.write('oh hai') + cmd_output('git', 'add', 'other_file') + + ret = set(git.get_conflicted_files()) + assert ret == {'conflict_file', 'other_file'} + + +def test_get_conflicted_files_in_submodule(in_conflicting_submodule): + resolve_conflict() + assert set(git.get_conflicted_files()) == {'conflict_file'} + + +def test_get_conflicted_files_unstaged_files(in_merge_conflict): + """This case no longer occurs, but it is a useful test nonetheless""" + resolve_conflict() + + # Make unstaged file. + with open('bar_only_file', 'w') as bar_only_file: + bar_only_file.write('new contents!\n') + + ret = set(git.get_conflicted_files()) + assert ret == {'conflict_file'} + + +MERGE_MSG = b"Merge branch 'foo' into bar\n\nConflicts:\n\tconflict_file\n" +OTHER_MERGE_MSG = MERGE_MSG + b'\tother_conflict_file\n' + + +@pytest.mark.parametrize( + ('input', 'expected_output'), + ( + (MERGE_MSG, ['conflict_file']), + (OTHER_MERGE_MSG, ['conflict_file', 'other_conflict_file']), + ), +) +def test_parse_merge_msg_for_conflicts(input, expected_output): + ret = git.parse_merge_msg_for_conflicts(input) + assert ret == expected_output + + +def test_get_changed_files(in_git_dir): + git_commit() + in_git_dir.join('a.txt').ensure() + in_git_dir.join('b.txt').ensure() + cmd_output('git', 'add', '.') + git_commit() + files = git.get_changed_files('HEAD^', 'HEAD') + assert files == ['a.txt', 'b.txt'] + + # files changed in source but not in origin should not be returned + files = git.get_changed_files('HEAD', 'HEAD^') + assert files == [] + + +@pytest.mark.parametrize( + ('s', 'expected'), + ( + ('foo\0bar\0', ['foo', 'bar']), + ('foo\0', ['foo']), + ('', []), + ('foo', ['foo']), + ), +) +def test_zsplit(s, expected): + assert git.zsplit(s) == expected + + +@pytest.fixture +def non_ascii_repo(in_git_dir): + git_commit() + in_git_dir.join('ΠΈΠ½Ρ‚Π΅Ρ€Π²ΡŒΡŽ').ensure() + cmd_output('git', 'add', '.') + git_commit() + yield in_git_dir + + +def test_all_files_non_ascii(non_ascii_repo): + ret = git.get_all_files() + assert ret == ['ΠΈΠ½Ρ‚Π΅Ρ€Π²ΡŒΡŽ'] + + +def test_staged_files_non_ascii(non_ascii_repo): + non_ascii_repo.join('ΠΈΠ½Ρ‚Π΅Ρ€Π²ΡŒΡŽ').write('hi') + cmd_output('git', 'add', '.') + assert git.get_staged_files() == ['ΠΈΠ½Ρ‚Π΅Ρ€Π²ΡŒΡŽ'] + + +def test_changed_files_non_ascii(non_ascii_repo): + ret = git.get_changed_files('HEAD^', 'HEAD') + assert ret == ['ΠΈΠ½Ρ‚Π΅Ρ€Π²ΡŒΡŽ'] + + +def test_get_conflicted_files_non_ascii(in_merge_conflict): + open('ΠΈΠ½Ρ‚Π΅Ρ€Π²ΡŒΡŽ', 'a').close() + cmd_output('git', 'add', '.') + ret = git.get_conflicted_files() + assert ret == {'conflict_file', 'ΠΈΠ½Ρ‚Π΅Ρ€Π²ΡŒΡŽ'} + + +def test_intent_to_add(in_git_dir): + in_git_dir.join('a').ensure() + cmd_output('git', 'add', '--intent-to-add', 'a') + + assert git.intent_to_add_files() == ['a'] + + +def test_status_output_with_rename(in_git_dir): + in_git_dir.join('a').write('1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n') + cmd_output('git', 'add', 'a') + git_commit() + cmd_output('git', 'mv', 'a', 'b') + in_git_dir.join('c').ensure() + cmd_output('git', 'add', '--intent-to-add', 'c') + + assert git.intent_to_add_files() == ['c'] + + +def test_no_git_env(): + env = { + 'http_proxy': 'http://myproxy:80', + 'GIT_EXEC_PATH': '/some/git/exec/path', + 'GIT_SSH': '/usr/bin/ssh', + 'GIT_SSH_COMMAND': 'ssh -o', + 'GIT_DIR': '/none/shall/pass', + } + no_git_env = git.no_git_env(env) + assert no_git_env == { + 'http_proxy': 'http://myproxy:80', + 'GIT_EXEC_PATH': '/some/git/exec/path', + 'GIT_SSH': '/usr/bin/ssh', + 'GIT_SSH_COMMAND': 'ssh -o', + } diff --git a/tests/languages/__init__.py b/tests/languages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/languages/docker_test.py b/tests/languages/docker_test.py new file mode 100644 index 0000000..171a3f7 --- /dev/null +++ b/tests/languages/docker_test.py @@ -0,0 +1,23 @@ +from unittest import mock + +from pre_commit.languages import docker +from pre_commit.util import CalledProcessError + + +def test_docker_is_running_process_error(): + with mock.patch( + 'pre_commit.languages.docker.cmd_output_b', + side_effect=CalledProcessError(1, (), 0, b'', None), + ): + assert docker.docker_is_running() is False + + +def test_docker_fallback_user(): + def invalid_attribute(): + raise AttributeError + with mock.patch.multiple( + 'os', create=True, + getuid=invalid_attribute, + getgid=invalid_attribute, + ): + assert docker.get_docker_user() == '1000:1000' diff --git a/tests/languages/golang_test.py b/tests/languages/golang_test.py new file mode 100644 index 0000000..9a64ed1 --- /dev/null +++ b/tests/languages/golang_test.py @@ -0,0 +1,20 @@ +import pytest + +from pre_commit.languages.golang import guess_go_dir + + +@pytest.mark.parametrize( + ('url', 'expected'), + ( + ('/im/a/path/on/disk', 'unknown_src_dir'), + ('file:///im/a/path/on/disk', 'unknown_src_dir'), + ('git@github.com:golang/lint', 'github.com/golang/lint'), + ('git://github.com/golang/lint', 'github.com/golang/lint'), + ('http://github.com/golang/lint', 'github.com/golang/lint'), + ('https://github.com/golang/lint', 'github.com/golang/lint'), + ('ssh://git@github.com/golang/lint', 'github.com/golang/lint'), + ('git@github.com:golang/lint.git', 'github.com/golang/lint'), + ), +) +def test_guess_go_dir(url, expected): + assert guess_go_dir(url) == expected diff --git a/tests/languages/helpers_test.py b/tests/languages/helpers_test.py new file mode 100644 index 0000000..c52e947 --- /dev/null +++ b/tests/languages/helpers_test.py @@ -0,0 +1,82 @@ +import multiprocessing +import os +import sys +from unittest import mock + +import pytest + +import pre_commit.constants as C +from pre_commit.languages import helpers +from pre_commit.prefix import Prefix +from pre_commit.util import CalledProcessError +from testing.auto_namedtuple import auto_namedtuple + + +def test_basic_get_default_version(): + assert helpers.basic_get_default_version() == C.DEFAULT + + +def test_basic_healthy(): + assert helpers.basic_healthy(Prefix('.'), 'default') is True + + +def test_failed_setup_command_does_not_unicode_error(): + script = ( + 'import sys\n' + "getattr(sys.stderr, 'buffer', sys.stderr).write(b'\\x81\\xfe')\n" + 'exit(1)\n' + ) + + # an assertion that this does not raise `UnicodeError` + with pytest.raises(CalledProcessError): + helpers.run_setup_cmd(Prefix('.'), (sys.executable, '-c', script)) + + +def test_assert_no_additional_deps(): + with pytest.raises(AssertionError) as excinfo: + helpers.assert_no_additional_deps('lang', ['hmmm']) + msg, = excinfo.value.args + assert msg == ( + 'For now, pre-commit does not support additional_dependencies for lang' + ) + + +SERIAL_FALSE = auto_namedtuple(require_serial=False) +SERIAL_TRUE = auto_namedtuple(require_serial=True) + + +def test_target_concurrency_normal(): + with mock.patch.object(multiprocessing, 'cpu_count', return_value=123): + with mock.patch.dict(os.environ, {}, clear=True): + assert helpers.target_concurrency(SERIAL_FALSE) == 123 + + +def test_target_concurrency_cpu_count_require_serial_true(): + with mock.patch.dict(os.environ, {}, clear=True): + assert helpers.target_concurrency(SERIAL_TRUE) == 1 + + +def test_target_concurrency_testing_env_var(): + with mock.patch.dict( + os.environ, {'PRE_COMMIT_NO_CONCURRENCY': '1'}, clear=True, + ): + assert helpers.target_concurrency(SERIAL_FALSE) == 1 + + +def test_target_concurrency_on_travis(): + with mock.patch.dict(os.environ, {'TRAVIS': '1'}, clear=True): + assert helpers.target_concurrency(SERIAL_FALSE) == 2 + + +def test_target_concurrency_cpu_count_not_implemented(): + with mock.patch.object( + multiprocessing, 'cpu_count', side_effect=NotImplementedError, + ): + with mock.patch.dict(os.environ, {}, clear=True): + assert helpers.target_concurrency(SERIAL_FALSE) == 1 + + +def test_shuffled_is_deterministic(): + seq = [str(i) for i in range(10)] + expected = ['3', '7', '8', '2', '4', '6', '5', '1', '0', '9'] + assert helpers._shuffled(seq) == expected diff --git a/tests/languages/pygrep_test.py b/tests/languages/pygrep_test.py new file mode 100644 index 0000000..cabea22 --- /dev/null +++ b/tests/languages/pygrep_test.py @@ -0,0 +1,65 @@ +import pytest + +from pre_commit.languages import pygrep + + +@pytest.fixture +def some_files(tmpdir): + tmpdir.join('f1').write_binary(b'foo\nbar\n') + tmpdir.join('f2').write_binary(b'[INFO] hi\n') + tmpdir.join('f3').write_binary(b"with'quotes\n") + with tmpdir.as_cwd(): + yield + + +@pytest.mark.usefixtures('some_files') +@pytest.mark.parametrize( + ('pattern', 'expected_retcode', 'expected_out'), + ( + ('baz', 0, ''), + ('foo', 1, 'f1:1:foo\n'), + ('bar', 1, 'f1:2:bar\n'), + (r'(?i)\[info\]', 1, 'f2:1:[INFO] hi\n'), + ("h'q", 1, "f3:1:with'quotes\n"), + ), +) +def test_main(some_files, cap_out, pattern, expected_retcode, expected_out): + ret = pygrep.main((pattern, 'f1', 'f2', 'f3')) + out = cap_out.get() + assert ret == expected_retcode + assert out == expected_out + + +def test_ignore_case(some_files, cap_out): + ret = pygrep.main(('--ignore-case', 'info', 'f1', 'f2', 'f3')) + out = cap_out.get() + assert ret == 1 + assert out == 'f2:1:[INFO] hi\n' + + +def test_multiline(some_files, cap_out): + ret = pygrep.main(('--multiline', r'foo\nbar', 'f1', 'f2', 'f3')) + out = cap_out.get() + assert ret == 1 + assert out == 'f1:1:foo\nbar\n' + + +def test_multiline_line_number(some_files, cap_out): + ret = pygrep.main(('--multiline', r'ar', 'f1', 'f2', 'f3')) + out = cap_out.get() + assert ret == 1 + assert out == 'f1:2:bar\n' + + +def test_multiline_dotall_flag_is_enabled(some_files, cap_out): + ret = pygrep.main(('--multiline', r'o.*bar', 'f1', 'f2', 'f3')) + out = cap_out.get() + assert ret == 1 + assert out == 'f1:1:foo\nbar\n' + + +def test_multiline_multiline_flag_is_enabled(some_files, cap_out): + ret = pygrep.main(('--multiline', r'foo$.*bar', 'f1', 'f2', 'f3')) + out = cap_out.get() + assert ret == 1 + assert out == 'f1:1:foo\nbar\n' diff --git a/tests/languages/python_test.py b/tests/languages/python_test.py new file mode 100644 index 0000000..34c6c7f --- /dev/null +++ b/tests/languages/python_test.py @@ -0,0 +1,75 @@ +import os.path +import sys +from unittest import mock + +import pytest + +import pre_commit.constants as C +from pre_commit.languages import python +from pre_commit.prefix import Prefix + + +def test_norm_version_expanduser(): + home = os.path.expanduser('~') + if os.name == 'nt': # pragma: nt cover + path = r'~\python343' + expected_path = fr'{home}\python343' + else: # pragma: nt no cover + path = '~/.pyenv/versions/3.4.3/bin/python' + expected_path = f'{home}/.pyenv/versions/3.4.3/bin/python' + result = python.norm_version(path) + assert result == expected_path + + +@pytest.mark.parametrize('v', ('python3.6', 'python3', 'python')) +def test_sys_executable_matches(v): + with mock.patch.object(sys, 'version_info', (3, 6, 7)): + assert python._sys_executable_matches(v) + + +@pytest.mark.parametrize('v', ('notpython', 'python3.x')) +def test_sys_executable_matches_does_not_match(v): + with mock.patch.object(sys, 'version_info', (3, 6, 7)): + assert not python._sys_executable_matches(v) + + +@pytest.mark.parametrize( + ('exe', 'realpath', 'expected'), ( + ('/usr/bin/python3', '/usr/bin/python3.7', 'python3'), + ('/usr/bin/python', '/usr/bin/python3.7', 'python3.7'), + ('/usr/bin/python', '/usr/bin/python', None), + ('/usr/bin/python3.6m', '/usr/bin/python3.6m', 'python3.6m'), + ('v/bin/python', 'v/bin/pypy', 'pypy'), + ), +) +def test_find_by_sys_executable(exe, realpath, expected): + with mock.patch.object(sys, 'executable', exe): + with mock.patch.object(os.path, 'realpath', return_value=realpath): + with mock.patch.object(python, 'find_executable', lambda x: x): + assert python._find_by_sys_executable() == expected + + +def test_healthy_types_py_in_cwd(tmpdir): + with tmpdir.as_cwd(): + prefix = tmpdir.join('prefix').ensure_dir() + prefix.join('setup.py').write('import setuptools; setuptools.setup()') + prefix = Prefix(str(prefix)) + python.install_environment(prefix, C.DEFAULT, ()) + + # even if a `types.py` file exists, should still be healthy + tmpdir.join('types.py').ensure() + assert python.healthy(prefix, C.DEFAULT) is True + + +def test_healthy_python_goes_missing(tmpdir): + with tmpdir.as_cwd(): + prefix = tmpdir.join('prefix').ensure_dir() + prefix.join('setup.py').write('import setuptools; setuptools.setup()') + prefix = Prefix(str(prefix)) + python.install_environment(prefix, C.DEFAULT, ()) + + exe_name = 'python' if sys.platform != 'win32' else 'python.exe' + py_exe = prefix.path(python.bin_dir('py_env-default'), exe_name) + os.remove(py_exe) + + assert python.healthy(prefix, C.DEFAULT) is False diff --git a/tests/languages/ruby_test.py b/tests/languages/ruby_test.py new file mode 100644 index 0000000..36a029d --- /dev/null +++ b/tests/languages/ruby_test.py @@ -0,0 +1,28 @@ +import os.path + +from pre_commit.languages import ruby +from pre_commit.prefix import Prefix +from pre_commit.util import cmd_output +from testing.util import xfailif_windows_no_ruby + + +@xfailif_windows_no_ruby +def test_install_rbenv(tempdir_factory): + prefix = Prefix(tempdir_factory.get()) + ruby._install_rbenv(prefix) + # Should have created rbenv directory + assert os.path.exists(prefix.path('rbenv-default')) + + # Should be able to activate using our script and access rbenv + with ruby.in_env(prefix, 'default'): + cmd_output('rbenv', '--help') + + +@xfailif_windows_no_ruby +def test_install_rbenv_with_version(tempdir_factory): + prefix = Prefix(tempdir_factory.get()) + ruby._install_rbenv(prefix, version='1.9.3p547') + + # Should be able to activate and use rbenv install + with ruby.in_env(prefix, '1.9.3p547'): + cmd_output('rbenv', 'install', '--help') diff --git a/tests/logging_handler_test.py b/tests/logging_handler_test.py new file mode 100644 index 0000000..fe68593 --- /dev/null +++ b/tests/logging_handler_test.py @@ -0,0 +1,21 @@ +import logging + +from pre_commit import color +from pre_commit.logging_handler import LoggingHandler + + +def _log_record(message, level): + return logging.LogRecord('name', level, '', 1, message, {}, None) + + +def test_logging_handler_color(cap_out): + handler = LoggingHandler(True) + handler.emit(_log_record('hi', logging.WARNING)) + ret = cap_out.get() + assert ret == f'{color.YELLOW}[WARNING]{color.NORMAL} hi\n' + + +def test_logging_handler_no_color(cap_out): + handler = LoggingHandler(False) + handler.emit(_log_record('hi', logging.WARNING)) + assert cap_out.get() == '[WARNING] hi\n' diff --git a/tests/main_test.py b/tests/main_test.py new file mode 100644 index 0000000..c472476 --- /dev/null +++ b/tests/main_test.py @@ -0,0 +1,189 @@ +import argparse +import os.path +from unittest import mock + +import pytest + +import pre_commit.constants as C +from pre_commit import main +from pre_commit.error_handler import FatalError +from testing.auto_namedtuple import auto_namedtuple + + +@pytest.mark.parametrize( + ('argv', 'expected'), + ( + ((), ['f']), + (('--f', 'x'), ['x']), + (('--f', 'x', '--f', 'y'), ['x', 'y']), + ), +) +def test_append_replace_default(argv, expected): + parser = argparse.ArgumentParser() + parser.add_argument('--f', action=main.AppendReplaceDefault, default=['f']) + assert parser.parse_args(argv).f == expected + + +def _args(**kwargs): + kwargs.setdefault('command', 'help') + kwargs.setdefault('config', C.CONFIG_FILE) + return argparse.Namespace(**kwargs) + + +def test_adjust_args_and_chdir_not_in_git_dir(in_tmpdir): + with pytest.raises(FatalError): + main._adjust_args_and_chdir(_args()) + + +def test_adjust_args_and_chdir_in_dot_git_dir(in_git_dir): + with in_git_dir.join('.git').as_cwd(), pytest.raises(FatalError): + main._adjust_args_and_chdir(_args()) + + +def test_adjust_args_and_chdir_noop(in_git_dir): + args = _args(command='run', files=['f1', 'f2']) + main._adjust_args_and_chdir(args) + assert os.getcwd() == in_git_dir + assert args.config == C.CONFIG_FILE + assert args.files == ['f1', 'f2'] + + +def test_adjust_args_and_chdir_relative_things(in_git_dir): + in_git_dir.join('foo/cfg.yaml').ensure() + in_git_dir.join('foo').chdir() + + args = _args(command='run', files=['f1', 'f2'], config='cfg.yaml') + main._adjust_args_and_chdir(args) + assert os.getcwd() == in_git_dir + assert args.config == os.path.join('foo', 'cfg.yaml') + assert args.files == [os.path.join('foo', 'f1'), os.path.join('foo', 'f2')] + + +def test_adjust_args_and_chdir_non_relative_config(in_git_dir): + in_git_dir.join('foo').ensure_dir().chdir() + + args = _args() + main._adjust_args_and_chdir(args) + assert os.getcwd() == in_git_dir + assert args.config == C.CONFIG_FILE + + +def test_adjust_args_try_repo_repo_relative(in_git_dir): + in_git_dir.join('foo').ensure_dir().chdir() + + args = _args(command='try-repo', repo='../foo', files=[]) + assert args.repo is not None + assert os.path.exists(args.repo) + main._adjust_args_and_chdir(args) + assert os.getcwd() == in_git_dir + assert os.path.exists(args.repo) + assert args.repo == 'foo' + + +FNS = ( + 'autoupdate', 'clean', 'gc', 'hook_impl', 'install', 'install_hooks', + 'migrate_config', 'run', 'sample_config', 'uninstall', +) +CMDS = tuple(fn.replace('_', '-') for fn in FNS) + + +@pytest.fixture +def mock_commands(): + mcks = {fn: mock.patch.object(main, fn).start() for fn in FNS} + ret = auto_namedtuple(**mcks) + yield ret + for mck in ret: + mck.stop() + + +@pytest.fixture +def argparse_parse_args_spy(): + parse_args_mock = mock.Mock() + + original_parse_args = argparse.ArgumentParser.parse_args + + def fake_parse_args(self, args): + # call our spy object + parse_args_mock(args) + return original_parse_args(self, args) + + with mock.patch.object( + argparse.ArgumentParser, 'parse_args', fake_parse_args, + ): + yield parse_args_mock + + +def assert_only_one_mock_called(mock_objs): + total_call_count = sum(mock_obj.call_count for mock_obj in mock_objs) + assert total_call_count == 1 + + +def test_overall_help(mock_commands): + with pytest.raises(SystemExit): + main.main(['--help']) + + +def test_help_command(mock_commands, argparse_parse_args_spy): + with pytest.raises(SystemExit): + main.main(['help']) + + argparse_parse_args_spy.assert_has_calls([ + mock.call(['help']), + mock.call(['--help']), + ]) + + +def test_help_other_command(mock_commands, argparse_parse_args_spy): + with pytest.raises(SystemExit): + main.main(['help', 'run']) + + argparse_parse_args_spy.assert_has_calls([ + mock.call(['help', 'run']), + mock.call(['run', '--help']), + ]) + + +@pytest.mark.parametrize('command', CMDS) +def test_all_cmds(command, mock_commands, mock_store_dir): + main.main((command,)) + assert getattr(mock_commands, command.replace('-', '_')).call_count == 1 + assert_only_one_mock_called(mock_commands) + + +def test_try_repo(mock_store_dir): + with mock.patch.object(main, 'try_repo') as patch: + main.main(('try-repo', '.')) + assert patch.call_count == 1 + + +def test_init_templatedir(mock_store_dir): + with mock.patch.object(main, 'init_templatedir') as patch: + main.main(('init-templatedir', 'tdir')) + assert patch.call_count == 1 + + +def test_help_cmd_in_empty_directory( + in_tmpdir, + mock_commands, + argparse_parse_args_spy, +): + with pytest.raises(SystemExit): + main.main(['help', 'run']) + + argparse_parse_args_spy.assert_has_calls([ + mock.call(['help', 'run']), + mock.call(['run', '--help']), + ]) + + +def test_expected_fatal_error_no_git_repo(in_tmpdir, cap_out, mock_store_dir): + with pytest.raises(SystemExit): + main.main([]) + log_file = os.path.join(mock_store_dir, 'pre-commit.log') + cap_out_lines = cap_out.get().splitlines() + assert ( + cap_out_lines[-2] == + 'An error has occurred: FatalError: git failed. ' + 'Is it installed, and are you in a Git repository directory?' + ) + assert cap_out_lines[-1] == f'Check the log at {log_file}' diff --git a/tests/make_archives_test.py b/tests/make_archives_test.py new file mode 100644 index 0000000..6ae2f8e --- /dev/null +++ b/tests/make_archives_test.py @@ -0,0 +1,46 @@ +import tarfile + +from pre_commit import git +from pre_commit import make_archives +from pre_commit.util import cmd_output +from testing.util import git_commit + + +def test_make_archive(in_git_dir, tmpdir): + output_dir = tmpdir.join('output').ensure_dir() + # Add a files to the git directory + in_git_dir.join('foo').ensure() + cmd_output('git', 'add', '.') + git_commit() + # We'll use this rev + head_rev = git.head_rev('.') + # And check that this file doesn't exist + in_git_dir.join('bar').ensure() + cmd_output('git', 'add', '.') + git_commit() + + # Do the thing + archive_path = make_archives.make_archive( + 'foo', in_git_dir.strpath, head_rev, output_dir.strpath, + ) + + expected = output_dir.join('foo.tar.gz') + assert archive_path == expected.strpath + assert expected.exists() + + extract_dir = tmpdir.join('extract').ensure_dir() + with tarfile.open(archive_path) as tf: + tf.extractall(extract_dir.strpath) + + # Verify the contents of the tar + assert extract_dir.join('foo').isdir() + assert extract_dir.join('foo/foo').exists() + assert not extract_dir.join('foo/.git').exists() + assert not extract_dir.join('foo/bar').exists() + + +def test_main(tmpdir): + make_archives.main(('--dest', tmpdir.strpath)) + + for archive, _, _ in make_archives.REPOS: + assert tmpdir.join(f'{archive}.tar.gz').exists() diff --git a/tests/meta_hooks/__init__.py b/tests/meta_hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/meta_hooks/check_hooks_apply_test.py b/tests/meta_hooks/check_hooks_apply_test.py new file mode 100644 index 0000000..06bdd04 --- /dev/null +++ b/tests/meta_hooks/check_hooks_apply_test.py @@ -0,0 +1,138 @@ +from pre_commit.meta_hooks import check_hooks_apply +from testing.fixtures import add_config_to_repo + + +def test_hook_excludes_everything(capsys, in_git_dir, mock_store_dir): + config = { + 'repos': [ + { + 'repo': 'meta', + 'hooks': [ + { + 'id': 'check-useless-excludes', + 'exclude': '.pre-commit-config.yaml', + }, + ], + }, + ], + } + + add_config_to_repo(in_git_dir.strpath, config) + + assert check_hooks_apply.main(()) == 1 + + out, _ = capsys.readouterr() + assert 'check-useless-excludes does not apply to this repository' in out + + +def test_hook_includes_nothing(capsys, in_git_dir, mock_store_dir): + config = { + 'repos': [ + { + 'repo': 'meta', + 'hooks': [ + { + 'id': 'check-useless-excludes', + 'files': 'foo', + }, + ], + }, + ], + } + + add_config_to_repo(in_git_dir.strpath, config) + + assert check_hooks_apply.main(()) == 1 + + out, _ = capsys.readouterr() + assert 'check-useless-excludes does not apply to this repository' in out + + +def test_hook_types_not_matched(capsys, in_git_dir, mock_store_dir): + config = { + 'repos': [ + { + 'repo': 'meta', + 'hooks': [ + { + 'id': 'check-useless-excludes', + 'types': ['python'], + }, + ], + }, + ], + } + + add_config_to_repo(in_git_dir.strpath, config) + + assert check_hooks_apply.main(()) == 1 + + out, _ = capsys.readouterr() + assert 'check-useless-excludes does not apply to this repository' in out + + +def test_hook_types_excludes_everything(capsys, in_git_dir, mock_store_dir): + config = { + 'repos': [ + { + 'repo': 'meta', + 'hooks': [ + { + 'id': 'check-useless-excludes', + 'exclude_types': ['yaml'], + }, + ], + }, + ], + } + + add_config_to_repo(in_git_dir.strpath, config) + + assert check_hooks_apply.main(()) == 1 + + out, _ = capsys.readouterr() + assert 'check-useless-excludes does not apply to this repository' in out + + +def test_valid_exceptions(capsys, in_git_dir, mock_store_dir): + config = { + 'repos': [ + { + 'repo': 'local', + 'hooks': [ + # applies to a file + { + 'id': 'check-yaml', + 'name': 'check yaml', + 'entry': './check-yaml', + 'language': 'script', + 'files': r'\.yaml$', + }, + # Should not be reported as an error due to language: fail + { + 'id': 'changelogs-rst', + 'name': 'changelogs must be rst', + 'entry': 'changelog filenames must end in .rst', + 'language': 'fail', + 'files': r'changelog/.*(? str: + exe = shutil.which('echo') + assert exe is not None + return exe + + +def test_file_doesnt_exist(): + assert parse_shebang.parse_filename('herp derp derp') == () + + +def test_simple_case(tmpdir): + x = tmpdir.join('f') + x.write('#!/usr/bin/env echo') + make_executable(x.strpath) + assert parse_shebang.parse_filename(x.strpath) == ('echo',) + + +def test_find_executable_full_path(): + assert parse_shebang.find_executable(sys.executable) == sys.executable + + +def test_find_executable_on_path(): + assert parse_shebang.find_executable('echo') == _echo_exe() + + +def test_find_executable_not_found_none(): + assert parse_shebang.find_executable('not-a-real-executable') is None + + +def write_executable(shebang, filename='run'): + os.mkdir('bin') + path = os.path.join('bin', filename) + with open(path, 'w') as f: + f.write(f'#!{shebang}') + make_executable(path) + return path + + +@contextlib.contextmanager +def bin_on_path(): + bindir = os.path.join(os.getcwd(), 'bin') + with envcontext((('PATH', (bindir, os.pathsep, Var('PATH'))),)): + yield + + +def test_find_executable_path_added(in_tmpdir): + path = os.path.abspath(write_executable('/usr/bin/env sh')) + assert parse_shebang.find_executable('run') is None + with bin_on_path(): + assert parse_shebang.find_executable('run') == path + + +def test_find_executable_path_ext(in_tmpdir): + """Windows exports PATHEXT as a list of extensions to automatically add + to executables when doing PATH searching. + """ + exe_path = os.path.abspath( + write_executable('/usr/bin/env sh', filename='run.myext'), + ) + env_path = {'PATH': os.path.dirname(exe_path)} + env_path_ext = dict(env_path, PATHEXT=os.pathsep.join(('.exe', '.myext'))) + assert parse_shebang.find_executable('run') is None + assert parse_shebang.find_executable('run', _environ=env_path) is None + ret = parse_shebang.find_executable('run.myext', _environ=env_path) + assert ret == exe_path + ret = parse_shebang.find_executable('run', _environ=env_path_ext) + assert ret == exe_path + + +def test_normexe_does_not_exist(): + with pytest.raises(OSError) as excinfo: + parse_shebang.normexe('i-dont-exist-lol') + assert excinfo.value.args == ('Executable `i-dont-exist-lol` not found',) + + +def test_normexe_does_not_exist_sep(): + with pytest.raises(OSError) as excinfo: + parse_shebang.normexe('./i-dont-exist-lol') + assert excinfo.value.args == ('Executable `./i-dont-exist-lol` not found',) + + +@pytest.mark.xfail(os.name == 'nt', reason='posix only') +def test_normexe_not_executable(tmpdir): # pragma: win32 no cover + tmpdir.join('exe').ensure() + with tmpdir.as_cwd(), pytest.raises(OSError) as excinfo: + parse_shebang.normexe('./exe') + assert excinfo.value.args == ('Executable `./exe` is not executable',) + + +def test_normexe_is_a_directory(tmpdir): + with tmpdir.as_cwd(): + tmpdir.join('exe').ensure_dir() + exe = os.path.join('.', 'exe') + with pytest.raises(OSError) as excinfo: + parse_shebang.normexe(exe) + msg, = excinfo.value.args + assert msg == f'Executable `{exe}` is a directory' + + +def test_normexe_already_full_path(): + assert parse_shebang.normexe(sys.executable) == sys.executable + + +def test_normexe_gives_full_path(): + assert parse_shebang.normexe('echo') == _echo_exe() + assert os.sep in _echo_exe() + + +def test_normalize_cmd_trivial(): + cmd = (_echo_exe(), 'hi') + assert parse_shebang.normalize_cmd(cmd) == cmd + + +def test_normalize_cmd_PATH(): + cmd = ('echo', '--version') + expected = (_echo_exe(), '--version') + assert parse_shebang.normalize_cmd(cmd) == expected + + +def test_normalize_cmd_shebang(in_tmpdir): + echo = _echo_exe().replace(os.sep, '/') + path = write_executable(echo) + assert parse_shebang.normalize_cmd((path,)) == (echo, path) + + +def test_normalize_cmd_PATH_shebang_full_path(in_tmpdir): + echo = _echo_exe().replace(os.sep, '/') + path = write_executable(echo) + with bin_on_path(): + ret = parse_shebang.normalize_cmd(('run',)) + assert ret == (echo, os.path.abspath(path)) + + +def test_normalize_cmd_PATH_shebang_PATH(in_tmpdir): + echo = _echo_exe() + path = write_executable('/usr/bin/env echo') + with bin_on_path(): + ret = parse_shebang.normalize_cmd(('run',)) + assert ret == (echo, os.path.abspath(path)) diff --git a/tests/prefix_test.py b/tests/prefix_test.py new file mode 100644 index 0000000..6ce8be1 --- /dev/null +++ b/tests/prefix_test.py @@ -0,0 +1,44 @@ +import os.path + +import pytest + +from pre_commit.prefix import Prefix + + +def norm_slash(*args): + return tuple(x.replace('/', os.sep) for x in args) + + +@pytest.mark.parametrize( + ('prefix', 'path_end', 'expected_output'), + ( + norm_slash('foo', '', 'foo'), + norm_slash('foo', 'bar', 'foo/bar'), + norm_slash('foo/bar', '../baz', 'foo/baz'), + norm_slash('./', 'bar', 'bar'), + norm_slash('./', '', '.'), + norm_slash('/tmp/foo', '/tmp/bar', '/tmp/bar'), + ), +) +def test_path(prefix, path_end, expected_output): + instance = Prefix(prefix) + ret = instance.path(path_end) + assert ret == expected_output + + +def test_path_multiple_args(): + instance = Prefix('foo') + ret = instance.path('bar', 'baz') + assert ret == os.path.join('foo', 'bar', 'baz') + + +def test_exists(tmpdir): + assert not Prefix(str(tmpdir)).exists('foo') + tmpdir.ensure('foo') + assert Prefix(str(tmpdir)).exists('foo') + + +def test_star(tmpdir): + for f in ('a.txt', 'b.txt', 'c.py'): + tmpdir.join(f).ensure() + assert set(Prefix(str(tmpdir)).star('.txt')) == {'a.txt', 'b.txt'} diff --git a/tests/repository_test.py b/tests/repository_test.py new file mode 100644 index 0000000..df7e7d1 --- /dev/null +++ b/tests/repository_test.py @@ -0,0 +1,902 @@ +import os.path +import re +import shutil +import sys +from typing import Any +from typing import Dict +from unittest import mock + +import cfgv +import pytest + +import pre_commit.constants as C +from pre_commit.clientlib import CONFIG_SCHEMA +from pre_commit.clientlib import load_manifest +from pre_commit.envcontext import envcontext +from pre_commit.hook import Hook +from pre_commit.languages import golang +from pre_commit.languages import helpers +from pre_commit.languages import node +from pre_commit.languages import python +from pre_commit.languages import ruby +from pre_commit.languages import rust +from pre_commit.languages.all import languages +from pre_commit.prefix import Prefix +from pre_commit.repository import all_hooks +from pre_commit.repository import install_hook_envs +from pre_commit.util import cmd_output +from pre_commit.util import cmd_output_b +from testing.fixtures import make_config_from_repo +from testing.fixtures import make_repo +from testing.fixtures import modify_manifest +from testing.util import cwd +from testing.util import get_resource_path +from testing.util import skipif_cant_run_docker +from testing.util import skipif_cant_run_swift +from testing.util import xfailif_no_venv +from testing.util import xfailif_windows_no_ruby + + +def _norm_out(b): + return b.replace(b'\r\n', b'\n') + + +def _hook_run(hook, filenames, color): + return languages[hook.language].run_hook(hook, filenames, color) + + +def _get_hook_no_install(repo_config, store, hook_id): + config = {'repos': [repo_config]} + config = cfgv.validate(config, CONFIG_SCHEMA) + config = cfgv.apply_defaults(config, CONFIG_SCHEMA) + hooks = all_hooks(config, store) + hook, = [hook for hook in hooks if hook.id == hook_id] + return hook + + +def _get_hook(repo_config, store, hook_id): + hook = _get_hook_no_install(repo_config, store, hook_id) + install_hook_envs([hook], store) + return hook + + +def _test_hook_repo( + tempdir_factory, + store, + repo_path, + hook_id, + args, + expected, + expected_return_code=0, + config_kwargs=None, + color=False, +): + path = make_repo(tempdir_factory, repo_path) + config = make_config_from_repo(path, **(config_kwargs or {})) + hook = _get_hook(config, store, hook_id) + ret, out = _hook_run(hook, args, color=color) + assert ret == expected_return_code + assert _norm_out(out) == expected + + +def test_conda_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'conda_hooks_repo', + 'sys-exec', [os.devnull], + b'conda-default\n', + ) + + +def test_conda_with_additional_dependencies_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'conda_hooks_repo', + 'additional-deps', [os.devnull], + b'OK\n', + config_kwargs={ + 'hooks': [{ + 'id': 'additional-deps', + 'args': ['-c', 'import mccabe; print("OK")'], + 'additional_dependencies': ['mccabe'], + }], + }, + ) + + +def test_local_conda_additional_dependencies(store): + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'local-conda', + 'name': 'local-conda', + 'entry': 'python', + 'language': 'conda', + 'args': ['-c', 'import mccabe; print("OK")'], + 'additional_dependencies': ['mccabe'], + }], + } + hook = _get_hook(config, store, 'local-conda') + ret, out = _hook_run(hook, (), color=False) + assert ret == 0 + assert _norm_out(out) == b'OK\n' + + +def test_python_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'python_hooks_repo', + 'foo', [os.devnull], + f'[{os.devnull!r}]\nHello World\n'.encode(), + ) + + +def test_python_hook_default_version(tempdir_factory, store): + # make sure that this continues to work for platforms where default + # language detection does not work + with mock.patch.object( + python, 'get_default_version', return_value=C.DEFAULT, + ): + test_python_hook(tempdir_factory, store) + + +def test_python_hook_args_with_spaces(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'python_hooks_repo', + 'foo', + [], + b"['i have spaces', 'and\"\\'quotes', '$and !this']\n" + b'Hello World\n', + config_kwargs={ + 'hooks': [{ + 'id': 'foo', + 'args': ['i have spaces', 'and"\'quotes', '$and !this'], + }], + }, + ) + + +def test_python_hook_weird_setup_cfg(in_git_dir, tempdir_factory, store): + in_git_dir.join('setup.cfg').write('[install]\ninstall_scripts=/usr/sbin') + + _test_hook_repo( + tempdir_factory, store, 'python_hooks_repo', + 'foo', [os.devnull], + f'[{os.devnull!r}]\nHello World\n'.encode(), + ) + + +@xfailif_no_venv +def test_python_venv(tempdir_factory, store): # pragma: no cover (no venv) + _test_hook_repo( + tempdir_factory, store, 'python_venv_hooks_repo', + 'foo', [os.devnull], + f'[{os.devnull!r}]\nHello World\n'.encode(), + ) + + +def test_switch_language_versions_doesnt_clobber(tempdir_factory, store): + # We're using the python3 repo because it prints the python version + path = make_repo(tempdir_factory, 'python3_hooks_repo') + + def run_on_version(version, expected_output): + config = make_config_from_repo(path) + config['hooks'][0]['language_version'] = version + hook = _get_hook(config, store, 'python3-hook') + ret, out = _hook_run(hook, [], color=False) + assert ret == 0 + assert _norm_out(out) == expected_output + + run_on_version('python2', b'2\n[]\nHello World\n') + run_on_version('python3', b'3\n[]\nHello World\n') + + +def test_versioned_python_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'python3_hooks_repo', + 'python3-hook', + [os.devnull], + f'3\n[{os.devnull!r}]\nHello World\n'.encode(), + ) + + +@skipif_cant_run_docker # pragma: win32 no cover +def test_run_a_docker_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'docker_hooks_repo', + 'docker-hook', + ['Hello World from docker'], b'Hello World from docker\n', + ) + + +@skipif_cant_run_docker # pragma: win32 no cover +def test_run_a_docker_hook_with_entry_args(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'docker_hooks_repo', + 'docker-hook-arg', + ['Hello World from docker'], b'Hello World from docker', + ) + + +@skipif_cant_run_docker # pragma: win32 no cover +def test_run_a_failing_docker_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'docker_hooks_repo', + 'docker-hook-failing', + ['Hello World from docker'], + mock.ANY, # an error message about `bork` not existing + expected_return_code=127, + ) + + +@skipif_cant_run_docker # pragma: win32 no cover +@pytest.mark.parametrize('hook_id', ('echo-entrypoint', 'echo-cmd')) +def test_run_a_docker_image_hook(tempdir_factory, store, hook_id): + _test_hook_repo( + tempdir_factory, store, 'docker_image_hooks_repo', + hook_id, + ['Hello World from docker'], b'Hello World from docker\n', + ) + + +def test_run_a_node_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'node_hooks_repo', + 'foo', [os.devnull], b'Hello World\n', + ) + + +def test_run_versioned_node_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'node_versioned_hooks_repo', + 'versioned-node-hook', [os.devnull], b'v9.3.0\nHello World\n', + ) + + +@xfailif_windows_no_ruby +def test_run_a_ruby_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'ruby_hooks_repo', + 'ruby_hook', [os.devnull], b'Hello world from a ruby hook\n', + ) + + +@xfailif_windows_no_ruby +def test_run_versioned_ruby_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'ruby_versioned_hooks_repo', + 'ruby_hook', + [os.devnull], + b'2.5.1\nHello world from a ruby hook\n', + ) + + +@xfailif_windows_no_ruby +def test_run_ruby_hook_with_disable_shared_gems( + tempdir_factory, + store, + tmpdir, +): + """Make sure a Gemfile in the project doesn't interfere.""" + tmpdir.join('Gemfile').write('gem "lol_hai"') + tmpdir.join('.bundle').mkdir() + tmpdir.join('.bundle', 'config').write( + 'BUNDLE_DISABLE_SHARED_GEMS: true\n' + 'BUNDLE_PATH: vendor/gem\n', + ) + with cwd(tmpdir.strpath): + _test_hook_repo( + tempdir_factory, store, 'ruby_versioned_hooks_repo', + 'ruby_hook', + [os.devnull], + b'2.5.1\nHello world from a ruby hook\n', + ) + + +def test_system_hook_with_spaces(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'system_hook_with_spaces_repo', + 'system-hook-with-spaces', [os.devnull], b'Hello World\n', + ) + + +@skipif_cant_run_swift # pragma: win32 no cover +def test_swift_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'swift_hooks_repo', + 'swift-hooks-repo', [], b'Hello, world!\n', + ) + + +def test_golang_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'golang_hooks_repo', + 'golang-hook', [], b'hello world\n', + ) + + +def test_golang_hook_still_works_when_gobin_is_set(tempdir_factory, store): + gobin_dir = tempdir_factory.get() + with envcontext((('GOBIN', gobin_dir),)): + test_golang_hook(tempdir_factory, store) + assert os.listdir(gobin_dir) == [] + + +def test_rust_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'rust_hooks_repo', + 'rust-hook', [], b'hello world\n', + ) + + +@pytest.mark.parametrize('dep', ('cli:shellharden:3.1.0', 'cli:shellharden')) +def test_additional_rust_cli_dependencies_installed( + tempdir_factory, store, dep, +): + path = make_repo(tempdir_factory, 'rust_hooks_repo') + config = make_config_from_repo(path) + # A small rust package with no dependencies. + config['hooks'][0]['additional_dependencies'] = [dep] + hook = _get_hook(config, store, 'rust-hook') + binaries = os.listdir( + hook.prefix.path( + helpers.environment_dir(rust.ENVIRONMENT_DIR, C.DEFAULT), 'bin', + ), + ) + # normalize for windows + binaries = [os.path.splitext(binary)[0] for binary in binaries] + assert 'shellharden' in binaries + + +def test_additional_rust_lib_dependencies_installed( + tempdir_factory, store, +): + path = make_repo(tempdir_factory, 'rust_hooks_repo') + config = make_config_from_repo(path) + # A small rust package with no dependencies. + deps = ['shellharden:3.1.0'] + config['hooks'][0]['additional_dependencies'] = deps + hook = _get_hook(config, store, 'rust-hook') + binaries = os.listdir( + hook.prefix.path( + helpers.environment_dir(rust.ENVIRONMENT_DIR, C.DEFAULT), 'bin', + ), + ) + # normalize for windows + binaries = [os.path.splitext(binary)[0] for binary in binaries] + assert 'rust-hello-world' in binaries + assert 'shellharden' not in binaries + + +def test_missing_executable(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'not_found_exe', + 'not-found-exe', [os.devnull], + b'Executable `i-dont-exist-lol` not found', + expected_return_code=1, + ) + + +def test_run_a_script_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'script_hooks_repo', + 'bash_hook', ['bar'], b'bar\nHello World\n', + ) + + +def test_run_hook_with_spaced_args(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'arg_per_line_hooks_repo', + 'arg-per-line', + ['foo bar', 'baz'], + b'arg: hello\narg: world\narg: foo bar\narg: baz\n', + ) + + +def test_run_hook_with_curly_braced_arguments(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'arg_per_line_hooks_repo', + 'arg-per-line', + [], + b"arg: hi {1}\narg: I'm {a} problem\n", + config_kwargs={ + 'hooks': [{ + 'id': 'arg-per-line', + 'args': ['hi {1}', "I'm {a} problem"], + }], + }, + ) + + +def test_intermixed_stdout_stderr(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'stdout_stderr_repo', + 'stdout-stderr', + [], + b'0\n1\n2\n3\n4\n5\n', + ) + + +@pytest.mark.xfail(os.name == 'nt', reason='ptys are posix-only') +def test_output_isatty(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'stdout_stderr_repo', + 'tty-check', + [], + b'stdin: False\nstdout: True\nstderr: True\n', + color=True, + ) + + +def _make_grep_repo(entry, store, args=()): + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'grep-hook', + 'name': 'grep-hook', + 'language': 'pygrep', + 'entry': entry, + 'args': args, + 'types': ['text'], + }], + } + return _get_hook(config, store, 'grep-hook') + + +@pytest.fixture +def greppable_files(tmpdir): + with tmpdir.as_cwd(): + cmd_output_b('git', 'init', '.') + tmpdir.join('f1').write_binary(b"hello'hi\nworld\n") + tmpdir.join('f2').write_binary(b'foo\nbar\nbaz\n') + tmpdir.join('f3').write_binary(b'[WARN] hi\n') + yield tmpdir + + +def test_grep_hook_matching(greppable_files, store): + hook = _make_grep_repo('ello', store) + ret, out = _hook_run(hook, ('f1', 'f2', 'f3'), color=False) + assert ret == 1 + assert _norm_out(out) == b"f1:1:hello'hi\n" + + +def test_grep_hook_case_insensitive(greppable_files, store): + hook = _make_grep_repo('ELLO', store, args=['-i']) + ret, out = _hook_run(hook, ('f1', 'f2', 'f3'), color=False) + assert ret == 1 + assert _norm_out(out) == b"f1:1:hello'hi\n" + + +@pytest.mark.parametrize('regex', ('nope', "foo'bar", r'^\[INFO\]')) +def test_grep_hook_not_matching(regex, greppable_files, store): + hook = _make_grep_repo(regex, store) + ret, out = _hook_run(hook, ('f1', 'f2', 'f3'), color=False) + assert (ret, out) == (0, b'') + + +def _norm_pwd(path): + # Under windows bash's temp and windows temp is different. + # This normalizes to the bash /tmp + return cmd_output_b( + 'bash', '-c', f"cd '{path}' && pwd", + )[1].strip() + + +def test_cwd_of_hook(in_git_dir, tempdir_factory, store): + # Note: this doubles as a test for `system` hooks + _test_hook_repo( + tempdir_factory, store, 'prints_cwd_repo', + 'prints_cwd', ['-L'], _norm_pwd(in_git_dir.strpath) + b'\n', + ) + + +def test_lots_of_files(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'script_hooks_repo', + 'bash_hook', [os.devnull] * 15000, mock.ANY, + ) + + +def test_additional_dependencies_roll_forward(tempdir_factory, store): + path = make_repo(tempdir_factory, 'python_hooks_repo') + + config1 = make_config_from_repo(path) + hook1 = _get_hook(config1, store, 'foo') + with python.in_env(hook1.prefix, hook1.language_version): + assert 'mccabe' not in cmd_output('pip', 'freeze', '-l')[1] + + # Make another repo with additional dependencies + config2 = make_config_from_repo(path) + config2['hooks'][0]['additional_dependencies'] = ['mccabe'] + hook2 = _get_hook(config2, store, 'foo') + with python.in_env(hook2.prefix, hook2.language_version): + assert 'mccabe' in cmd_output('pip', 'freeze', '-l')[1] + + # should not have affected original + with python.in_env(hook1.prefix, hook1.language_version): + assert 'mccabe' not in cmd_output('pip', 'freeze', '-l')[1] + + +@xfailif_windows_no_ruby # pragma: win32 no cover +def test_additional_ruby_dependencies_installed(tempdir_factory, store): + path = make_repo(tempdir_factory, 'ruby_hooks_repo') + config = make_config_from_repo(path) + config['hooks'][0]['additional_dependencies'] = ['tins'] + hook = _get_hook(config, store, 'ruby_hook') + with ruby.in_env(hook.prefix, hook.language_version): + output = cmd_output('gem', 'list', '--local')[1] + assert 'tins' in output + + +def test_additional_node_dependencies_installed(tempdir_factory, store): + path = make_repo(tempdir_factory, 'node_hooks_repo') + config = make_config_from_repo(path) + # Careful to choose a small package that's not depped by npm + config['hooks'][0]['additional_dependencies'] = ['lodash'] + hook = _get_hook(config, store, 'foo') + with node.in_env(hook.prefix, hook.language_version): + output = cmd_output('npm', 'ls', '-g')[1] + assert 'lodash' in output + + +def test_additional_golang_dependencies_installed( + tempdir_factory, store, +): + path = make_repo(tempdir_factory, 'golang_hooks_repo') + config = make_config_from_repo(path) + # A small go package + deps = ['github.com/golang/example/hello'] + config['hooks'][0]['additional_dependencies'] = deps + hook = _get_hook(config, store, 'golang-hook') + binaries = os.listdir( + hook.prefix.path( + helpers.environment_dir(golang.ENVIRONMENT_DIR, C.DEFAULT), 'bin', + ), + ) + # normalize for windows + binaries = [os.path.splitext(binary)[0] for binary in binaries] + assert 'hello' in binaries + + +def test_local_golang_additional_dependencies(store): + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'hello', + 'name': 'hello', + 'entry': 'hello', + 'language': 'golang', + 'additional_dependencies': ['github.com/golang/example/hello'], + }], + } + hook = _get_hook(config, store, 'hello') + ret, out = _hook_run(hook, (), color=False) + assert ret == 0 + assert _norm_out(out) == b'Hello, Go examples!\n' + + +def test_local_rust_additional_dependencies(store): + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'hello', + 'name': 'hello', + 'entry': 'hello', + 'language': 'rust', + 'additional_dependencies': ['cli:hello-cli:0.2.2'], + }], + } + hook = _get_hook(config, store, 'hello') + ret, out = _hook_run(hook, (), color=False) + assert ret == 0 + assert _norm_out(out) == b'Hello World!\n' + + +def test_fail_hooks(store): + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'fail', + 'name': 'fail', + 'language': 'fail', + 'entry': 'make sure to name changelogs as .rst!', + 'files': r'changelog/.*(? too-much: foo, hello' + assert fake_log_handler.handle.call_args[0][0].msg == expected + + +def test_reinstall(tempdir_factory, store, log_info_mock): + path = make_repo(tempdir_factory, 'python_hooks_repo') + config = make_config_from_repo(path) + _get_hook(config, store, 'foo') + # We print some logging during clone (1) + install (3) + assert log_info_mock.call_count == 4 + log_info_mock.reset_mock() + # Reinstall on another run should not trigger another install + _get_hook(config, store, 'foo') + assert log_info_mock.call_count == 0 + + +def test_control_c_control_c_on_install(tempdir_factory, store): + """Regression test for #186.""" + path = make_repo(tempdir_factory, 'python_hooks_repo') + config = make_config_from_repo(path) + hooks = [_get_hook_no_install(config, store, 'foo')] + + class MyKeyboardInterrupt(KeyboardInterrupt): + pass + + # To simulate a killed install, we'll make PythonEnv.run raise ^C + # and then to simulate a second ^C during cleanup, we'll make shutil.rmtree + # raise as well. + with pytest.raises(MyKeyboardInterrupt): + with mock.patch.object( + helpers, 'run_setup_cmd', side_effect=MyKeyboardInterrupt, + ): + with mock.patch.object( + shutil, 'rmtree', side_effect=MyKeyboardInterrupt, + ): + install_hook_envs(hooks, store) + + # Should have made an environment, however this environment is broken! + hook, = hooks + assert hook.prefix.exists( + helpers.environment_dir(python.ENVIRONMENT_DIR, hook.language_version), + ) + + # However, it should be perfectly runnable (reinstall after botched + # install) + install_hook_envs(hooks, store) + ret, out = _hook_run(hook, (), color=False) + assert ret == 0 + + +def test_invalidated_virtualenv(tempdir_factory, store): + # A cached virtualenv may become invalidated if the system python upgrades + # This should not cause every hook in that virtualenv to fail. + path = make_repo(tempdir_factory, 'python_hooks_repo') + config = make_config_from_repo(path) + hook = _get_hook(config, store, 'foo') + + # Simulate breaking of the virtualenv + libdir = hook.prefix.path( + helpers.environment_dir(python.ENVIRONMENT_DIR, hook.language_version), + 'lib', hook.language_version, + ) + paths = [ + os.path.join(libdir, p) for p in ('site.py', 'site.pyc', '__pycache__') + ] + cmd_output_b('rm', '-rf', *paths) + + # pre-commit should rebuild the virtualenv and it should be runnable + hook = _get_hook(config, store, 'foo') + ret, out = _hook_run(hook, (), color=False) + assert ret == 0 + + +def test_really_long_file_paths(tempdir_factory, store): + base_path = tempdir_factory.get() + really_long_path = os.path.join(base_path, 'really_long' * 10) + cmd_output_b('git', 'init', really_long_path) + + path = make_repo(tempdir_factory, 'python_hooks_repo') + config = make_config_from_repo(path) + + with cwd(really_long_path): + _get_hook(config, store, 'foo') + + +def test_config_overrides_repo_specifics(tempdir_factory, store): + path = make_repo(tempdir_factory, 'script_hooks_repo') + config = make_config_from_repo(path) + + hook = _get_hook(config, store, 'bash_hook') + assert hook.files == '' + # Set the file regex to something else + config['hooks'][0]['files'] = '\\.sh$' + hook = _get_hook(config, store, 'bash_hook') + assert hook.files == '\\.sh$' + + +def _create_repo_with_tags(tempdir_factory, src, tag): + path = make_repo(tempdir_factory, src) + cmd_output_b('git', 'tag', tag, cwd=path) + return path + + +def test_tags_on_repositories(in_tmpdir, tempdir_factory, store): + tag = 'v1.1' + git1 = _create_repo_with_tags(tempdir_factory, 'prints_cwd_repo', tag) + git2 = _create_repo_with_tags(tempdir_factory, 'script_hooks_repo', tag) + + config1 = make_config_from_repo(git1, rev=tag) + hook1 = _get_hook(config1, store, 'prints_cwd') + ret1, out1 = _hook_run(hook1, ('-L',), color=False) + assert ret1 == 0 + assert out1.strip() == _norm_pwd(in_tmpdir) + + config2 = make_config_from_repo(git2, rev=tag) + hook2 = _get_hook(config2, store, 'bash_hook') + ret2, out2 = _hook_run(hook2, ('bar',), color=False) + assert ret2 == 0 + assert out2 == b'bar\nHello World\n' + + +@pytest.fixture +def local_python_config(): + # Make a "local" hooks repo that just installs our other hooks repo + repo_path = get_resource_path('python_hooks_repo') + manifest = load_manifest(os.path.join(repo_path, C.MANIFEST_FILE)) + hooks = [ + dict(hook, additional_dependencies=[repo_path]) for hook in manifest + ] + return {'repo': 'local', 'hooks': hooks} + + +@pytest.mark.xfail( # pragma: win32 no cover + sys.platform == 'win32', + reason='microsoft/azure-pipelines-image-generation#989', +) +def test_local_python_repo(store, local_python_config): + hook = _get_hook(local_python_config, store, 'foo') + # language_version should have been adjusted to the interpreter version + assert hook.language_version != C.DEFAULT + ret, out = _hook_run(hook, ('filename',), color=False) + assert ret == 0 + assert _norm_out(out) == b"['filename']\nHello World\n" + + +def test_default_language_version(store, local_python_config): + config: Dict[str, Any] = { + 'default_language_version': {'python': 'fake'}, + 'default_stages': ['commit'], + 'repos': [local_python_config], + } + + # `language_version` was not set, should default + hook, = all_hooks(config, store) + assert hook.language_version == 'fake' + + # `language_version` is set, should not default + config['repos'][0]['hooks'][0]['language_version'] = 'fake2' + hook, = all_hooks(config, store) + assert hook.language_version == 'fake2' + + +def test_default_stages(store, local_python_config): + config: Dict[str, Any] = { + 'default_language_version': {'python': C.DEFAULT}, + 'default_stages': ['commit'], + 'repos': [local_python_config], + } + + # `stages` was not set, should default + hook, = all_hooks(config, store) + assert hook.stages == ['commit'] + + # `stages` is set, should not default + config['repos'][0]['hooks'][0]['stages'] = ['push'] + hook, = all_hooks(config, store) + assert hook.stages == ['push'] + + +def test_hook_id_not_present(tempdir_factory, store, fake_log_handler): + path = make_repo(tempdir_factory, 'script_hooks_repo') + config = make_config_from_repo(path) + config['hooks'][0]['id'] = 'i-dont-exist' + with pytest.raises(SystemExit): + _get_hook(config, store, 'i-dont-exist') + assert fake_log_handler.handle.call_args[0][0].msg == ( + f'`i-dont-exist` is not present in repository file://{path}. ' + f'Typo? Perhaps it is introduced in a newer version? ' + f'Often `pre-commit autoupdate` fixes this.' + ) + + +def test_too_new_version(tempdir_factory, store, fake_log_handler): + path = make_repo(tempdir_factory, 'script_hooks_repo') + with modify_manifest(path) as manifest: + manifest[0]['minimum_pre_commit_version'] = '999.0.0' + config = make_config_from_repo(path) + with pytest.raises(SystemExit): + _get_hook(config, store, 'bash_hook') + msg = fake_log_handler.handle.call_args[0][0].msg + assert re.match( + r'^The hook `bash_hook` requires pre-commit version 999\.0\.0 but ' + r'version \d+\.\d+\.\d+ is installed. ' + r'Perhaps run `pip install --upgrade pre-commit`\.$', + msg, + ) + + +@pytest.mark.parametrize('version', ('0.1.0', C.VERSION)) +def test_versions_ok(tempdir_factory, store, version): + path = make_repo(tempdir_factory, 'script_hooks_repo') + with modify_manifest(path) as manifest: + manifest[0]['minimum_pre_commit_version'] = version + config = make_config_from_repo(path) + # Should succeed + _get_hook(config, store, 'bash_hook') + + +def test_manifest_hooks(tempdir_factory, store): + path = make_repo(tempdir_factory, 'script_hooks_repo') + config = make_config_from_repo(path) + hook = _get_hook(config, store, 'bash_hook') + + assert hook == Hook( + src=f'file://{path}', + prefix=Prefix(mock.ANY), + additional_dependencies=[], + alias='', + always_run=False, + args=[], + description='', + entry='bin/hook.sh', + exclude='^$', + exclude_types=[], + files='', + id='bash_hook', + language='script', + language_version='default', + log_file='', + minimum_pre_commit_version='0', + name='Bash hook', + pass_filenames=True, + require_serial=False, + stages=( + 'commit', 'merge-commit', 'prepare-commit-msg', 'commit-msg', + 'manual', 'post-checkout', 'push', + ), + types=['file'], + verbose=False, + ) + + +def test_perl_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'perl_hooks_repo', + 'perl-hook', [], b'Hello from perl-commit Perl!\n', + ) + + +def test_local_perl_additional_dependencies(store): + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'hello', + 'name': 'hello', + 'entry': 'perltidy --version', + 'language': 'perl', + 'additional_dependencies': ['SHANCOCK/Perl-Tidy-20200110.tar.gz'], + }], + } + hook = _get_hook(config, store, 'hello') + ret, out = _hook_run(hook, (), color=False) + assert ret == 0 + assert _norm_out(out).startswith(b'This is perltidy, v20200110') diff --git a/tests/staged_files_only_test.py b/tests/staged_files_only_test.py new file mode 100644 index 0000000..ddb9574 --- /dev/null +++ b/tests/staged_files_only_test.py @@ -0,0 +1,349 @@ +import itertools +import os.path +import shutil + +import pytest + +from pre_commit import git +from pre_commit.staged_files_only import staged_files_only +from pre_commit.util import cmd_output +from testing.auto_namedtuple import auto_namedtuple +from testing.fixtures import git_dir +from testing.util import cwd +from testing.util import get_resource_path +from testing.util import git_commit + + +FOO_CONTENTS = '\n'.join(('1', '2', '3', '4', '5', '6', '7', '8', '')) + + +@pytest.fixture +def patch_dir(tempdir_factory): + return tempdir_factory.get() + + +def get_short_git_status(): + git_status = cmd_output('git', 'status', '-s')[1] + line_parts = [line.split() for line in git_status.splitlines()] + return {v: k for k, v in line_parts} + + +@pytest.fixture +def foo_staged(in_git_dir): + foo = in_git_dir.join('foo') + foo.write(FOO_CONTENTS) + cmd_output('git', 'add', 'foo') + yield auto_namedtuple(path=in_git_dir.strpath, foo_filename=foo.strpath) + + +def _test_foo_state( + path, + foo_contents=FOO_CONTENTS, + status='A', + encoding='UTF-8', +): + assert os.path.exists(path.foo_filename) + with open(path.foo_filename, encoding=encoding) as f: + assert f.read() == foo_contents + actual_status = get_short_git_status()['foo'] + assert status == actual_status + + +def test_foo_staged(foo_staged): + _test_foo_state(foo_staged) + + +def test_foo_nothing_unstaged(foo_staged, patch_dir): + with staged_files_only(patch_dir): + _test_foo_state(foo_staged) + _test_foo_state(foo_staged) + + +def test_foo_something_unstaged(foo_staged, patch_dir): + with open(foo_staged.foo_filename, 'w') as foo_file: + foo_file.write('herp\nderp\n') + + _test_foo_state(foo_staged, 'herp\nderp\n', 'AM') + + with staged_files_only(patch_dir): + _test_foo_state(foo_staged) + + _test_foo_state(foo_staged, 'herp\nderp\n', 'AM') + + +def test_does_not_crash_patch_dir_does_not_exist(foo_staged, patch_dir): + with open(foo_staged.foo_filename, 'w') as foo_file: + foo_file.write('hello\nworld\n') + + shutil.rmtree(patch_dir) + with staged_files_only(patch_dir): + pass + + +def test_something_unstaged_ext_diff_tool(foo_staged, patch_dir, tmpdir): + diff_tool = tmpdir.join('diff-tool.sh') + diff_tool.write('#!/usr/bin/env bash\necho "$@"\n') + cmd_output('git', 'config', 'diff.external', diff_tool.strpath) + test_foo_something_unstaged(foo_staged, patch_dir) + + +def test_foo_something_unstaged_diff_color_always(foo_staged, patch_dir): + cmd_output('git', 'config', '--local', 'color.diff', 'always') + test_foo_something_unstaged(foo_staged, patch_dir) + + +def test_foo_both_modify_non_conflicting(foo_staged, patch_dir): + with open(foo_staged.foo_filename, 'w') as foo_file: + foo_file.write(f'{FOO_CONTENTS}9\n') + + _test_foo_state(foo_staged, f'{FOO_CONTENTS}9\n', 'AM') + + with staged_files_only(patch_dir): + _test_foo_state(foo_staged) + + # Modify the file as part of the "pre-commit" + with open(foo_staged.foo_filename, 'w') as foo_file: + foo_file.write(FOO_CONTENTS.replace('1', 'a')) + + _test_foo_state(foo_staged, FOO_CONTENTS.replace('1', 'a'), 'AM') + + _test_foo_state(foo_staged, f'{FOO_CONTENTS.replace("1", "a")}9\n', 'AM') + + +def test_foo_both_modify_conflicting(foo_staged, patch_dir): + with open(foo_staged.foo_filename, 'w') as foo_file: + foo_file.write(FOO_CONTENTS.replace('1', 'a')) + + _test_foo_state(foo_staged, FOO_CONTENTS.replace('1', 'a'), 'AM') + + with staged_files_only(patch_dir): + _test_foo_state(foo_staged) + + # Modify in the same place as the stashed diff + with open(foo_staged.foo_filename, 'w') as foo_file: + foo_file.write(FOO_CONTENTS.replace('1', 'b')) + + _test_foo_state(foo_staged, FOO_CONTENTS.replace('1', 'b'), 'AM') + + _test_foo_state(foo_staged, FOO_CONTENTS.replace('1', 'a'), 'AM') + + +@pytest.fixture +def img_staged(in_git_dir): + img = in_git_dir.join('img.jpg') + shutil.copy(get_resource_path('img1.jpg'), img.strpath) + cmd_output('git', 'add', 'img.jpg') + yield auto_namedtuple(path=in_git_dir.strpath, img_filename=img.strpath) + + +def _test_img_state(path, expected_file='img1.jpg', status='A'): + assert os.path.exists(path.img_filename) + with open(path.img_filename, 'rb') as f1: + with open(get_resource_path(expected_file), 'rb') as f2: + assert f1.read() == f2.read() + actual_status = get_short_git_status()['img.jpg'] + assert status == actual_status + + +def test_img_staged(img_staged): + _test_img_state(img_staged) + + +def test_img_nothing_unstaged(img_staged, patch_dir): + with staged_files_only(patch_dir): + _test_img_state(img_staged) + _test_img_state(img_staged) + + +def test_img_something_unstaged(img_staged, patch_dir): + shutil.copy(get_resource_path('img2.jpg'), img_staged.img_filename) + + _test_img_state(img_staged, 'img2.jpg', 'AM') + + with staged_files_only(patch_dir): + _test_img_state(img_staged) + + _test_img_state(img_staged, 'img2.jpg', 'AM') + + +def test_img_conflict(img_staged, patch_dir): + """Admittedly, this shouldn't happen, but just in case.""" + shutil.copy(get_resource_path('img2.jpg'), img_staged.img_filename) + + _test_img_state(img_staged, 'img2.jpg', 'AM') + + with staged_files_only(patch_dir): + _test_img_state(img_staged) + shutil.copy(get_resource_path('img3.jpg'), img_staged.img_filename) + _test_img_state(img_staged, 'img3.jpg', 'AM') + + _test_img_state(img_staged, 'img2.jpg', 'AM') + + +@pytest.fixture +def submodule_with_commits(tempdir_factory): + path = git_dir(tempdir_factory) + with cwd(path): + git_commit() + rev1 = cmd_output('git', 'rev-parse', 'HEAD')[1].strip() + git_commit() + rev2 = cmd_output('git', 'rev-parse', 'HEAD')[1].strip() + yield auto_namedtuple(path=path, rev1=rev1, rev2=rev2) + + +def checkout_submodule(rev): + cmd_output('git', 'checkout', rev, cwd='sub') + + +@pytest.fixture +def sub_staged(submodule_with_commits, tempdir_factory): + path = git_dir(tempdir_factory) + with cwd(path): + cmd_output( + 'git', 'submodule', 'add', submodule_with_commits.path, 'sub', + ) + checkout_submodule(submodule_with_commits.rev1) + cmd_output('git', 'add', 'sub') + yield auto_namedtuple( + path=path, + sub_path=os.path.join(path, 'sub'), + submodule=submodule_with_commits, + ) + + +def _test_sub_state(path, rev='rev1', status='A'): + assert os.path.exists(path.sub_path) + with cwd(path.sub_path): + actual_rev = cmd_output('git', 'rev-parse', 'HEAD')[1].strip() + assert actual_rev == getattr(path.submodule, rev) + actual_status = get_short_git_status()['sub'] + assert actual_status == status + + +def test_sub_staged(sub_staged): + _test_sub_state(sub_staged) + + +def test_sub_nothing_unstaged(sub_staged, patch_dir): + with staged_files_only(patch_dir): + _test_sub_state(sub_staged) + _test_sub_state(sub_staged) + + +def test_sub_something_unstaged(sub_staged, patch_dir): + checkout_submodule(sub_staged.submodule.rev2) + + _test_sub_state(sub_staged, 'rev2', 'AM') + + with staged_files_only(patch_dir): + # This is different from others, we don't want to touch subs + _test_sub_state(sub_staged, 'rev2', 'AM') + + _test_sub_state(sub_staged, 'rev2', 'AM') + + +def test_stage_utf8_changes(foo_staged, patch_dir): + contents = '\u2603' + with open('foo', 'w', encoding='UTF-8') as foo_file: + foo_file.write(contents) + + _test_foo_state(foo_staged, contents, 'AM') + with staged_files_only(patch_dir): + _test_foo_state(foo_staged) + _test_foo_state(foo_staged, contents, 'AM') + + +def test_stage_non_utf8_changes(foo_staged, patch_dir): + contents = 'ΓΊ' + # Produce a latin-1 diff + with open('foo', 'w', encoding='latin-1') as foo_file: + foo_file.write(contents) + + _test_foo_state(foo_staged, contents, 'AM', encoding='latin-1') + with staged_files_only(patch_dir): + _test_foo_state(foo_staged) + _test_foo_state(foo_staged, contents, 'AM', encoding='latin-1') + + +def test_non_utf8_conflicting_diff(foo_staged, patch_dir): + """Regression test for #397""" + # The trailing whitespace is important here, this triggers git to produce + # an error message which looks like: + # + # ...patch1471530032:14: trailing whitespace. + # [[unprintable character]][[space character]] + # error: patch failed: foo:1 + # error: foo: patch does not apply + # + # Previously, the error message (though discarded immediately) was being + # decoded with the UTF-8 codec (causing a crash) + contents = 'ΓΊ \n' + with open('foo', 'w', encoding='latin-1') as foo_file: + foo_file.write(contents) + + _test_foo_state(foo_staged, contents, 'AM', encoding='latin-1') + with staged_files_only(patch_dir): + _test_foo_state(foo_staged) + # Create a conflicting diff that will need to be rolled back + with open('foo', 'w') as foo_file: + foo_file.write('') + _test_foo_state(foo_staged, contents, 'AM', encoding='latin-1') + + +def _write(b): + with open('foo', 'wb') as f: + f.write(b) + + +def assert_no_diff(): + tree = cmd_output('git', 'write-tree')[1].strip() + cmd_output('git', 'diff-index', tree, '--exit-code') + + +bool_product = tuple(itertools.product((True, False), repeat=2)) + + +@pytest.mark.parametrize(('crlf_before', 'crlf_after'), bool_product) +@pytest.mark.parametrize('autocrlf', ('true', 'false', 'input')) +def test_crlf(in_git_dir, patch_dir, crlf_before, crlf_after, autocrlf): + cmd_output('git', 'config', '--local', 'core.autocrlf', autocrlf) + + before, after = b'1\n2\n', b'3\n4\n\n' + before = before.replace(b'\n', b'\r\n') if crlf_before else before + after = after.replace(b'\n', b'\r\n') if crlf_after else after + + _write(before) + cmd_output('git', 'add', 'foo') + _write(after) + with staged_files_only(patch_dir): + assert_no_diff() + + +def test_whitespace_errors(in_git_dir, patch_dir): + cmd_output('git', 'config', '--local', 'apply.whitespace', 'error') + test_crlf(in_git_dir, patch_dir, True, True, 'true') + + +def test_autocrlf_committed_crlf(in_git_dir, patch_dir): + """Regression test for #570""" + cmd_output('git', 'config', '--local', 'core.autocrlf', 'false') + _write(b'1\r\n2\r\n') + cmd_output('git', 'add', 'foo') + git_commit() + + cmd_output('git', 'config', '--local', 'core.autocrlf', 'true') + _write(b'1\r\n2\r\n\r\n\r\n\r\n') + + with staged_files_only(patch_dir): + assert_no_diff() + + +def test_intent_to_add(in_git_dir, patch_dir): + """Regression test for #881""" + _write(b'hello\nworld\n') + cmd_output('git', 'add', '--intent-to-add', 'foo') + + assert git.intent_to_add_files() == ['foo'] + with staged_files_only(patch_dir): + assert_no_diff() + assert git.intent_to_add_files() == ['foo'] diff --git a/tests/store_test.py b/tests/store_test.py new file mode 100644 index 0000000..5866616 --- /dev/null +++ b/tests/store_test.py @@ -0,0 +1,216 @@ +import os.path +import sqlite3 +from unittest import mock + +import pytest + +from pre_commit import git +from pre_commit.store import _get_default_directory +from pre_commit.store import Store +from pre_commit.util import CalledProcessError +from pre_commit.util import cmd_output +from testing.fixtures import git_dir +from testing.util import cwd +from testing.util import git_commit + + +def test_our_session_fixture_works(): + """There's a session fixture which makes `Store` invariantly raise to + prevent writing to the home directory. + """ + with pytest.raises(AssertionError): + Store() + + +def test_get_default_directory_defaults_to_home(): + # Not we use the module level one which is not mocked + ret = _get_default_directory() + assert ret == os.path.join(os.path.expanduser('~/.cache'), 'pre-commit') + + +def test_adheres_to_xdg_specification(): + with mock.patch.dict( + os.environ, {'XDG_CACHE_HOME': '/tmp/fakehome'}, + ): + ret = _get_default_directory() + assert ret == os.path.join('/tmp/fakehome', 'pre-commit') + + +def test_uses_environment_variable_when_present(): + with mock.patch.dict( + os.environ, {'PRE_COMMIT_HOME': '/tmp/pre_commit_home'}, + ): + ret = _get_default_directory() + assert ret == '/tmp/pre_commit_home' + + +def test_store_init(store): + # Should create the store directory + assert os.path.exists(store.directory) + # Should create a README file indicating what the directory is about + with open(os.path.join(store.directory, 'README')) as readme_file: + readme_contents = readme_file.read() + for text_line in ( + 'This directory is maintained by the pre-commit project.', + 'Learn more: https://github.com/pre-commit/pre-commit', + ): + assert text_line in readme_contents + + +def test_clone(store, tempdir_factory, log_info_mock): + path = git_dir(tempdir_factory) + with cwd(path): + git_commit() + rev = git.head_rev(path) + git_commit() + + ret = store.clone(path, rev) + # Should have printed some stuff + assert log_info_mock.call_args_list[0][0][0].startswith( + 'Initializing environment for ', + ) + + # Should return a directory inside of the store + assert os.path.exists(ret) + assert ret.startswith(store.directory) + # Directory should start with `repo` + _, dirname = os.path.split(ret) + assert dirname.startswith('repo') + # Should be checked out to the rev we specified + assert git.head_rev(ret) == rev + + # Assert there's an entry in the sqlite db for this + assert store.select_all_repos() == [(path, rev, ret)] + + +def test_clone_cleans_up_on_checkout_failure(store): + with pytest.raises(Exception) as excinfo: + # This raises an exception because you can't clone something that + # doesn't exist! + store.clone('/i_dont_exist_lol', 'fake_rev') + assert '/i_dont_exist_lol' in str(excinfo.value) + + repo_dirs = [ + d for d in os.listdir(store.directory) if d.startswith('repo') + ] + assert repo_dirs == [] + + +def test_clone_when_repo_already_exists(store): + # Create an entry in the sqlite db that makes it look like the repo has + # been cloned. + with sqlite3.connect(store.db_path) as db: + db.execute( + 'INSERT INTO repos (repo, ref, path) ' + 'VALUES ("fake_repo", "fake_ref", "fake_path")', + ) + + assert store.clone('fake_repo', 'fake_ref') == 'fake_path' + + +def test_clone_shallow_failure_fallback_to_complete( + store, tempdir_factory, + log_info_mock, +): + path = git_dir(tempdir_factory) + with cwd(path): + git_commit() + rev = git.head_rev(path) + git_commit() + + # Force shallow clone failure + def fake_shallow_clone(self, *args, **kwargs): + raise CalledProcessError(1, (), 0, b'', None) + store._shallow_clone = fake_shallow_clone + + ret = store.clone(path, rev) + + # Should have printed some stuff + assert log_info_mock.call_args_list[0][0][0].startswith( + 'Initializing environment for ', + ) + + # Should return a directory inside of the store + assert os.path.exists(ret) + assert ret.startswith(store.directory) + # Directory should start with `repo` + _, dirname = os.path.split(ret) + assert dirname.startswith('repo') + # Should be checked out to the rev we specified + assert git.head_rev(ret) == rev + + # Assert there's an entry in the sqlite db for this + assert store.select_all_repos() == [(path, rev, ret)] + + +def test_clone_tag_not_on_mainline(store, tempdir_factory): + path = git_dir(tempdir_factory) + with cwd(path): + git_commit() + cmd_output('git', 'checkout', 'master', '-b', 'branch') + git_commit() + cmd_output('git', 'tag', 'v1') + cmd_output('git', 'checkout', 'master') + cmd_output('git', 'branch', '-D', 'branch') + + # previously crashed on unreachable refs + store.clone(path, 'v1') + + +def test_create_when_directory_exists_but_not_db(store): + # In versions <= 0.3.5, there was no sqlite db causing a need for + # backward compatibility + os.remove(store.db_path) + store = Store(store.directory) + assert os.path.exists(store.db_path) + + +def test_create_when_store_already_exists(store): + # an assertion that this is idempotent and does not crash + Store(store.directory) + + +def test_db_repo_name(store): + assert store.db_repo_name('repo', ()) == 'repo' + assert store.db_repo_name('repo', ('b', 'a', 'c')) == 'repo:a,b,c' + + +def test_local_resources_reflects_reality(): + on_disk = { + res[len('empty_template_'):] + for res in os.listdir('pre_commit/resources') + if res.startswith('empty_template_') + } + assert on_disk == set(Store.LOCAL_RESOURCES) + + +def test_mark_config_as_used(store, tmpdir): + with tmpdir.as_cwd(): + f = tmpdir.join('f').ensure() + store.mark_config_used('f') + assert store.select_all_configs() == [f.strpath] + + +def test_mark_config_as_used_idempotent(store, tmpdir): + test_mark_config_as_used(store, tmpdir) + test_mark_config_as_used(store, tmpdir) + + +def test_mark_config_as_used_does_not_exist(store): + store.mark_config_used('f') + assert store.select_all_configs() == [] + + +def _simulate_pre_1_14_0(store): + with store.connect() as db: + db.executescript('DROP TABLE configs') + + +def test_select_all_configs_roll_forward(store): + _simulate_pre_1_14_0(store) + assert store.select_all_configs() == [] + + +def test_mark_config_as_used_roll_forward(store, tmpdir): + _simulate_pre_1_14_0(store) + test_mark_config_as_used(store, tmpdir) diff --git a/tests/util_test.py b/tests/util_test.py new file mode 100644 index 0000000..01afbd4 --- /dev/null +++ b/tests/util_test.py @@ -0,0 +1,122 @@ +import os.path +import stat +import subprocess + +import pytest + +from pre_commit.util import CalledProcessError +from pre_commit.util import clean_path_on_failure +from pre_commit.util import cmd_output +from pre_commit.util import cmd_output_b +from pre_commit.util import cmd_output_p +from pre_commit.util import make_executable +from pre_commit.util import parse_version +from pre_commit.util import rmtree +from pre_commit.util import tmpdir + + +def test_CalledProcessError_str(): + error = CalledProcessError(1, ('exe',), 0, b'output', b'errors') + assert str(error) == ( + "command: ('exe',)\n" + 'return code: 1\n' + 'expected return code: 0\n' + 'stdout:\n' + ' output\n' + 'stderr:\n' + ' errors' + ) + + +def test_CalledProcessError_str_nooutput(): + error = CalledProcessError(1, ('exe',), 0, b'', b'') + assert str(error) == ( + "command: ('exe',)\n" + 'return code: 1\n' + 'expected return code: 0\n' + 'stdout: (none)\n' + 'stderr: (none)' + ) + + +def test_clean_on_failure_noop(in_tmpdir): + with clean_path_on_failure('foo'): + pass + + +def test_clean_path_on_failure_does_nothing_when_not_raising(in_tmpdir): + with clean_path_on_failure('foo'): + os.mkdir('foo') + assert os.path.exists('foo') + + +def test_clean_path_on_failure_cleans_for_normal_exception(in_tmpdir): + class MyException(Exception): + pass + + with pytest.raises(MyException): + with clean_path_on_failure('foo'): + os.mkdir('foo') + raise MyException + + assert not os.path.exists('foo') + + +def test_clean_path_on_failure_cleans_for_system_exit(in_tmpdir): + class MySystemExit(SystemExit): + pass + + with pytest.raises(MySystemExit): + with clean_path_on_failure('foo'): + os.mkdir('foo') + raise MySystemExit + + assert not os.path.exists('foo') + + +def test_tmpdir(): + with tmpdir() as tempdir: + assert os.path.exists(tempdir) + assert not os.path.exists(tempdir) + + +def test_cmd_output_exe_not_found(): + ret, out, _ = cmd_output('dne', retcode=None) + assert ret == 1 + assert out == 'Executable `dne` not found' + + +@pytest.mark.parametrize('fn', (cmd_output_b, cmd_output_p)) +def test_cmd_output_exe_not_found_bytes(fn): + ret, out, _ = fn('dne', retcode=None, stderr=subprocess.STDOUT) + assert ret == 1 + assert out == b'Executable `dne` not found' + + +@pytest.mark.parametrize('fn', (cmd_output_b, cmd_output_p)) +def test_cmd_output_no_shebang(tmpdir, fn): + f = tmpdir.join('f').ensure() + make_executable(f) + + # previously this raised `OSError` -- the output is platform specific + ret, out, _ = fn(str(f), retcode=None, stderr=subprocess.STDOUT) + assert ret == 1 + assert isinstance(out, bytes) + assert out.endswith(b'\n') + + +def test_parse_version(): + assert parse_version('0.0') == parse_version('0.0') + assert parse_version('0.1') > parse_version('0.0') + assert parse_version('2.1') >= parse_version('2') + + +def test_rmtree_read_only_directories(tmpdir): + """Simulates the go module tree. See #1042""" + tmpdir.join('x/y/z').ensure_dir().join('a').ensure() + mode = os.stat(str(tmpdir.join('x'))).st_mode + mode_no_w = mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH) + tmpdir.join('x/y/z').chmod(mode_no_w) + tmpdir.join('x/y/z').chmod(mode_no_w) + tmpdir.join('x/y/z').chmod(mode_no_w) + rmtree(str(tmpdir.join('x'))) diff --git a/tests/xargs_test.py b/tests/xargs_test.py new file mode 100644 index 0000000..1fc9207 --- /dev/null +++ b/tests/xargs_test.py @@ -0,0 +1,197 @@ +import concurrent.futures +import os +import sys +import time +from typing import Tuple +from unittest import mock + +import pytest + +from pre_commit import parse_shebang +from pre_commit import xargs + + +@pytest.mark.parametrize( + ('env', 'expected'), + ( + ({}, 0), + ({b'x': b'1'}, 12), + ({b'x': b'12'}, 13), + ({b'x': b'1', b'y': b'2'}, 24), + ), +) +def test_environ_size(env, expected): + # normalize integer sizing + assert xargs._environ_size(_env=env) == expected + + +@pytest.fixture +def win32_mock(): + with mock.patch.object(sys, 'getfilesystemencoding', return_value='utf-8'): + with mock.patch.object(sys, 'platform', 'win32'): + yield + + +@pytest.fixture +def linux_mock(): + with mock.patch.object(sys, 'getfilesystemencoding', return_value='utf-8'): + with mock.patch.object(sys, 'platform', 'linux'): + yield + + +def test_partition_trivial(): + assert xargs.partition(('cmd',), (), 1) == (('cmd',),) + + +def test_partition_simple(): + assert xargs.partition(('cmd',), ('foo',), 1) == (('cmd', 'foo'),) + + +def test_partition_limits(): + ret = xargs.partition( + ('ninechars',), ( + # Just match the end (with spaces) + '.' * 5, '.' * 4, + # Just match the end (single arg) + '.' * 10, + # Goes over the end + '.' * 5, + '.' * 6, + ), + 1, + _max_length=21, + ) + assert ret == ( + ('ninechars', '.' * 5, '.' * 4), + ('ninechars', '.' * 10), + ('ninechars', '.' * 5), + ('ninechars', '.' * 6), + ) + + +def test_partition_limit_win32(win32_mock): + cmd = ('ninechars',) + # counted as half because of utf-16 encode + varargs = ('πŸ˜‘' * 5,) + ret = xargs.partition(cmd, varargs, 1, _max_length=21) + assert ret == (cmd + varargs,) + + +def test_partition_limit_linux(linux_mock): + cmd = ('ninechars',) + varargs = ('πŸ˜‘' * 5,) + ret = xargs.partition(cmd, varargs, 1, _max_length=31) + assert ret == (cmd + varargs,) + + +def test_argument_too_long_with_large_unicode(linux_mock): + cmd = ('ninechars',) + varargs = ('πŸ˜‘' * 10,) # 4 bytes * 10 + with pytest.raises(xargs.ArgumentTooLongError): + xargs.partition(cmd, varargs, 1, _max_length=20) + + +def test_partition_target_concurrency(): + ret = xargs.partition( + ('foo',), ('A',) * 22, + 4, + _max_length=50, + ) + assert ret == ( + ('foo',) + ('A',) * 6, + ('foo',) + ('A',) * 6, + ('foo',) + ('A',) * 6, + ('foo',) + ('A',) * 4, + ) + + +def test_partition_target_concurrency_wont_make_tiny_partitions(): + ret = xargs.partition( + ('foo',), ('A',) * 10, + 4, + _max_length=50, + ) + assert ret == ( + ('foo',) + ('A',) * 4, + ('foo',) + ('A',) * 4, + ('foo',) + ('A',) * 2, + ) + + +def test_argument_too_long(): + with pytest.raises(xargs.ArgumentTooLongError): + xargs.partition(('a' * 5,), ('a' * 5,), 1, _max_length=10) + + +def test_xargs_smoke(): + ret, out = xargs.xargs(('echo',), ('hello', 'world')) + assert ret == 0 + assert out.replace(b'\r\n', b'\n') == b'hello world\n' + + +exit_cmd = parse_shebang.normalize_cmd(('bash', '-c', 'exit $1', '--')) +# Abuse max_length to control the exit code +max_length = len(' '.join(exit_cmd)) + 3 + + +def test_xargs_retcode_normal(): + ret, _ = xargs.xargs(exit_cmd, ('0',), _max_length=max_length) + assert ret == 0 + + ret, _ = xargs.xargs(exit_cmd, ('0', '1'), _max_length=max_length) + assert ret == 1 + + # takes the maximum return code + ret, _ = xargs.xargs(exit_cmd, ('0', '5', '1'), _max_length=max_length) + assert ret == 5 + + +def test_xargs_concurrency(): + bash_cmd = parse_shebang.normalize_cmd(('bash', '-c')) + print_pid = ('sleep 0.5 && echo $$',) + + start = time.time() + ret, stdout = xargs.xargs( + bash_cmd, print_pid * 5, + target_concurrency=5, + _max_length=len(' '.join(bash_cmd + print_pid)) + 1, + ) + elapsed = time.time() - start + assert ret == 0 + pids = stdout.splitlines() + assert len(pids) == 5 + # It would take 0.5*5=2.5 seconds ot run all of these in serial, so if it + # takes less, they must have run concurrently. + assert elapsed < 2.5 + + +def test_thread_mapper_concurrency_uses_threadpoolexecutor_map(): + with xargs._thread_mapper(10) as thread_map: + _self = thread_map.__self__ # type: ignore + assert isinstance(_self, concurrent.futures.ThreadPoolExecutor) + + +def test_thread_mapper_concurrency_uses_regular_map(): + with xargs._thread_mapper(1) as thread_map: + assert thread_map is map + + +def test_xargs_propagate_kwargs_to_cmd(): + env = {'PRE_COMMIT_TEST_VAR': 'Pre commit is awesome'} + cmd: Tuple[str, ...] = ('bash', '-c', 'echo $PRE_COMMIT_TEST_VAR', '--') + cmd = parse_shebang.normalize_cmd(cmd) + + ret, stdout = xargs.xargs(cmd, ('1',), env=env) + assert ret == 0 + assert b'Pre commit is awesome' in stdout + + +@pytest.mark.xfail(os.name == 'nt', reason='posix only') +def test_xargs_color_true_makes_tty(): + retcode, out = xargs.xargs( + (sys.executable, '-c', 'import sys; print(sys.stdout.isatty())'), + ('1',), + color=True, + ) + assert retcode == 0 + assert out == b'True\n' diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..d9f9420 --- /dev/null +++ b/tox.ini @@ -0,0 +1,28 @@ +[tox] +envlist = py36,py37,py38,pypy3,pre-commit + +[testenv] +deps = -rrequirements-dev.txt +passenv = HOME LOCALAPPDATA RUSTUP_HOME +commands = + coverage erase + coverage run -m pytest {posargs:tests} + coverage report + pre-commit install + +[testenv:pre-commit] +skip_install = true +deps = pre-commit +commands = pre-commit run --all-files --show-diff-on-failure + +[pep8] +ignore = E265,E501,W504 + +[pytest] +env = + GIT_AUTHOR_NAME=test + GIT_COMMITTER_NAME=test + GIT_AUTHOR_EMAIL=test@example.com + GIT_COMMITTER_EMAIL=test@example.com + VIRTUALENV_NO_DOWNLOAD=1 + PRE_COMMIT_NO_CONCURRENCY=1