diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 9408e44d..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,2 +0,0 @@ -github: asottile -open_collective: pre-commit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3553642d..4639bc6b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + rev: v4.1.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -12,35 +12,36 @@ repos: - id: name-tests-test - id: double-quote-string-fixer - id: requirements-txt-fixer -- repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 +- repo: https://github.com/PyCQA/flake8 + rev: 4.0.1 hooks: - id: flake8 additional_dependencies: [flake8-typing-imports==1.7.0] - repo: https://github.com/pre-commit/mirrors-autopep8 - rev: v1.5.4 + rev: v1.5.7 hooks: - id: autopep8 - repo: https://github.com/asottile/reorder_python_imports - rev: v2.3.6 + rev: v2.6.0 hooks: - id: reorder-python-imports args: [--py3-plus] - repo: https://github.com/asottile/pyupgrade - rev: v2.7.4 + rev: v2.29.1 hooks: - id: pyupgrade args: [--py36-plus] - repo: https://github.com/asottile/add-trailing-comma - rev: v2.0.1 + rev: v2.2.1 hooks: - id: add-trailing-comma args: [--py36-plus] - repo: https://github.com/asottile/setup-cfg-fmt - rev: v1.15.1 + rev: v1.20.0 hooks: - id: setup-cfg-fmt - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.790 + rev: v0.920 hooks: - id: mypy + additional_dependencies: [types-all] diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index fa617b97..56906603 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,206 +1,193 @@ -- id: autopep8-wrapper - name: autopep8 wrapper (removed) - description: (removed) use pre-commit/mirrors-autopep8 instead. - entry: pre-commit-hooks-removed autopep8-wrapper autopep8 https://github.com/pre-commit/mirrors-autopep8 - language: python - always_run: true - pass_filenames: false - id: check-added-large-files - name: Check for added large files - description: Prevent giant files from being committed + name: check for added large files + description: prevents giant files from being committed. entry: check-added-large-files language: python - id: check-ast - name: Check python ast - description: Simply check whether the files parse as valid python. + name: check python ast + description: simply checks whether the files parse as valid python. entry: check-ast language: python types: [python] - id: check-byte-order-marker name: 'check BOM - deprecated: use fix-byte-order-marker' - description: forbid files which have a UTF-8 byte-order marker + description: forbids files which have a utf-8 byte-order marker. entry: check-byte-order-marker language: python types: [text] - id: check-builtin-literals - name: Check builtin type constructor use - description: Require literal syntax when initializing empty or zero Python builtin types. + name: check builtin type constructor use + description: requires literal syntax when initializing empty or zero python builtin types. entry: check-builtin-literals language: python types: [python] - id: check-case-conflict - name: Check for case conflicts - description: Check for files that would conflict in case-insensitive filesystems + name: check for case conflicts + description: checks for files that would conflict in case-insensitive filesystems. entry: check-case-conflict language: python - id: check-docstring-first - name: Check docstring is first - description: Checks a common error of defining a docstring after code. + name: check docstring is first + description: checks a common error of defining a docstring after code. entry: check-docstring-first language: python types: [python] - id: check-executables-have-shebangs - name: Check that executables have shebangs - description: Ensures that (non-binary) executables have a shebang. + name: check that executables have shebangs + description: ensures that (non-binary) executables have a shebang. entry: check-executables-have-shebangs language: python types: [text, executable] stages: [commit, push, manual] - id: check-json - name: Check JSON - description: This hook checks json files for parseable syntax. + name: check json + description: checks json files for parseable syntax. entry: check-json language: python types: [json] +- id: check-shebang-scripts-are-executable + name: check that scripts with shebangs are executable + description: ensures that (non-binary) files with a shebang are executable. + entry: check-shebang-scripts-are-executable + language: python + types: [text] + stages: [commit, push, manual] - id: pretty-format-json - name: Pretty format JSON - description: This hook sets a standard for formatting JSON files. + name: pretty format json + description: sets a standard for formatting json files. entry: pretty-format-json language: python types: [json] - id: check-merge-conflict - name: Check for merge conflicts - description: Check for files that contain merge conflict strings. + name: check for merge conflicts + description: checks for files that contain merge conflict strings. entry: check-merge-conflict language: python types: [text] - id: check-symlinks - name: Check for broken symlinks - description: Checks for symlinks which do not point to anything. + name: check for broken symlinks + description: checks for symlinks which do not point to anything. entry: check-symlinks language: python types: [symlink] - id: check-toml - name: Check Toml - description: This hook checks toml files for parseable syntax. + name: check toml + description: checks toml files for parseable syntax. entry: check-toml language: python types: [toml] - id: check-vcs-permalinks - name: Check vcs permalinks - description: Ensures that links to vcs websites are permalinks. + name: check vcs permalinks + description: ensures that links to vcs websites are permalinks. entry: check-vcs-permalinks language: python types: [text] - id: check-xml - name: Check Xml - description: This hook checks xml files for parseable syntax. + name: check xml + description: checks xml files for parseable syntax. entry: check-xml language: python types: [xml] - id: check-yaml - name: Check Yaml - description: This hook checks yaml files for parseable syntax. + name: check yaml + description: checks yaml files for parseable syntax. entry: check-yaml language: python types: [yaml] - id: debug-statements - name: Debug Statements (Python) - description: Check for debugger imports and py37+ `breakpoint()` calls in python source. + name: debug statements (python) + description: checks for debugger imports and py37+ `breakpoint()` calls in python source. entry: debug-statement-hook language: python types: [python] - id: destroyed-symlinks - name: Detect Destroyed Symlinks - description: Detects symlinks which are changed to regular files with a content of a path which that symlink was pointing to. + name: detect destroyed symlinks + description: detects symlinks which are changed to regular files with a content of a path which that symlink was pointing to. entry: destroyed-symlinks language: python types: [file] - id: detect-aws-credentials - name: Detect AWS Credentials - description: Detects *your* aws credentials from the aws cli credentials file + name: detect aws credentials + description: detects *your* aws credentials from the aws cli credentials file. entry: detect-aws-credentials language: python types: [text] - id: detect-private-key - name: Detect Private Key - description: Detects the presence of private keys + name: detect private key + description: detects the presence of private keys. entry: detect-private-key language: python types: [text] - id: double-quote-string-fixer - name: Fix double quoted strings - description: This hook replaces double quoted strings with single quoted strings + name: fix double quoted strings + description: replaces double quoted strings with single quoted strings. entry: double-quote-string-fixer language: python types: [python] - id: end-of-file-fixer - name: Fix End of Files - description: Ensures that a file is either empty, or ends with one newline. + name: fix end of files + description: ensures that a file is either empty, or ends with one newline. entry: end-of-file-fixer language: python types: [text] stages: [commit, push, manual] - id: file-contents-sorter - name: File Contents Sorter - description: Sort the lines in specified files (defaults to alphabetical). You must provide list of target files as input in your .pre-commit-config.yaml file. + name: file contents sorter + description: sorts the lines in specified files (defaults to alphabetical). you must provide list of target files as input in your .pre-commit-config.yaml file. entry: file-contents-sorter language: python files: '^$' - id: fix-byte-order-marker - name: fix UTF-8 byte order marker - description: removes UTF-8 byte order marker + name: fix utf-8 byte order marker + description: removes utf-8 byte order marker. entry: fix-byte-order-marker language: python types: [text] - id: fix-encoding-pragma - name: Fix python encoding pragma + name: fix python encoding pragma + description: 'adds # -*- coding: utf-8 -*- to the top of python files.' language: python entry: fix-encoding-pragma - description: 'Add # -*- coding: utf-8 -*- to the top of python files' types: [python] -- id: flake8 - name: Flake8 (removed) - description: (removed) use gitlab.com/pycqa/flake8 instead. - entry: pre-commit-hooks-removed flake8 flake8 https://gitlab.com/pycqa/flake8 - language: python - always_run: true - pass_filenames: false - id: forbid-new-submodules - name: Forbid new submodules + name: forbid new submodules + description: prevents addition of new git submodules. language: python entry: forbid-new-submodules - description: Prevent addition of new git submodules + types: [directory] - id: mixed-line-ending - name: Mixed line ending - description: Replaces or checks mixed line ending + name: mixed line ending + description: replaces or checks mixed line ending. entry: mixed-line-ending language: python types: [text] - id: name-tests-test - name: Tests should end in _test.py - description: This verifies that test files are named correctly + name: tests should end in _test.py + description: this verifies that test files are named correctly. entry: name-tests-test language: python files: (^|/)tests/.+\.py$ - id: no-commit-to-branch - name: "Don't commit to branch" + name: "don't commit to branch" entry: no-commit-to-branch language: python pass_filenames: false always_run: true -- id: pyflakes - name: Pyflakes (removed) - description: (removed) use gitlab.com/pycqa/flake8 instead. - entry: pre-commit-hooks-removed pyflakes flake8 https://gitlab.com/pycqa/flake8 - language: python - always_run: true - pass_filenames: false - id: requirements-txt-fixer - name: Fix requirements.txt - description: Sorts entries in requirements.txt + name: fix requirements.txt + description: sorts entries in requirements.txt. entry: requirements-txt-fixer language: python files: requirements.*\.txt$ - id: sort-simple-yaml - name: Sort simple YAML files + name: sort simple yaml files + description: sorts simple yaml files which consist only of top-level keys, preserving comments and blocks. language: python entry: sort-simple-yaml - description: Sorts simple YAML files which consist only of top-level keys, preserving comments and blocks. files: '^$' - id: trailing-whitespace - name: Trim Trailing Whitespace - description: This hook trims trailing whitespace. + name: trim trailing whitespace + description: trims trailing whitespace. entry: trailing-whitespace-fixer language: python types: [text] diff --git a/CHANGELOG.md b/CHANGELOG.md index 5de3576e..50e72022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,78 @@ +4.1.0 - 2021-12-22 +================== + +### Features +- `debug-statements`: add `pdbr` debugger. + - #614 PR by @cansarigol. +- `detect-private-key`: add detection for additional key types. + - #658 PR by @ljmf00. +- `check-executables-have-shebangs`: improve messaging on windows. + - #689 PR by @pujitm. + - #686 issue by @jmerdich. +- `check-added-large-files`: support `--enforce-all` with `git-lfs`. + - #674 PR by @amartani. + - #560 issue by @jeremy-coulon. + +### Fixes +- `check-case-conflict`: improve performance. + - #626 PR by @guykisel. + - #625 issue by @guykisel. +- `forbid-new-submodules`: fix false-negatives for `pre-push`. + - #619 PR by @m-khvoinitsky. + - #609 issue by @m-khvoinitsky. +- `check-merge-conflict`: fix execution in git worktrees. + - #662 PR by @errsyn. + - #638 issue by @daschuer. + +### Misc. +- Normalize case of hook names and descriptions. + - #671 PR by @dennisroche. + - #673 PR by @revolter. + +4.0.1 - 2021-05-16 +================== + +### Fixes +- `check-shebang-scripts-are-executable` fix entry point. + - #602 issue by @Person-93. + - #603 PR by @scop. + +4.0.0 - 2021-05-14 +================== + +### Features +- `check-json`: report duplicate keys. + - #558 PR by @AdityaKhursale. + - #554 issue by @adamchainz. +- `no-commit-to-branch`: add `main` to default blocked branches. + - #565 PR by @ndevenish. +- `check-case-conflict`: check conflicts in directory names as well. + - #575 PR by @slsyy. + - #70 issue by @andyjack. +- `check-vcs-permalinks`: forbid other branch names. + - #582 PR by @jack1142. + - #581 issue by @jack1142. +- `check-shebang-scripts-are-executable`: new hook which ensures shebang'd + scripts are executable. + - #545 PR by @scop. + +### Fixes +- `check-executables-have-shebangs`: Short circuit shebang lookup on windows. + - #544 PR by @scop. +- `requirements-txt-fixer`: Fix comments which have indentation + - #549 PR by @greshilov. + - #548 issue by @greshilov. +- `pretty-format-json`: write to stdout using UTF-8 encoding. + - #571 PR by @jack1142. + - #570 issue by @jack1142. +- Use more inclusive language. + - #599 PR by @asottile. + +### Breaking changes +- Remove deprecated hooks: `flake8`, `pyflakes`, `autopep8-wrapper`. + - #597 PR by @asottile. + + 3.4.0 - 2020-12-15 ================== diff --git a/README.md b/README.md index f66d2657..c5a199e1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Add this to your `.pre-commit-config.yaml` ```yaml - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 # Use the ref you want to point at + rev: v4.1.0 # Use the ref you want to point at hooks: - id: trailing-whitespace # - id: ... @@ -58,6 +58,9 @@ Attempts to load all json files to verify syntax. #### `check-merge-conflict` Check for files that contain merge conflict strings. +#### `check-shebang-scripts-are-executable` +Checks that scripts with shebangs are executable. + #### `check-symlinks` Checks for symlinks which do not point to anything. @@ -140,7 +143,7 @@ Assert that files in tests/ end in `_test.py`. #### `no-commit-to-branch` Protect specific branches from direct checkins. - Use `args: [--branch, staging, --branch, master]` to set the branch. - `master` is the default if no branch argument is set. + Both `master` and `main` are protected by default if no branch argument is set. - `-b` / `--branch` may be specified multiple times to protect multiple branches. - `-p` / `--pattern` can be used to protect branches that match a supplied regex @@ -190,10 +193,6 @@ Trims trailing whitespace. ### Deprecated / replaced hooks -- `autopep8-wrapper`: instead use - [mirrors-autopep8](https://github.com/pre-commit/mirrors-autopep8) -- `pyflakes`: instead use `flake8` -- `flake8`: instead use [upstream flake8](https://gitlab.com/pycqa/flake8) - `check-byte-order-marker`: instead use fix-byte-order-marker ### As a standalone package diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c1ef4f4c..58dc61dd 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -10,7 +10,7 @@ resources: type: github endpoint: github name: asottile/azure-pipeline-templates - ref: refs/tags/v1.0.0 + ref: refs/tags/v2.1.0 jobs: - template: job--python-tox.yml@asottile diff --git a/pre_commit_hooks/check_added_large_files.py b/pre_commit_hooks/check_added_large_files.py index cb646d7b..e1beb4ef 100644 --- a/pre_commit_hooks/check_added_large_files.py +++ b/pre_commit_hooks/check_added_large_files.py @@ -1,24 +1,33 @@ import argparse -import json import math import os +import subprocess from typing import Optional from typing import Sequence from typing import Set from pre_commit_hooks.util import added_files -from pre_commit_hooks.util import CalledProcessError -from pre_commit_hooks.util import cmd_output +from pre_commit_hooks.util import zsplit -def lfs_files() -> Set[str]: - try: - # Introduced in git-lfs 2.2.0, first working in 2.2.1 - lfs_ret = cmd_output('git', 'lfs', 'status', '--json') - except CalledProcessError: # pragma: no cover (with git-lfs) - lfs_ret = '{"files":{}}' +def filter_lfs_files(filenames: Set[str]) -> None: # pragma: no cover (lfs) + """Remove files tracked by git-lfs from the set.""" + if not filenames: + return - return set(json.loads(lfs_ret)['files']) + check_attr = subprocess.run( + ('git', 'check-attr', 'filter', '-z', '--stdin'), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + encoding='utf-8', + check=True, + input='\0'.join(filenames), + ) + stdout = zsplit(check_attr.stdout) + for i in range(0, len(stdout), 3): + filename, filter_tag = stdout[i], stdout[i + 2] + if filter_tag == 'lfs': + filenames.remove(filename) def find_large_added_files( @@ -30,7 +39,9 @@ def find_large_added_files( # Find all added files that are also in the list of files pre-commit tells # us about retv = 0 - filenames_filtered = set(filenames) - lfs_files() + filenames_filtered = set(filenames) + filter_lfs_files(filenames_filtered) + if not enforce_all: filenames_filtered &= added_files() @@ -55,7 +66,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: ) parser.add_argument( '--maxkb', type=int, default=500, - help='Maxmimum allowable KB for added files', + help='Maximum allowable KB for added files', ) args = parser.parse_args(argv) @@ -67,4 +78,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_ast.py b/pre_commit_hooks/check_ast.py index 2be6e1af..ab5661dc 100644 --- a/pre_commit_hooks/check_ast.py +++ b/pre_commit_hooks/check_ast.py @@ -29,4 +29,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_builtin_literals.py b/pre_commit_hooks/check_builtin_literals.py index 6bcd8387..3fbae3e8 100644 --- a/pre_commit_hooks/check_builtin_literals.py +++ b/pre_commit_hooks/check_builtin_literals.py @@ -103,4 +103,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_byte_order_marker.py b/pre_commit_hooks/check_byte_order_marker.py index c0c2969c..fda05e84 100644 --- a/pre_commit_hooks/check_byte_order_marker.py +++ b/pre_commit_hooks/check_byte_order_marker.py @@ -20,4 +20,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_case_conflict.py b/pre_commit_hooks/check_case_conflict.py index 6b8ba82f..c3f39db5 100644 --- a/pre_commit_hooks/check_case_conflict.py +++ b/pre_commit_hooks/check_case_conflict.py @@ -1,5 +1,6 @@ import argparse from typing import Iterable +from typing import Iterator from typing import Optional from typing import Sequence from typing import Set @@ -12,9 +13,23 @@ def lower_set(iterable: Iterable[str]) -> Set[str]: return {x.lower() for x in iterable} +def parents(file: str) -> Iterator[str]: + path_parts = file.split('/') + path_parts.pop() + while path_parts: + yield '/'.join(path_parts) + path_parts.pop() + + +def directories_for(files: Set[str]) -> Set[str]: + return {parent for file in files for parent in parents(file)} + + def find_conflicting_filenames(filenames: Sequence[str]) -> int: repo_files = set(cmd_output('git', 'ls-files').splitlines()) + repo_files |= directories_for(repo_files) relevant_files = set(filenames) | added_files() + relevant_files |= directories_for(relevant_files) repo_files -= relevant_files retv = 0 @@ -54,4 +69,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_executables_have_shebangs.py b/pre_commit_hooks/check_executables_have_shebangs.py index 2d2bd7df..34af5cac 100644 --- a/pre_commit_hooks/check_executables_have_shebangs.py +++ b/pre_commit_hooks/check_executables_have_shebangs.py @@ -2,7 +2,9 @@ import argparse import shlex import sys +from typing import Generator from typing import List +from typing import NamedTuple from typing import Optional from typing import Sequence from typing import Set @@ -19,30 +21,38 @@ def check_executables(paths: List[str]) -> int: else: # pragma: win32 no cover retv = 0 for path in paths: - if not _check_has_shebang(path): + if not has_shebang(path): _message(path) retv = 1 return retv -def _check_git_filemode(paths: Sequence[str]) -> int: +class GitLsFile(NamedTuple): + mode: str + filename: str + + +def git_ls_files(paths: Sequence[str]) -> Generator[GitLsFile, None, None]: outs = cmd_output('git', 'ls-files', '-z', '--stage', '--', *paths) - seen: Set[str] = set() for out in zsplit(outs): - metadata, path = out.split('\t') - tagmode = metadata.split(' ', 1)[0] + metadata, filename = out.split('\t') + mode, _, _ = metadata.split() + yield GitLsFile(mode, filename) - is_executable = any(b in EXECUTABLE_VALUES for b in tagmode[-3:]) - has_shebang = _check_has_shebang(path) - if is_executable and not has_shebang: - _message(path) - seen.add(path) + +def _check_git_filemode(paths: Sequence[str]) -> int: + seen: Set[str] = set() + for ls_file in git_ls_files(paths): + is_executable = any(b in EXECUTABLE_VALUES for b in ls_file.mode[-3:]) + if is_executable and not has_shebang(ls_file.filename): + _message(ls_file.filename) + seen.add(ls_file.filename) return int(bool(seen)) -def _check_has_shebang(path: str) -> int: +def has_shebang(path: str) -> int: with open(path, 'rb') as f: first_bytes = f.read(2) @@ -54,6 +64,8 @@ def _message(path: str) -> None: f'{path}: marked executable but has no (or invalid) shebang!\n' f" If it isn't supposed to be executable, try: " f'`chmod -x {shlex.quote(path)}`\n' + f' If on Windows, you may also need to: ' + f'`git add --chmod=-x {shlex.quote(path)}`\n' f' If it is supposed to be executable, double-check its shebang.', file=sys.stderr, ) @@ -68,4 +80,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_json.py b/pre_commit_hooks/check_json.py index 6026270c..96ba024e 100644 --- a/pre_commit_hooks/check_json.py +++ b/pre_commit_hooks/check_json.py @@ -1,7 +1,23 @@ import argparse import json +from typing import Any +from typing import Dict +from typing import List from typing import Optional from typing import Sequence +from typing import Tuple + + +def raise_duplicate_keys( + ordered_pairs: List[Tuple[str, Any]], +) -> Dict[str, Any]: + d = {} + for key, val in ordered_pairs: + if key in d: + raise ValueError(f'Duplicate key: {key}') + else: + d[key] = val + return d def main(argv: Optional[Sequence[str]] = None) -> int: @@ -13,7 +29,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: for filename in args.filenames: with open(filename, 'rb') as f: try: - json.load(f) + json.load(f, object_pairs_hook=raise_duplicate_keys) except ValueError as exc: print(f'{filename}: Failed to json decode ({exc})') retval = 1 @@ -21,4 +37,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_merge_conflict.py b/pre_commit_hooks/check_merge_conflict.py index c20a8af7..aed1e9ca 100644 --- a/pre_commit_hooks/check_merge_conflict.py +++ b/pre_commit_hooks/check_merge_conflict.py @@ -3,6 +3,8 @@ from typing import Optional from typing import Sequence +from pre_commit_hooks.util import cmd_output + CONFLICT_PATTERNS = [ b'<<<<<<< ', @@ -12,13 +14,14 @@ ] -def is_in_merge() -> int: +def is_in_merge() -> bool: + git_dir = cmd_output('git', 'rev-parse', '--git-dir').rstrip() return ( - os.path.exists(os.path.join('.git', 'MERGE_MSG')) and + os.path.exists(os.path.join(git_dir, 'MERGE_MSG')) and ( - os.path.exists(os.path.join('.git', 'MERGE_HEAD')) or - os.path.exists(os.path.join('.git', 'rebase-apply')) or - os.path.exists(os.path.join('.git', 'rebase-merge')) + os.path.exists(os.path.join(git_dir, 'MERGE_HEAD')) or + os.path.exists(os.path.join(git_dir, 'rebase-apply')) or + os.path.exists(os.path.join(git_dir, 'rebase-merge')) ) ) @@ -48,4 +51,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_shebang_scripts_are_executable.py b/pre_commit_hooks/check_shebang_scripts_are_executable.py new file mode 100644 index 00000000..50bc9c0c --- /dev/null +++ b/pre_commit_hooks/check_shebang_scripts_are_executable.py @@ -0,0 +1,53 @@ +"""Check that text files with a shebang are executable.""" +import argparse +import shlex +import sys +from typing import List +from typing import Optional +from typing import Sequence +from typing import Set + +from pre_commit_hooks.check_executables_have_shebangs import EXECUTABLE_VALUES +from pre_commit_hooks.check_executables_have_shebangs import git_ls_files +from pre_commit_hooks.check_executables_have_shebangs import has_shebang + + +def check_shebangs(paths: List[str]) -> int: + # Cannot optimize on non-executability here if we intend this check to + # work on win32 -- and that's where problems caused by non-executability + # (elsewhere) are most likely to arise from. + return _check_git_filemode(paths) + + +def _check_git_filemode(paths: Sequence[str]) -> int: + seen: Set[str] = set() + for ls_file in git_ls_files(paths): + is_executable = any(b in EXECUTABLE_VALUES for b in ls_file.mode[-3:]) + if not is_executable and has_shebang(ls_file.filename): + _message(ls_file.filename) + seen.add(ls_file.filename) + + return int(bool(seen)) + + +def _message(path: str) -> None: + print( + f'{path}: has a shebang but is not marked executable!\n' + f' If it is supposed to be executable, try: ' + f'`chmod +x {shlex.quote(path)}`\n' + f' If it not supposed to be executable, double-check its shebang ' + f'is wanted.\n', + file=sys.stderr, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('filenames', nargs='*') + args = parser.parse_args(argv) + + return check_shebangs(args.filenames) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_symlinks.py b/pre_commit_hooks/check_symlinks.py index f014714a..db3b4fea 100644 --- a/pre_commit_hooks/check_symlinks.py +++ b/pre_commit_hooks/check_symlinks.py @@ -23,4 +23,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_toml.py b/pre_commit_hooks/check_toml.py index 87496753..f623e688 100644 --- a/pre_commit_hooks/check_toml.py +++ b/pre_commit_hooks/check_toml.py @@ -21,4 +21,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_vcs_permalinks.py b/pre_commit_hooks/check_vcs_permalinks.py index a30277ce..1c77b9a3 100644 --- a/pre_commit_hooks/check_vcs_permalinks.py +++ b/pre_commit_hooks/check_vcs_permalinks.py @@ -8,7 +8,10 @@ def _get_pattern(domain: str) -> Pattern[bytes]: - regex = rf'https://{domain}/[^/ ]+/[^/ ]+/blob/master/[^# ]+#L\d+' + regex = ( + rf'https://{domain}/[^/ ]+/[^/ ]+/blob/' + r'(?![a-fA-F0-9]{4,64}/)([^/. ]+)/[^# ]+#L\d+' + ) return re.compile(regex.encode()) @@ -54,4 +57,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_xml.py b/pre_commit_hooks/check_xml.py index 59b4d59e..0fa6bb23 100644 --- a/pre_commit_hooks/check_xml.py +++ b/pre_commit_hooks/check_xml.py @@ -22,4 +22,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/check_yaml.py b/pre_commit_hooks/check_yaml.py index 7453f6fb..5e86b73a 100644 --- a/pre_commit_hooks/check_yaml.py +++ b/pre_commit_hooks/check_yaml.py @@ -68,4 +68,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/debug_statement_hook.py b/pre_commit_hooks/debug_statement_hook.py index 794f7080..f78d6d62 100644 --- a/pre_commit_hooks/debug_statement_hook.py +++ b/pre_commit_hooks/debug_statement_hook.py @@ -10,6 +10,7 @@ DEBUG_STATEMENTS = { 'ipdb', 'pdb', + 'pdbr', 'pudb', 'pydevd_pycharm', 'q', @@ -81,4 +82,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/destroyed_symlinks.py b/pre_commit_hooks/destroyed_symlinks.py old mode 100755 new mode 100644 index cfaf4e53..a3f122ff --- a/pre_commit_hooks/destroyed_symlinks.py +++ b/pre_commit_hooks/destroyed_symlinks.py @@ -93,4 +93,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/detect_aws_credentials.py b/pre_commit_hooks/detect_aws_credentials.py index 1663cfd6..ba1d789a 100644 --- a/pre_commit_hooks/detect_aws_credentials.py +++ b/pre_commit_hooks/detect_aws_credentials.py @@ -149,4 +149,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/detect_private_key.py b/pre_commit_hooks/detect_private_key.py index 7bbc2f91..18f95394 100644 --- a/pre_commit_hooks/detect_private_key.py +++ b/pre_commit_hooks/detect_private_key.py @@ -11,6 +11,8 @@ b'PuTTY-User-Key-File-2', b'BEGIN SSH2 ENCRYPTED PRIVATE KEY', b'BEGIN PGP PRIVATE KEY BLOCK', + b'BEGIN ENCRYPTED PRIVATE KEY', + b'BEGIN OpenVPN Static key V1', ] @@ -36,4 +38,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/end_of_file_fixer.py b/pre_commit_hooks/end_of_file_fixer.py index 1c07379d..40e8821d 100644 --- a/pre_commit_hooks/end_of_file_fixer.py +++ b/pre_commit_hooks/end_of_file_fixer.py @@ -67,4 +67,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/file_contents_sorter.py b/pre_commit_hooks/file_contents_sorter.py index ebbcd206..392e2261 100644 --- a/pre_commit_hooks/file_contents_sorter.py +++ b/pre_commit_hooks/file_contents_sorter.py @@ -81,4 +81,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/fix_byte_order_marker.py b/pre_commit_hooks/fix_byte_order_marker.py index 1ffe047d..51b94e16 100644 --- a/pre_commit_hooks/fix_byte_order_marker.py +++ b/pre_commit_hooks/fix_byte_order_marker.py @@ -27,4 +27,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/fix_encoding_pragma.py b/pre_commit_hooks/fix_encoding_pragma.py index 88d72ed7..c704774f 100644 --- a/pre_commit_hooks/fix_encoding_pragma.py +++ b/pre_commit_hooks/fix_encoding_pragma.py @@ -145,4 +145,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/forbid_new_submodules.py b/pre_commit_hooks/forbid_new_submodules.py index c144d728..02758089 100644 --- a/pre_commit_hooks/forbid_new_submodules.py +++ b/pre_commit_hooks/forbid_new_submodules.py @@ -1,3 +1,5 @@ +import argparse +import os from typing import Optional from typing import Sequence @@ -5,10 +7,23 @@ def main(argv: Optional[Sequence[str]] = None) -> int: - # `argv` is ignored, pre-commit will send us a list of files that we - # don't care about + parser = argparse.ArgumentParser() + parser.add_argument('filenames', nargs='*') + args = parser.parse_args(argv) + + if ( + 'PRE_COMMIT_FROM_REF' in os.environ and + 'PRE_COMMIT_TO_REF' in os.environ + ): + diff_arg = '...'.join(( + os.environ['PRE_COMMIT_FROM_REF'], + os.environ['PRE_COMMIT_TO_REF'], + )) + else: + diff_arg = '--staged' added_diff = cmd_output( - 'git', 'diff', '--staged', '--diff-filter=A', '--raw', + 'git', 'diff', '--diff-filter=A', '--raw', diff_arg, '--', + *args.filenames, ) retv = 0 for line in added_diff.splitlines(): @@ -29,4 +44,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/mixed_line_ending.py b/pre_commit_hooks/mixed_line_ending.py index 0ef8e2c0..4e07ed96 100644 --- a/pre_commit_hooks/mixed_line_ending.py +++ b/pre_commit_hooks/mixed_line_ending.py @@ -85,4 +85,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/no_commit_to_branch.py b/pre_commit_hooks/no_commit_to_branch.py index fb1506f9..db848507 100644 --- a/pre_commit_hooks/no_commit_to_branch.py +++ b/pre_commit_hooks/no_commit_to_branch.py @@ -38,10 +38,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int: ) args = parser.parse_args(argv) - protected = frozenset(args.branch or ('master',)) + protected = frozenset(args.branch or ('master', 'main')) patterns = frozenset(args.pattern or ()) return int(is_on_branch(protected, patterns)) if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/pretty_format_json.py b/pre_commit_hooks/pretty_format_json.py index 25827dc4..33ad5a1c 100644 --- a/pre_commit_hooks/pretty_format_json.py +++ b/pre_commit_hooks/pretty_format_json.py @@ -1,5 +1,6 @@ import argparse import json +import sys from difflib import unified_diff from typing import List from typing import Mapping @@ -111,17 +112,6 @@ def main(argv: Optional[Sequence[str]] = None) -> int: contents, args.indent, ensure_ascii=not args.no_ensure_ascii, sort_keys=not args.no_sort_keys, top_keys=args.top_keys, ) - - if contents != pretty_contents: - if args.autofix: - _autofix(json_file, pretty_contents) - else: - print( - get_diff(contents, pretty_contents, json_file), - end='', - ) - - status = 1 except ValueError: print( f'Input File {json_file} is not a valid JSON, consider using ' @@ -129,8 +119,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int: ) return 1 + if contents != pretty_contents: + if args.autofix: + _autofix(json_file, pretty_contents) + else: + diff_output = get_diff(contents, pretty_contents, json_file) + sys.stdout.buffer.write(diff_output.encode()) + + status = 1 + return status if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/removed.py b/pre_commit_hooks/removed.py index 60df0963..236cbf83 100644 --- a/pre_commit_hooks/removed.py +++ b/pre_commit_hooks/removed.py @@ -12,4 +12,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/requirements_txt_fixer.py b/pre_commit_hooks/requirements_txt_fixer.py index 24126331..63f891f7 100644 --- a/pre_commit_hooks/requirements_txt_fixer.py +++ b/pre_commit_hooks/requirements_txt_fixer.py @@ -95,7 +95,7 @@ def fix_requirements(f: IO[bytes]) -> int: requirement.value = b'\n' else: requirement.comments.append(line) - elif line.startswith(b'#') or line.strip() == b'': + elif line.lstrip().startswith(b'#') or line.strip() == b'': requirement.comments.append(line) else: requirement.append_value(line) @@ -150,4 +150,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/sort_simple_yaml.py b/pre_commit_hooks/sort_simple_yaml.py old mode 100755 new mode 100644 index 8ebc84ff..39f683e4 --- a/pre_commit_hooks/sort_simple_yaml.py +++ b/pre_commit_hooks/sort_simple_yaml.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python """Sort a simple YAML file, keeping blocks of comments and definitions together. @@ -123,4 +122,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/string_fixer.py b/pre_commit_hooks/string_fixer.py index 3fdb6e2f..a08a5f76 100644 --- a/pre_commit_hooks/string_fixer.py +++ b/pre_commit_hooks/string_fixer.py @@ -77,4 +77,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/tests_should_end_in_test.py b/pre_commit_hooks/tests_should_end_in_test.py index b8cf9152..bffb0c42 100644 --- a/pre_commit_hooks/tests_should_end_in_test.py +++ b/pre_commit_hooks/tests_should_end_in_test.py @@ -30,4 +30,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pre_commit_hooks/trailing_whitespace_fixer.py b/pre_commit_hooks/trailing_whitespace_fixer.py index 05ed9994..82faa2dc 100644 --- a/pre_commit_hooks/trailing_whitespace_fixer.py +++ b/pre_commit_hooks/trailing_whitespace_fixer.py @@ -99,4 +99,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/setup.cfg b/setup.cfg index 31c9c84b..45498ebe 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pre_commit_hooks -version = 3.4.0 +version = 4.1.0 description = Some out-of-the-box hooks for pre-commit. long_description = file: README.md long_description_content_type = text/markdown @@ -17,6 +17,7 @@ classifiers = Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: PyPy @@ -27,6 +28,11 @@ install_requires = toml python_requires = >=3.6.1 +[options.packages.find] +exclude = + tests* + testing* + [options.entry_points] console_scripts = check-added-large-files = pre_commit_hooks.check_added_large_files:main @@ -38,6 +44,7 @@ console_scripts = check-executables-have-shebangs = pre_commit_hooks.check_executables_have_shebangs:main check-json = pre_commit_hooks.check_json:main check-merge-conflict = pre_commit_hooks.check_merge_conflict:main + check-shebang-scripts-are-executable = pre_commit_hooks.check_shebang_scripts_are_executable:main check-symlinks = pre_commit_hooks.check_symlinks:main check-toml = pre_commit_hooks.check_toml:main check-vcs-permalinks = pre_commit_hooks.check_vcs_permalinks:main @@ -62,11 +69,6 @@ console_scripts = sort-simple-yaml = pre_commit_hooks.sort_simple_yaml:main trailing-whitespace-fixer = pre_commit_hooks.trailing_whitespace_fixer:main -[options.packages.find] -exclude = - tests* - testing* - [bdist_wheel] universal = True @@ -79,6 +81,8 @@ disallow_any_generics = true disallow_incomplete_defs = true disallow_untyped_defs = true no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true [mypy-testing.*] disallow_untyped_defs = false diff --git a/testing/resources/duplicate_key_json.notjson b/testing/resources/duplicate_key_json.notjson new file mode 100644 index 00000000..8a432623 --- /dev/null +++ b/testing/resources/duplicate_key_json.notjson @@ -0,0 +1,4 @@ +{ + "hello": "world", + "hello": "planet" +} diff --git a/testing/util.py b/testing/util.py index 8e468d60..50437544 100644 --- a/testing/util.py +++ b/testing/util.py @@ -1,4 +1,5 @@ import os.path +import subprocess TESTING_DIR = os.path.abspath(os.path.dirname(__file__)) @@ -6,3 +7,8 @@ def get_resource_path(path): return os.path.join(TESTING_DIR, 'resources', path) + + +def git_commit(*args, **kwargs): + cmd = ('git', 'commit', '--no-gpg-sign', '--no-verify', '--no-edit', *args) + subprocess.check_call(cmd, **kwargs) diff --git a/tests/check_added_large_files_test.py b/tests/check_added_large_files_test.py index ff53b05b..c16bf5a9 100644 --- a/tests/check_added_large_files_test.py +++ b/tests/check_added_large_files_test.py @@ -1,10 +1,11 @@ -import distutils.spawn +import shutil import pytest from pre_commit_hooks.check_added_large_files import find_large_added_files from pre_commit_hooks.check_added_large_files import main from pre_commit_hooks.util import cmd_output +from testing.util import git_commit def test_nothing_added(temp_git_dir): @@ -75,7 +76,7 @@ def test_integration(temp_git_dir): def has_gitlfs(): - return distutils.spawn.find_executable('git-lfs') is not None + return shutil.which('git-lfs') is not None xfailif_no_gitlfs = pytest.mark.xfail( @@ -84,10 +85,9 @@ def has_gitlfs(): @xfailif_no_gitlfs -def test_allows_gitlfs(temp_git_dir, monkeypatch): # pragma: no cover +def test_allows_gitlfs(temp_git_dir): # pragma: no cover with temp_git_dir.as_cwd(): - monkeypatch.setenv('HOME', str(temp_git_dir)) - cmd_output('git', 'lfs', 'install') + cmd_output('git', 'lfs', 'install', '--local') temp_git_dir.join('f.py').write('a' * 10000) cmd_output('git', 'lfs', 'track', 'f.py') cmd_output('git', 'add', '--', '.') @@ -96,27 +96,37 @@ def test_allows_gitlfs(temp_git_dir, monkeypatch): # pragma: no cover @xfailif_no_gitlfs -def test_moves_with_gitlfs(temp_git_dir, monkeypatch): # pragma: no cover +def test_moves_with_gitlfs(temp_git_dir): # pragma: no cover with temp_git_dir.as_cwd(): - monkeypatch.setenv('HOME', str(temp_git_dir)) - cmd_output('git', 'lfs', 'install') + cmd_output('git', 'lfs', 'install', '--local') cmd_output('git', 'lfs', 'track', 'a.bin', 'b.bin') # First add the file we're going to move temp_git_dir.join('a.bin').write('a' * 10000) cmd_output('git', 'add', '--', '.') - cmd_output('git', 'commit', '--no-gpg-sign', '-am', 'foo') + git_commit('-am', 'foo') # Now move it and make sure the hook still succeeds cmd_output('git', 'mv', 'a.bin', 'b.bin') assert main(('--maxkb', '9', 'b.bin')) == 0 @xfailif_no_gitlfs -def test_enforce_allows_gitlfs(temp_git_dir, monkeypatch): # pragma: no cover +def test_enforce_allows_gitlfs(temp_git_dir): # pragma: no cover with temp_git_dir.as_cwd(): - monkeypatch.setenv('HOME', str(temp_git_dir)) - cmd_output('git', 'lfs', 'install') + cmd_output('git', 'lfs', 'install', '--local') temp_git_dir.join('f.py').write('a' * 10000) cmd_output('git', 'lfs', 'track', 'f.py') cmd_output('git', 'add', '--', '.') # With --enforce-all large files on git lfs should succeed assert main(('--enforce-all', '--maxkb', '9', 'f.py')) == 0 + + +@xfailif_no_gitlfs +def test_enforce_allows_gitlfs_after_commit(temp_git_dir): # pragma: no cover + with temp_git_dir.as_cwd(): + cmd_output('git', 'lfs', 'install', '--local') + temp_git_dir.join('f.py').write('a' * 10000) + cmd_output('git', 'lfs', 'track', 'f.py') + cmd_output('git', 'add', '--', '.') + git_commit('-am', 'foo') + # With --enforce-all large files on git lfs should succeed + assert main(('--enforce-all', '--maxkb', '9', 'f.py')) == 0 diff --git a/tests/check_case_conflict_test.py b/tests/check_case_conflict_test.py index 53de852e..d9211b57 100644 --- a/tests/check_case_conflict_test.py +++ b/tests/check_case_conflict_test.py @@ -1,6 +1,24 @@ +import sys + +import pytest + from pre_commit_hooks.check_case_conflict import find_conflicting_filenames from pre_commit_hooks.check_case_conflict import main +from pre_commit_hooks.check_case_conflict import parents from pre_commit_hooks.util import cmd_output +from testing.util import git_commit + +skip_win32 = pytest.mark.skipif( + sys.platform == 'win32', + reason='case conflicts between directories and files', +) + + +def test_parents(): + assert set(parents('a')) == set() + assert set(parents('a/b')) == {'a'} + assert set(parents('a/b/c')) == {'a/b', 'a'} + assert set(parents('a/b/c/d')) == {'a/b/c', 'a/b', 'a'} def test_nothing_added(temp_git_dir): @@ -26,6 +44,36 @@ def test_adding_something_with_conflict(temp_git_dir): assert find_conflicting_filenames(['f.py', 'F.py']) == 1 +@skip_win32 # pragma: win32 no cover +def test_adding_files_with_conflicting_directories(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.mkdir('dir').join('x').write('foo') + temp_git_dir.mkdir('DIR').join('y').write('foo') + cmd_output('git', 'add', '-A') + + assert find_conflicting_filenames([]) == 1 + + +@skip_win32 # pragma: win32 no cover +def test_adding_files_with_conflicting_deep_directories(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.mkdir('x').mkdir('y').join('z').write('foo') + temp_git_dir.join('X').write('foo') + cmd_output('git', 'add', '-A') + + assert find_conflicting_filenames([]) == 1 + + +@skip_win32 # pragma: win32 no cover +def test_adding_file_with_conflicting_directory(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.mkdir('dir').join('x').write('foo') + temp_git_dir.join('DIR').write('foo') + cmd_output('git', 'add', '-A') + + assert find_conflicting_filenames([]) == 1 + + def test_added_file_not_in_pre_commits_list(temp_git_dir): with temp_git_dir.as_cwd(): temp_git_dir.join('f.py').write("print('hello world')") @@ -38,7 +86,7 @@ def test_file_conflicts_with_committed_file(temp_git_dir): with temp_git_dir.as_cwd(): temp_git_dir.join('f.py').write("print('hello world')") cmd_output('git', 'add', 'f.py') - cmd_output('git', 'commit', '--no-gpg-sign', '-n', '-m', 'Add f.py') + git_commit('-m', 'Add f.py') temp_git_dir.join('F.py').write("print('hello world')") cmd_output('git', 'add', 'F.py') @@ -46,6 +94,19 @@ def test_file_conflicts_with_committed_file(temp_git_dir): assert find_conflicting_filenames(['F.py']) == 1 +@skip_win32 # pragma: win32 no cover +def test_file_conflicts_with_committed_dir(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.mkdir('dir').join('x').write('foo') + cmd_output('git', 'add', '-A') + git_commit('-m', 'Add f.py') + + temp_git_dir.join('DIR').write('foo') + cmd_output('git', 'add', '-A') + + assert find_conflicting_filenames([]) == 1 + + def test_integration(temp_git_dir): with temp_git_dir.as_cwd(): assert main(argv=[]) == 0 diff --git a/tests/check_json_test.py b/tests/check_json_test.py index c63dc4c8..3ec67f1c 100644 --- a/tests/check_json_test.py +++ b/tests/check_json_test.py @@ -9,6 +9,7 @@ ('bad_json.notjson', 1), ('bad_json_latin1.nonjson', 1), ('ok_json.json', 0), + ('duplicate_key_json.notjson', 1), ), ) def test_main(capsys, filename, expected_retval): diff --git a/tests/check_merge_conflict_test.py b/tests/check_merge_conflict_test.py index fccf41ff..79c1b118 100644 --- a/tests/check_merge_conflict_test.py +++ b/tests/check_merge_conflict_test.py @@ -6,6 +6,7 @@ from pre_commit_hooks.check_merge_conflict import main from pre_commit_hooks.util import cmd_output from testing.util import get_resource_path +from testing.util import git_commit @pytest.fixture @@ -20,19 +21,19 @@ def f1_is_a_conflict_file(tmpdir): with repo1.as_cwd(): repo1_f1.ensure() cmd_output('git', 'add', '.') - cmd_output('git', 'commit', '--no-gpg-sign', '-m', 'commit1') + git_commit('-m', 'commit1') cmd_output('git', 'clone', str(repo1), str(repo2)) # Commit in master with repo1.as_cwd(): repo1_f1.write('parent\n') - cmd_output('git', 'commit', '--no-gpg-sign', '-am', 'master commit2') + git_commit('-am', 'master commit2') # Commit in clone and pull with repo2.as_cwd(): repo2_f1.write('child\n') - cmd_output('git', 'commit', '--no-gpg-sign', '-am', 'clone commit2') + git_commit('-am', 'clone commit2') cmd_output('git', 'pull', '--no-rebase', retcode=None) # We should end up in a merge conflict! f1 = repo2_f1.read() @@ -75,20 +76,20 @@ def repository_pending_merge(tmpdir): with repo1.as_cwd(): repo1_f1.ensure() cmd_output('git', 'add', '.') - cmd_output('git', 'commit', '--no-gpg-sign', '-m', 'commit1') + git_commit('-m', 'commit1') cmd_output('git', 'clone', str(repo1), str(repo2)) # Commit in master with repo1.as_cwd(): repo1_f1.write('parent\n') - cmd_output('git', 'commit', '--no-gpg-sign', '-am', 'master commit2') + git_commit('-am', 'master commit2') # Commit in clone and pull without committing with repo2.as_cwd(): repo2_f2.write('child\n') cmd_output('git', 'add', '.') - cmd_output('git', 'commit', '--no-gpg-sign', '-m', 'clone commit2') + git_commit('-m', 'clone commit2') cmd_output('git', 'pull', '--no-commit', '--no-rebase') # We should end up in a pending merge assert repo2_f1.read() == 'parent\n' @@ -134,3 +135,15 @@ def test_care_when_assumed_merge(tmpdir): f = tmpdir.join('README.md') f.write_binary(b'problem\n=======\n') assert main([str(f.realpath()), '--assume-in-merge']) == 1 + + +def test_worktree_merge_conflicts(f1_is_a_conflict_file, tmpdir): + worktree = tmpdir.join('worktree') + cmd_output('git', 'worktree', 'add', str(worktree)) + with worktree.as_cwd(): + cmd_output( + 'git', 'pull', '--no-rebase', 'origin', 'master', retcode=None, + ) + msg = f1_is_a_conflict_file.join('.git/worktrees/worktree/MERGE_MSG') + assert msg.exists() + test_merge_conflicts_git() diff --git a/tests/check_shebang_scripts_are_executable_test.py b/tests/check_shebang_scripts_are_executable_test.py new file mode 100644 index 00000000..9e78b06c --- /dev/null +++ b/tests/check_shebang_scripts_are_executable_test.py @@ -0,0 +1,87 @@ +import os + +import pytest + +from pre_commit_hooks.check_shebang_scripts_are_executable import \ + _check_git_filemode +from pre_commit_hooks.check_shebang_scripts_are_executable import main +from pre_commit_hooks.util import cmd_output + + +def test_check_git_filemode_passing(tmpdir): + with tmpdir.as_cwd(): + cmd_output('git', 'init', '.') + + f = tmpdir.join('f') + f.write('#!/usr/bin/env bash') + f_path = str(f) + cmd_output('chmod', '+x', f_path) + cmd_output('git', 'add', f_path) + cmd_output('git', 'update-index', '--chmod=+x', f_path) + + g = tmpdir.join('g').ensure() + g_path = str(g) + cmd_output('git', 'add', g_path) + + files = [f_path, g_path] + assert _check_git_filemode(files) == 0 + + # this is the one we should trigger on + h = tmpdir.join('h') + h.write('#!/usr/bin/env bash') + h_path = str(h) + cmd_output('git', 'add', h_path) + + files = [h_path] + assert _check_git_filemode(files) == 1 + + +def test_check_git_filemode_passing_unusual_characters(tmpdir): + with tmpdir.as_cwd(): + cmd_output('git', 'init', '.') + + f = tmpdir.join('maƱana.txt') + f.write('#!/usr/bin/env bash') + f_path = str(f) + cmd_output('chmod', '+x', f_path) + cmd_output('git', 'add', f_path) + cmd_output('git', 'update-index', '--chmod=+x', f_path) + + files = (f_path,) + assert _check_git_filemode(files) == 0 + + +def test_check_git_filemode_failing(tmpdir): + with tmpdir.as_cwd(): + cmd_output('git', 'init', '.') + + f = tmpdir.join('f').ensure() + f.write('#!/usr/bin/env bash') + f_path = str(f) + cmd_output('git', 'add', f_path) + + files = (f_path,) + assert _check_git_filemode(files) == 1 + + +@pytest.mark.parametrize( + ('content', 'mode', 'expected'), + ( + pytest.param('#!python', '+x', 0, id='shebang with executable'), + pytest.param('#!python', '-x', 1, id='shebang without executable'), + pytest.param('', '+x', 0, id='no shebang with executable'), + pytest.param('', '-x', 0, id='no shebang without executable'), + ), +) +def test_git_executable_shebang(temp_git_dir, content, mode, expected): + with temp_git_dir.as_cwd(): + path = temp_git_dir.join('path') + path.write(content) + cmd_output('git', 'add', str(path)) + cmd_output('chmod', mode, str(path)) + cmd_output('git', 'update-index', f'--chmod={mode}', str(path)) + + # simulate how identify chooses that something is executable + filenames = [path for path in [str(path)] if os.access(path, os.X_OK)] + + assert main(filenames) == expected diff --git a/tests/check_vcs_permalinks_test.py b/tests/check_vcs_permalinks_test.py index 7d5f86c2..ad591515 100644 --- a/tests/check_vcs_permalinks_test.py +++ b/tests/check_vcs_permalinks_test.py @@ -11,6 +11,8 @@ def test_passing(tmpdir): f.write_binary( # permalinks are ok b'https://github.com/asottile/test/blob/649e6/foo%20bar#L1\n' + # tags are ok + b'https://github.com/asottile/test/blob/1.0.0/foo%20bar#L1\n' # links to files but not line numbers are ok b'https://github.com/asottile/test/blob/master/foo%20bar\n' # regression test for overly-greedy regex @@ -23,7 +25,8 @@ def test_failing(tmpdir, capsys): with tmpdir.as_cwd(): tmpdir.join('f.txt').write_binary( b'https://github.com/asottile/test/blob/master/foo#L1\n' - b'https://example.com/asottile/test/blob/master/foo#L1\n', + b'https://example.com/asottile/test/blob/master/foo#L1\n' + b'https://example.com/asottile/test/blob/main/foo#L1\n', ) assert main(('f.txt', '--additional-github-domain', 'example.com')) @@ -31,6 +34,7 @@ def test_failing(tmpdir, capsys): assert out == ( 'f.txt:1:https://github.com/asottile/test/blob/master/foo#L1\n' 'f.txt:2:https://example.com/asottile/test/blob/master/foo#L1\n' + 'f.txt:3:https://example.com/asottile/test/blob/main/foo#L1\n' '\n' 'Non-permanent github link detected.\n' 'On any page on github press [y] to load a permalink.\n' diff --git a/tests/destroyed_symlinks_test.py b/tests/destroyed_symlinks_test.py index d2c90310..cde06cfb 100644 --- a/tests/destroyed_symlinks_test.py +++ b/tests/destroyed_symlinks_test.py @@ -5,6 +5,7 @@ from pre_commit_hooks.destroyed_symlinks import find_destroyed_symlinks from pre_commit_hooks.destroyed_symlinks import main +from testing.util import git_commit TEST_SYMLINK = 'test_symlink' TEST_SYMLINK_TARGET = '/doesnt/really/matters' @@ -23,9 +24,7 @@ def repo_with_destroyed_symlink(tmpdir): with open(TEST_FILE, 'w') as f: print('some random content', file=f) subprocess.check_call(('git', 'add', '.')) - subprocess.check_call( - ('git', 'commit', '--no-gpg-sign', '-m', 'initial'), - ) + git_commit('-m', 'initial') assert b'120000 ' in subprocess.check_output( ('git', 'cat-file', '-p', 'HEAD^{tree}'), ) diff --git a/tests/detect_aws_credentials_test.py b/tests/detect_aws_credentials_test.py index 4f00744e..72125099 100644 --- a/tests/detect_aws_credentials_test.py +++ b/tests/detect_aws_credentials_test.py @@ -13,15 +13,15 @@ ('env_vars', 'values'), ( ({}, set()), - ({'AWS_DUMMY_KEY': '/foo'}, set()), + ({'AWS_PLACEHOLDER_KEY': '/foo'}, set()), ({'AWS_CONFIG_FILE': '/foo'}, {'/foo'}), ({'AWS_CREDENTIAL_FILE': '/foo'}, {'/foo'}), ({'AWS_SHARED_CREDENTIALS_FILE': '/foo'}, {'/foo'}), ({'BOTO_CONFIG': '/foo'}, {'/foo'}), - ({'AWS_DUMMY_KEY': '/foo', 'AWS_CONFIG_FILE': '/bar'}, {'/bar'}), + ({'AWS_PLACEHOLDER_KEY': '/foo', 'AWS_CONFIG_FILE': '/bar'}, {'/bar'}), ( { - 'AWS_DUMMY_KEY': '/foo', 'AWS_CONFIG_FILE': '/bar', + 'AWS_PLACEHOLDER_KEY': '/foo', 'AWS_CONFIG_FILE': '/bar', 'AWS_CREDENTIAL_FILE': '/baz', }, {'/bar', '/baz'}, @@ -44,13 +44,16 @@ def test_get_aws_credentials_file_from_env(env_vars, values): ('env_vars', 'values'), ( ({}, set()), - ({'AWS_DUMMY_KEY': 'foo'}, set()), + ({'AWS_PLACEHOLDER_KEY': 'foo'}, set()), ({'AWS_SECRET_ACCESS_KEY': 'foo'}, {'foo'}), ({'AWS_SECURITY_TOKEN': 'foo'}, {'foo'}), ({'AWS_SESSION_TOKEN': 'foo'}, {'foo'}), ({'AWS_SESSION_TOKEN': ''}, set()), ({'AWS_SESSION_TOKEN': 'foo', 'AWS_SECURITY_TOKEN': ''}, {'foo'}), - ({'AWS_DUMMY_KEY': 'foo', 'AWS_SECRET_ACCESS_KEY': 'bar'}, {'bar'}), + ( + {'AWS_PLACEHOLDER_KEY': 'foo', 'AWS_SECRET_ACCESS_KEY': 'bar'}, + {'bar'}, + ), ( {'AWS_SECRET_ACCESS_KEY': 'foo', 'AWS_SECURITY_TOKEN': 'bar'}, {'foo', 'bar'}, diff --git a/tests/detect_private_key_test.py b/tests/detect_private_key_test.py index 72810008..d2c724f0 100644 --- a/tests/detect_private_key_test.py +++ b/tests/detect_private_key_test.py @@ -10,6 +10,8 @@ (b'-----BEGIN OPENSSH PRIVATE KEY-----', 1), (b'PuTTY-User-Key-File-2: ssh-rsa', 1), (b'---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----', 1), + (b'-----BEGIN ENCRYPTED PRIVATE KEY-----', 1), + (b'-----BEGIN OpenVPN Static key V1-----', 1), (b'ssh-rsa DATA', 0), (b'ssh-dsa DATA', 0), # Some arbitrary binary data diff --git a/tests/forbid_new_submodules_test.py b/tests/forbid_new_submodules_test.py index 4871ae7f..0326d941 100644 --- a/tests/forbid_new_submodules_test.py +++ b/tests/forbid_new_submodules_test.py @@ -1,22 +1,20 @@ +import os import subprocess +from unittest import mock import pytest from pre_commit_hooks.forbid_new_submodules import main +from testing.util import git_commit @pytest.fixture def git_dir_with_git_dir(tmpdir): with tmpdir.as_cwd(): subprocess.check_call(('git', 'init', '.')) - subprocess.check_call(( - 'git', 'commit', '-m', 'init', '--allow-empty', '--no-gpg-sign', - )) + git_commit('--allow-empty', '-m', 'init') subprocess.check_call(('git', 'init', 'foo')) - subprocess.check_call( - ('git', 'commit', '-m', 'init', '--allow-empty', '--no-gpg-sign'), - cwd=str(tmpdir.join('foo')), - ) + git_commit('--allow-empty', '-m', 'init', cwd=str(tmpdir.join('foo'))) yield @@ -31,7 +29,24 @@ def git_dir_with_git_dir(tmpdir): ) def test_main_new_submodule(git_dir_with_git_dir, capsys, cmd): subprocess.check_call(cmd) - assert main() == 1 + assert main(('random_non-related_file',)) == 0 + assert main(('foo',)) == 1 + out, _ = capsys.readouterr() + assert out.startswith('foo: new submodule introduced\n') + + +def test_main_new_submodule_committed(git_dir_with_git_dir, capsys): + rev_parse_cmd = ('git', 'rev-parse', 'HEAD') + from_ref = subprocess.check_output(rev_parse_cmd).decode().strip() + subprocess.check_call(('git', 'submodule', 'add', './foo')) + git_commit('-m', 'new submodule') + to_ref = subprocess.check_output(rev_parse_cmd).decode().strip() + with mock.patch.dict( + os.environ, + {'PRE_COMMIT_FROM_REF': from_ref, 'PRE_COMMIT_TO_REF': to_ref}, + ): + assert main(('random_non-related_file',)) == 0 + assert main(('foo',)) == 1 out, _ = capsys.readouterr() assert out.startswith('foo: new submodule introduced\n') @@ -39,4 +54,4 @@ def test_main_new_submodule(git_dir_with_git_dir, capsys, cmd): def test_main_no_new_submodule(git_dir_with_git_dir): open('test.py', 'a+').close() subprocess.check_call(('git', 'add', 'test.py')) - assert main() == 0 + assert main(('test.py',)) == 0 diff --git a/tests/no_commit_to_branch_test.py b/tests/no_commit_to_branch_test.py index 72b32e64..9fcb5808 100644 --- a/tests/no_commit_to_branch_test.py +++ b/tests/no_commit_to_branch_test.py @@ -3,6 +3,7 @@ from pre_commit_hooks.no_commit_to_branch import is_on_branch from pre_commit_hooks.no_commit_to_branch import main from pre_commit_hooks.util import cmd_output +from testing.util import git_commit def test_other_branch(temp_git_dir): @@ -62,8 +63,15 @@ def test_main_default_call(temp_git_dir): def test_not_on_a_branch(temp_git_dir): with temp_git_dir.as_cwd(): - cmd_output('git', 'commit', '--no-gpg-sign', '--allow-empty', '-m1') + git_commit('--allow-empty', '-m1') head = cmd_output('git', 'rev-parse', 'HEAD').strip() cmd_output('git', 'checkout', head) # we're not on a branch! assert main(()) == 0 + + +@pytest.mark.parametrize('branch_name', ('master', 'main')) +def test_default_branch_names(temp_git_dir, branch_name): + with temp_git_dir.as_cwd(): + cmd_output('git', 'checkout', '-b', branch_name) + assert main(()) == 1 diff --git a/tests/requirements_txt_fixer_test.py b/tests/requirements_txt_fixer_test.py index f4f679da..e3c6ed50 100644 --- a/tests/requirements_txt_fixer_test.py +++ b/tests/requirements_txt_fixer_test.py @@ -30,6 +30,16 @@ ), (b'#comment\n\nfoo\nbar\n', FAIL, b'#comment\n\nbar\nfoo\n'), (b'#comment\n\nbar\nfoo\n', PASS, b'#comment\n\nbar\nfoo\n'), + ( + b'foo\n\t#comment with indent\nbar\n', + FAIL, + b'\t#comment with indent\nbar\nfoo\n', + ), + ( + b'bar\n\t#comment with indent\nfoo\n', + PASS, + b'bar\n\t#comment with indent\nfoo\n', + ), (b'\nfoo\nbar\n', FAIL, b'bar\n\nfoo\n'), (b'\nbar\nfoo\n', PASS, b'\nbar\nfoo\n'), (