diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d42bb1b11..214c2857e 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.3.0 + rev: v3.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -11,40 +11,40 @@ repos: - id: name-tests-test - id: requirements-txt-fixer - id: double-quote-string-fixer -- repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 +- repo: https://github.com/PyCQA/flake8 + rev: 3.9.0 hooks: - id: flake8 additional_dependencies: [flake8-typing-imports==1.10.0] - repo: https://github.com/pre-commit/mirrors-autopep8 - rev: v1.5.4 + rev: v1.5.6 hooks: - id: autopep8 - repo: https://github.com/pre-commit/pre-commit - rev: v2.9.3 + rev: v2.12.1 hooks: - id: validate_manifest - repo: https://github.com/asottile/pyupgrade - rev: v2.7.4 + rev: v2.12.0 hooks: - id: pyupgrade args: [--py36-plus] - repo: https://github.com/asottile/reorder_python_imports - rev: v2.3.6 + rev: v2.4.0 hooks: - id: reorder-python-imports args: [--py3-plus] - repo: https://github.com/asottile/add-trailing-comma - rev: v2.0.1 + rev: v2.1.0 hooks: - id: add-trailing-comma args: [--py36-plus] - repo: https://github.com/asottile/setup-cfg-fmt - rev: v1.15.1 + rev: v1.17.0 hooks: - id: setup-cfg-fmt - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.790 + rev: v0.812 hooks: - id: mypy exclude: ^testing/resources/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ef36decce..2f154c447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,86 @@ +2.12.1 - 2021-04-16 +=================== + +### Fixes +- Fix race condition when stashing files in multiple parallel invocations + - #1881 PR by @adamchainz. + - #1880 issue by @adamchainz. + +2.12.0 - 2021-04-06 +=================== + +### Features +- Upgrade rbenv. + - #1854 PR by @asottile. + - #1848 issue by @sirosen. + +### Fixes +- Give command length a little more room when running batch files on windows + so underlying commands can expand further. + - #1864 PR by @asottile. + - pre-commit/mirrors-prettier#7 issue by @DeltaXWizard. +- Fix permissions of root folder in ruby archives. + - #1868 PR by @asottile. + +2.11.1 - 2021-03-09 +=================== + +### Fixes +- Fix r hooks when hook repo is a package + - #1831 PR by @lorenzwalthert. + +2.11.0 - 2021-03-07 +=================== + +### Features +- Improve warning for mutable ref. + - #1809 PR by @JamMarHer. +- Add support for `post-merge` hook. + - #1800 PR by @psacawa. + - #1762 issue by @psacawa. +- Add `r` as a supported hook language. + - #1799 PR by @lorenzwalthert. + +### Fixes +- Fix `pre-commit install` on `subst` / network drives on windows. + - #1814 PR by @asottile. + - #1802 issue by @goroderickgo. +- Fix installation of `local` golang repositories for go 1.16. + - #1818 PR by @rafikdraoui. + - #1815 issue by @rafikdraoui. + +2.10.1 - 2021-02-06 +=================== + +### Fixes +- Fix `language: golang` repositories containing recursive submodules + - #1788 issue by @gaurav517. + - #1789 PR by @paulhfischer. + +2.10.0 - 2021-01-27 +=================== + +### Features +- Allow `ci` as a top-level map for configuration for https://pre-commit.ci + - #1735 PR by @asottile. +- Add warning for mutable `rev` in configuration + - #1715 PR by @paulhfischer. + - #974 issue by @asottile. +- Add warning for `/*` in top-level `files` / `exclude` regexes + - #1750 PR by @paulhfischer. + - #1702 issue by @asottile. +- Expose `PRE_COMMIT_REMOTE_BRANCH` environment variable during `pre-push` + hooks + - #1770 PR by @surafelabebe. +- Produce error message for `language` / `language_version` for non-installable + languages + - #1771 PR by @asottile. + +### Fixes +- Fix execution in worktrees in subdirectories of bare repositories + - #1778 PR by @asottile. + - #1777 issue by @s0undt3ch. + 2.9.3 - 2020-12-07 ================== @@ -8,7 +91,7 @@ - Fix cleanup code on docker volumes for go - #1725 PR by @fsouza. - Fix working directory detection on SUBST drives on windows - - #1727 PR by mrogaski. + - #1727 PR by @mrogaski. - #1610 issue by @jcameron73. 2.9.2 - 2020-11-25 diff --git a/azure-pipelines.yml b/azure-pipelines.yml index e7256da18..58dee74a8 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/v2.0.0 + ref: refs/tags/v2.1.0 jobs: - template: job--python-tox.yml@asottile @@ -26,6 +26,10 @@ jobs: 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 + - task: PowerShell@2 + inputs: + filePath: "testing/get-r.ps1" + displayName: install R - template: job--python-tox.yml@asottile parameters: toxenvs: [py37] @@ -42,6 +46,8 @@ jobs: testing/get-swift.sh echo '##vso[task.prependpath]/tmp/swift/usr/bin' displayName: install swift + - bash: testing/get-r.sh + displayName: install R - template: job--python-tox.yml@asottile parameters: toxenvs: [pypy3, py36, py37, py38, py39] @@ -56,3 +62,5 @@ jobs: testing/get-swift.sh echo '##vso[task.prependpath]/tmp/swift/usr/bin' displayName: install swift + - bash: testing/get-r.sh + displayName: install R diff --git a/pre_commit/clientlib.py b/pre_commit/clientlib.py index 20d44925a..962c7fa8f 100644 --- a/pre_commit/clientlib.py +++ b/pre_commit/clientlib.py @@ -1,6 +1,7 @@ import argparse import functools import logging +import re import shlex import sys from typing import Any @@ -112,7 +113,27 @@ def validate_manifest_main(argv: Optional[Sequence[str]] = None) -> int: META = 'meta' -class OptionalSensibleRegex(cfgv.OptionalNoDefault): +# should inherit from cfgv.Conditional if sha support is dropped +class WarnMutableRev(cfgv.ConditionalOptional): + def check(self, dct: Dict[str, Any]) -> None: + super().check(dct) + + if self.key in dct: + rev = dct[self.key] + + if '.' not in rev and not re.match(r'^[a-fA-F0-9]+$', rev): + logger.warning( + f'The {self.key!r} field of repo {dct["repo"]!r} ' + f'appears to be a mutable reference ' + f'(moving tag / branch). Mutable references are never ' + f'updated after first install and are not supported. ' + f'See https://pre-commit.com/#using-the-latest-version-for-a-repository ' # noqa: E501 + f'for more details. ' + f'Hint: `pre-commit autoupdate` often fixes this.', + ) + + +class OptionalSensibleRegexAtHook(cfgv.OptionalNoDefault): def check(self, dct: Dict[str, Any]) -> None: super().check(dct) @@ -124,6 +145,17 @@ def check(self, dct: Dict[str, Any]) -> None: ) +class OptionalSensibleRegexAtTop(cfgv.OptionalNoDefault): + def check(self, dct: Dict[str, Any]) -> None: + super().check(dct) + + if '/*' in dct.get(self.key, ''): + logger.warning( + f'The top-level {self.key!r} field is a regex, not a glob -- ' + f"matching '/*' probably isn't what you want here", + ) + + class MigrateShaToRev: key = 'rev' @@ -239,8 +271,8 @@ def warn_unknown_keys_repo( for item in MANIFEST_HOOK_DICT.items if item.key != 'id' ), - OptionalSensibleRegex('files', cfgv.check_string), - OptionalSensibleRegex('exclude', cfgv.check_string), + OptionalSensibleRegexAtHook('files', cfgv.check_string), + OptionalSensibleRegexAtHook('exclude', cfgv.check_string), ) CONFIG_REPO_DICT = cfgv.Map( 'Repository', 'repo', @@ -261,6 +293,14 @@ def warn_unknown_keys_repo( ), MigrateShaToRev(), + WarnMutableRev( + 'rev', + cfgv.check_string, + '', + 'repo', + cfgv.NotIn(LOCAL, META), + True, + ), cfgv.WarnAdditionalKeys(('repo', 'rev', 'hooks'), warn_unknown_keys_repo), ) DEFAULT_LANGUAGE_VERSION = cfgv.Map( @@ -297,9 +337,15 @@ def warn_unknown_keys_repo( 'exclude', 'fail_fast', 'minimum_pre_commit_version', + 'ci', ), warn_unknown_keys_root, ), + OptionalSensibleRegexAtTop('files', cfgv.check_string), + OptionalSensibleRegexAtTop('exclude', cfgv.check_string), + + # do not warn about configuration for pre-commit.ci + cfgv.OptionalNoDefault('ci', cfgv.check_type(dict)), ) diff --git a/pre_commit/commands/hook_impl.py b/pre_commit/commands/hook_impl.py index d0e226f8c..a766ee9d6 100644 --- a/pre_commit/commands/hook_impl.py +++ b/pre_commit/commands/hook_impl.py @@ -69,16 +69,19 @@ def _ns( color: bool, *, all_files: bool = False, + remote_branch: Optional[str] = None, 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, + is_squash_merge: Optional[str] = None, ) -> argparse.Namespace: return argparse.Namespace( color=color, hook_stage=hook_type.replace('pre-', ''), + remote_branch=remote_branch, from_ref=from_ref, to_ref=to_ref, remote_name=remote_name, @@ -86,6 +89,7 @@ def _ns( commit_msg_filename=commit_msg_filename, all_files=all_files, checkout_type=checkout_type, + is_squash_merge=is_squash_merge, files=(), hook=None, verbose=False, @@ -106,13 +110,14 @@ def _pre_push_ns( remote_url = args[1] for line in stdin.decode().splitlines(): - _, local_sha, _, remote_sha = line.split() + _, local_sha, remote_branch, 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_branch=remote_branch, remote_name=remote_name, remote_url=remote_url, ) else: @@ -133,6 +138,7 @@ def _pre_push_ns( 'pre-push', color, all_files=True, remote_name=remote_name, remote_url=remote_url, + remote_branch=remote_branch, ) else: rev_cmd = ('git', 'rev-parse', f'{first_ancestor}^') @@ -141,6 +147,7 @@ def _pre_push_ns( 'pre-push', color, from_ref=source, to_ref=local_sha, remote_name=remote_name, remote_url=remote_url, + remote_branch=remote_branch, ) # nothing to push @@ -153,6 +160,7 @@ def _pre_push_ns( 'post-commit': 0, 'pre-commit': 0, 'pre-merge-commit': 0, + 'post-merge': 1, 'pre-push': 2, } @@ -194,6 +202,8 @@ def _run_ns( hook_type, color, from_ref=args[0], to_ref=args[1], checkout_type=args[2], ) + elif hook_type == 'post-merge': + return _ns(hook_type, color, is_squash_merge=args[0]) else: raise AssertionError(f'unexpected hook type: {hook_type}') diff --git a/pre_commit/commands/run.py b/pre_commit/commands/run.py index 1e8fad23b..05c3268e3 100644 --- a/pre_commit/commands/run.py +++ b/pre_commit/commands/run.py @@ -245,7 +245,7 @@ def _compute_cols(hooks: Sequence[Hook]) -> int: def _all_filenames(args: argparse.Namespace) -> Collection[str]: # these hooks do not operate on files - if args.hook_stage in {'post-checkout', 'post-commit'}: + if args.hook_stage in {'post-checkout', 'post-commit', 'post-merge'}: return () elif args.hook_stage in {'prepare-commit-msg', 'commit-msg'}: return (args.commit_msg_filename,) @@ -371,13 +371,17 @@ def run( environ['PRE_COMMIT_FROM_REF'] = args.from_ref environ['PRE_COMMIT_TO_REF'] = args.to_ref - if args.remote_name and args.remote_url: + if args.remote_name and args.remote_url and args.remote_branch: + environ['PRE_COMMIT_REMOTE_BRANCH'] = args.remote_branch 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 + if args.is_squash_merge: + environ['PRE_COMMIT_IS_SQUASH_MERGE'] = args.is_squash_merge + # Set pre_commit flag environ['PRE_COMMIT'] = '1' diff --git a/pre_commit/constants.py b/pre_commit/constants.py index 5150fdcf4..3dcbbaca3 100644 --- a/pre_commit/constants.py +++ b/pre_commit/constants.py @@ -18,7 +18,7 @@ # `manual` is not invoked by any installed git hook. See #719 STAGES = ( 'commit', 'merge-commit', 'prepare-commit-msg', 'commit-msg', - 'post-commit', 'manual', 'post-checkout', 'push', + 'post-commit', 'manual', 'post-checkout', 'push', 'post-merge', ) DEFAULT = 'default' diff --git a/pre_commit/file_lock.py b/pre_commit/file_lock.py index 5e7a05862..55a8eb29c 100644 --- a/pre_commit/file_lock.py +++ b/pre_commit/file_lock.py @@ -1,11 +1,11 @@ import contextlib import errno -import os +import sys from typing import Callable from typing import Generator -if os.name == 'nt': # pragma: no cover (windows) +if sys.platform == 'win32': # pragma: no cover (windows) import msvcrt # https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/locking diff --git a/pre_commit/git.py b/pre_commit/git.py index 50962745b..4bf282357 100644 --- a/pre_commit/git.py +++ b/pre_commit/git.py @@ -52,16 +52,16 @@ def get_root() -> str: # "rev-parse --show-cdup" to get the appropriate path, but must perform # an extra check to see if we are in the .git directory. try: - root = os.path.realpath( + root = os.path.abspath( cmd_output('git', 'rev-parse', '--show-cdup')[1].strip(), ) - git_dir = os.path.realpath(get_git_dir()) + git_dir = os.path.abspath(get_git_dir()) except CalledProcessError: raise FatalError( 'git failed. Is it installed, and are you in a Git repository ' 'directory?', ) - if os.path.commonpath((root, git_dir)) == git_dir: + if os.path.samefile(root, git_dir): raise FatalError( 'git toplevel unexpectedly empty! make sure you are not ' 'inside the `.git` directory of your repository.', diff --git a/pre_commit/languages/all.py b/pre_commit/languages/all.py index 9c2e59d78..fde6000cb 100644 --- a/pre_commit/languages/all.py +++ b/pre_commit/languages/all.py @@ -16,6 +16,7 @@ from pre_commit.languages import perl from pre_commit.languages import pygrep from pre_commit.languages import python +from pre_commit.languages import r from pre_commit.languages import ruby from pre_commit.languages import rust from pre_commit.languages import script @@ -52,6 +53,7 @@ class Language(NamedTuple): '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 + 'r': Language(name='r', ENVIRONMENT_DIR=r.ENVIRONMENT_DIR, get_default_version=r.get_default_version, healthy=r.healthy, install_environment=r.install_environment, run_hook=r.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 diff --git a/pre_commit/languages/golang.py b/pre_commit/languages/golang.py index 91ade1e99..d6165d95e 100644 --- a/pre_commit/languages/golang.py +++ b/pre_commit/languages/golang.py @@ -69,7 +69,8 @@ def install_environment( 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)) + cmd = ('git', 'clone', '--recursive', '.', repo_src_dir) + helpers.run_setup_cmd(prefix, cmd) if sys.platform == 'cygwin': # pragma: no cover _, gopath, _ = cmd_output('cygpath', '-w', directory) diff --git a/pre_commit/languages/r.py b/pre_commit/languages/r.py new file mode 100644 index 000000000..83e600094 --- /dev/null +++ b/pre_commit/languages/r.py @@ -0,0 +1,139 @@ +import contextlib +import os +import shlex +import shutil +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.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 = 'renv' +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy + + +def get_env_patch(venv: str) -> PatchesT: + return ( + ('R_PROFILE_USER', os.path.join(venv, 'activate.R')), + ) + + +@contextlib.contextmanager +def in_env( + prefix: Prefix, + language_version: str, +) -> Generator[None, None, None]: + envdir = _get_env_dir(prefix, language_version) + with envcontext(get_env_patch(envdir)): + yield + + +def _get_env_dir(prefix: Prefix, version: str) -> str: + return prefix.path(helpers.environment_dir(ENVIRONMENT_DIR, version)) + + +def _prefix_if_file_entry( + entry: Sequence[str], + prefix: Prefix, +) -> Sequence[str]: + if entry[1] == '-e': + return entry[1:] + else: + return (prefix.path(entry[1]),) + + +def _entry_validate(entry: Sequence[str]) -> None: + """ + Allowed entries: + # Rscript -e expr + # Rscript path/to/file + """ + if entry[0] != 'Rscript': + raise ValueError('entry must start with `Rscript`.') + + if entry[1] == '-e': + if len(entry) > 3: + raise ValueError('You can supply at most one expression.') + elif len(entry) > 2: + raise ValueError( + 'The only valid syntax is `Rscript -e {expr}`', + 'or `Rscript path/to/hook/script`', + ) + + +def _cmd_from_hook(hook: Hook) -> Tuple[str, ...]: + opts = ('--no-save', '--no-restore', '--no-site-file', '--no-environ') + entry = shlex.split(hook.entry) + _entry_validate(entry) + + return ( + *entry[:1], *opts, + *_prefix_if_file_entry(entry, hook.prefix), + *hook.args, + ) + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + env_dir = _get_env_dir(prefix, version) + with clean_path_on_failure(env_dir): + os.makedirs(env_dir, exist_ok=True) + shutil.copy(prefix.path('renv.lock'), env_dir) + cmd_output_b( + 'Rscript', '--vanilla', '-e', + f"""\ + prefix_dir <- {prefix.prefix_dir!r} + missing_pkgs <- setdiff( + "renv", unname(installed.packages()[, "Package"]) + ) + options( + repos = c(CRAN = "https://cran.rstudio.com"), + renv.consent = TRUE + ) + install.packages(missing_pkgs) + renv::activate() + renv::restore() + activate_statement <- paste0( + 'renv::activate("', file.path(getwd()), '"); ' + ) + writeLines(activate_statement, 'activate.R') + is_package <- tryCatch({{ + content_desc <- read.dcf(file.path(prefix_dir, 'DESCRIPTION')) + suppressWarnings(unname(content_desc[,'Type']) == "Package") + }}, + error = function(...) FALSE + ) + if (is_package) {{ + renv::install(prefix_dir) + }} + """, + cwd=env_dir, + ) + if additional_dependencies: + cmd_output_b( + 'Rscript', '-e', + 'renv::install(commandArgs(trailingOnly = TRUE))', + *additional_dependencies, + cwd=env_dir, + ) + + +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, _cmd_from_hook(hook), file_args, color=color, + ) diff --git a/pre_commit/main.py b/pre_commit/main.py index c1eb104ac..c66cfb9a4 100644 --- a/pre_commit/main.py +++ b/pre_commit/main.py @@ -67,8 +67,8 @@ def __call__( 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-commit', 'post-checkout', + 'pre-commit', 'pre-merge-commit', 'pre-push', 'prepare-commit-msg', + 'commit-msg', 'post-commit', 'post-checkout', 'post-merge', ), action=AppendReplaceDefault, default=['pre-commit'], @@ -96,6 +96,9 @@ def _add_run_options(parser: argparse.ArgumentParser) -> None: '--hook-stage', choices=C.STAGES, default='commit', help='The stage during which the hook is fired. One of %(choices)s', ) + parser.add_argument( + '--remote-branch', help='Remote branch ref used by `git push`.', + ) parser.add_argument( '--from-ref', '--source', '-s', help=( @@ -133,6 +136,13 @@ def _add_run_options(parser: argparse.ArgumentParser) -> None: 'file from the index, flag=0).' ), ) + parser.add_argument( + '--is-squash-merge', + help=( + 'During a post-merge hook, indicates whether the merge was a ' + 'squash merge' + ), + ) def _adjust_args_and_chdir(args: argparse.Namespace) -> None: diff --git a/pre_commit/repository.py b/pre_commit/repository.py index 46e96c1dc..15827dde4 100644 --- a/pre_commit/repository.py +++ b/pre_commit/repository.py @@ -118,6 +118,24 @@ def _hook( if not ret['stages']: ret['stages'] = root_config['default_stages'] + if languages[lang].ENVIRONMENT_DIR is None: + if ret['language_version'] != C.DEFAULT: + logger.error( + f'The hook `{ret["id"]}` specifies `language_version` but is ' + f'using language `{lang}` which does not install an ' + f'environment. ' + f'Perhaps you meant to use a specific language?', + ) + exit(1) + if ret['additional_dependencies']: + logger.error( + f'The hook `{ret["id"]}` specifies `additional_dependencies` ' + f'but is using language `{lang}` which does not install an ' + f'environment. ' + f'Perhaps you meant to use a specific language?', + ) + exit(1) + return ret diff --git a/pre_commit/resources/empty_template_go.mod b/pre_commit/resources/empty_template_go.mod index e69de29bb..de3e24157 100644 --- a/pre_commit/resources/empty_template_go.mod +++ b/pre_commit/resources/empty_template_go.mod @@ -0,0 +1 @@ +module pre-commit-dummy-empty-module diff --git a/pre_commit/resources/empty_template_renv.lock b/pre_commit/resources/empty_template_renv.lock new file mode 100644 index 000000000..d6e31f86c --- /dev/null +++ b/pre_commit/resources/empty_template_renv.lock @@ -0,0 +1,20 @@ +{ + "R": { + "Version": "4.0.3", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cran.rstudio.com" + } + ] + }, + "Packages": { + "renv": { + "Package": "renv", + "Version": "0.12.5", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "5c0cdb37f063c58cdab3c7e9fbb8bd2c" + } + } +} diff --git a/pre_commit/resources/rbenv.tar.gz b/pre_commit/resources/rbenv.tar.gz index 97ac469a7..95b5a364d 100644 Binary files a/pre_commit/resources/rbenv.tar.gz and b/pre_commit/resources/rbenv.tar.gz differ diff --git a/pre_commit/resources/ruby-build.tar.gz b/pre_commit/resources/ruby-build.tar.gz index 4412ed40f..ef82e2f5f 100644 Binary files a/pre_commit/resources/ruby-build.tar.gz and b/pre_commit/resources/ruby-build.tar.gz differ diff --git a/pre_commit/resources/ruby-download.tar.gz b/pre_commit/resources/ruby-download.tar.gz index 7ccfb6c81..2e195077b 100644 Binary files a/pre_commit/resources/ruby-download.tar.gz and b/pre_commit/resources/ruby-download.tar.gz differ diff --git a/pre_commit/staged_files_only.py b/pre_commit/staged_files_only.py index 617930102..48cc10299 100644 --- a/pre_commit/staged_files_only.py +++ b/pre_commit/staged_files_only.py @@ -47,7 +47,7 @@ def _unstaged_changes_cleared(patch_dir: str) -> Generator[None, None, None]: retcode=None, ) if retcode and diff_stdout_binary.strip(): - patch_filename = f'patch{int(time.time())}' + patch_filename = f'patch{int(time.time())}-{os.getpid()}' patch_filename = os.path.join(patch_dir, patch_filename) logger.warning('Unstaged files detected.') logger.info(f'Stashing unstaged files to {patch_filename}.') diff --git a/pre_commit/store.py b/pre_commit/store.py index e5522ec33..187c9d353 100644 --- a/pre_commit/store.py +++ b/pre_commit/store.py @@ -189,7 +189,7 @@ def _git_cmd(*args: str) -> None: LOCAL_RESOURCES = ( 'Cargo.toml', 'main.go', 'go.mod', 'main.rs', '.npmignore', 'package.json', 'pre_commit_dummy_package.gemspec', 'setup.py', - 'environment.yml', 'Makefile.PL', + 'environment.yml', 'Makefile.PL', 'renv.lock', ) def make_local(self, deps: Sequence[str]) -> str: diff --git a/pre_commit/xargs.py b/pre_commit/xargs.py index 60a057c19..6b0fa2086 100644 --- a/pre_commit/xargs.py +++ b/pre_commit/xargs.py @@ -145,7 +145,9 @@ def xargs( # this is implementation details but the command gets translated into # full/path/to/cmd.exe /c *cmd cmd_exe = parse_shebang.find_executable('cmd.exe') - _max_length = 8192 - len(cmd_exe) - len(' /c ') + # 1024 is additionally subtracted to give headroom for further + # expansion inside the batch file + _max_length = 8192 - len(cmd_exe) - len(' /c ') - 1024 partitions = partition(cmd, varargs, target_concurrency, _max_length) diff --git a/setup.cfg b/setup.cfg index 2e77fcf4e..400299870 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pre_commit -version = 2.9.3 +version = 2.12.1 description = A framework for managing and maintaining multi-language pre-commit hooks. long_description = file: README.md long_description_content_type = text/markdown @@ -33,6 +33,11 @@ install_requires = importlib-resources;python_version<"3.7" python_requires = >=3.6.1 +[options.packages.find] +exclude = + tests* + testing* + [options.entry_points] console_scripts = pre-commit = pre_commit.main:main @@ -45,11 +50,6 @@ pre_commit.resources = empty_template_* hook-tmpl -[options.packages.find] -exclude = - tests* - testing* - [bdist_wheel] universal = True diff --git a/testing/gen-languages-all b/testing/gen-languages-all index d9b01bd04..eb7cd701e 100755 --- a/testing/gen-languages-all +++ b/testing/gen-languages-all @@ -2,9 +2,9 @@ import sys LANGUAGES = [ - 'conda', 'coursier', 'docker', 'dotnet', 'docker_image', 'fail', 'golang', - 'node', 'perl', 'pygrep', 'python', 'ruby', 'rust', 'script', 'swift', - 'system', + 'conda', 'coursier', 'docker', 'docker_image', 'dotnet', 'fail', 'golang', + 'node', 'perl', 'pygrep', 'python', 'r', 'ruby', 'rust', 'script', + 'swift', 'system', ] FIELDS = [ 'ENVIRONMENT_DIR', 'get_default_version', 'healthy', 'install_environment', diff --git a/testing/get-r.ps1 b/testing/get-r.ps1 new file mode 100644 index 000000000..e7b7b6195 --- /dev/null +++ b/testing/get-r.ps1 @@ -0,0 +1,6 @@ +$dir = $Env:Temp +$urlR = "https://cran.r-project.org/bin/windows/base/old/4.0.4/R-4.0.4-win.exe" +$outputR = "$dir\R-win.exe" +$wcR = New-Object System.Net.WebClient +$wcR.DownloadFile($urlR, $outputR) +Start-Process -FilePath $outputR -ArgumentList "/S /v/qn" diff --git a/testing/get-r.sh b/testing/get-r.sh new file mode 100755 index 000000000..5d09828e4 --- /dev/null +++ b/testing/get-r.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +sudo apt install r-base +# create empty folder for user library. +# necessary for non-root users who have +# never installed an R package before. +# Alternatively, we require the renv +# package to be installed already, then we can +# omit that. +Rscript -e 'dir.create(Sys.getenv("R_LIBS_USER"), recursive = TRUE)' diff --git a/pre_commit/make_archives.py b/testing/make-archives old mode 100644 new mode 100755 similarity index 72% rename from pre_commit/make_archives.py rename to testing/make-archives index d320b830d..b2b288cfa --- a/pre_commit/make_archives.py +++ b/testing/make-archives @@ -1,14 +1,13 @@ +#!/usr/bin/env python3 import argparse import os.path +import shutil +import subprocess import tarfile +import tempfile 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. @@ -16,7 +15,7 @@ REPOS = ( ('rbenv', 'git://github.com/rbenv/rbenv', '0843745'), - ('ruby-build', 'git://github.com/rbenv/ruby-build', '258455e'), + ('ruby-build', 'git://github.com/rbenv/ruby-build', '500863c'), ( 'ruby-download', 'git://github.com/garnieretienne/rvm-download', @@ -35,18 +34,21 @@ def make_archive(name: str, repo: str, ref: str, destdir: str) -> str: :param text destdir: Directory to place archives in. """ output_path = os.path.join(destdir, f'{name}.tar.gz') - with tmpdir() as tempdir: + with tempfile.TemporaryDirectory() as tmpdir: + # this ensures that the root directory has umask permissions + gitdir = os.path.join(tmpdir, 'root') + # Clone the repository to the temporary directory - cmd_output_b('git', 'clone', repo, tempdir) - cmd_output_b('git', 'checkout', ref, cwd=tempdir) + subprocess.check_call(('git', 'clone', repo, gitdir)) + subprocess.check_call(('git', '-C', gitdir, 'checkout', ref)) # 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')) + shutil.rmtree(os.path.join(gitdir, '.git')) with tarfile.open(output_path, 'w|gz') as tf: - tf.add(tempdir, name) + tf.add(gitdir, name) return output_path @@ -56,7 +58,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: 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}') + print(f'Making {archive_name}.tar.gz for {repo}@{ref}') make_archive(archive_name, repo, ref, args.dest) return 0 diff --git a/testing/resources/golang_hooks_repo/go.mod b/testing/resources/golang_hooks_repo/go.mod new file mode 100644 index 000000000..523bfc9f5 --- /dev/null +++ b/testing/resources/golang_hooks_repo/go.mod @@ -0,0 +1 @@ +module golang-hello-world diff --git a/testing/resources/r_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/r_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 000000000..b3545d969 --- /dev/null +++ b/testing/resources/r_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,48 @@ +# parsing file +- id: parse-file-no-opts-no-args + name: Say hi + entry: Rscript parse-file-no-opts-no-args.R + language: r + types: [r] +- id: parse-file-no-opts-args + name: Say hi + entry: Rscript parse-file-no-opts-args.R + args: [--no-cache] + language: r + types: [r] +## parsing expr +- id: parse-expr-no-opts-no-args-1 + name: Say hi + entry: Rscript -e '1+1' + language: r + types: [r] +- id: parse-expr-args-in-entry-2 + name: Say hi + entry: Rscript -e '1+1' -e '3' --no-cache3 + language: r + types: [r] +# real world +- id: hello-world + name: Say hi + entry: Rscript hello-world.R + args: [blibla] + language: r + types: [r] +- id: hello-world-inline + name: Say hi + entry: | + Rscript -e + 'stopifnot( + packageVersion("rprojroot") == "1.0", + packageVersion("gli.clu") == "0.0.0.9000" + ) + cat(commandArgs(trailingOnly = TRUE), "from R!\n", sep = ", ") + ' + args: ['Hi-there'] + language: r + types: [r] +- id: additional-deps + name: Check additional deps + entry: Rscript additional-deps.R + language: r + types: [r] diff --git a/testing/resources/r_hooks_repo/DESCRIPTION b/testing/resources/r_hooks_repo/DESCRIPTION new file mode 100644 index 000000000..0e597a8a6 --- /dev/null +++ b/testing/resources/r_hooks_repo/DESCRIPTION @@ -0,0 +1,19 @@ +Package: gli.clu +Title: What the Package Does (One Line, Title Case) +Type: Package +Version: 0.0.0.9000 +Authors@R: + person(given = "First", + family = "Last", + role = c("aut", "cre"), + email = "first.last@example.com", + comment = c(ORCID = "YOUR-ORCID-ID")) +Description: What the package does (one paragraph). +License: `use_mit_license()`, `use_gpl3_license()` or friends to + pick a license +Encoding: UTF-8 +LazyData: true +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.1.1 +Imports: + rprojroot diff --git a/testing/resources/r_hooks_repo/additional-deps.R b/testing/resources/r_hooks_repo/additional-deps.R new file mode 100755 index 000000000..bc145951b --- /dev/null +++ b/testing/resources/r_hooks_repo/additional-deps.R @@ -0,0 +1,2 @@ +suppressPackageStartupMessages(library("cachem")) +cat("OK\n") diff --git a/testing/resources/r_hooks_repo/hello-world.R b/testing/resources/r_hooks_repo/hello-world.R new file mode 100755 index 000000000..bf8d92f42 --- /dev/null +++ b/testing/resources/r_hooks_repo/hello-world.R @@ -0,0 +1,5 @@ +stopifnot( + packageVersion('rprojroot') == '1.0', + packageVersion('gli.clu') == '0.0.0.9000' +) +cat("Hello, World, from R!\n") diff --git a/testing/resources/r_hooks_repo/renv.lock b/testing/resources/r_hooks_repo/renv.lock new file mode 100644 index 000000000..d7d5fdcc9 --- /dev/null +++ b/testing/resources/r_hooks_repo/renv.lock @@ -0,0 +1,27 @@ +{ + "R": { + "Version": "4.0.3", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cloud.r-project.org" + } + ] + }, + "Packages": { + "renv": { + "Package": "renv", + "Version": "0.12.5", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "5c0cdb37f063c58cdab3c7e9fbb8bd2c" + }, + "rprojroot": { + "Package": "rprojroot", + "Version": "1.0", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "86704667fe0860e4fec35afdfec137f3" + } + } +} diff --git a/testing/util.py b/testing/util.py index 18cd73427..13644531d 100644 --- a/testing/util.py +++ b/testing/util.py @@ -61,6 +61,7 @@ def run_opts( color=False, verbose=False, hook=None, + remote_branch='', from_ref='', to_ref='', remote_name='', @@ -69,6 +70,7 @@ def run_opts( show_diff_on_failure=False, commit_msg_filename='', checkout_type='', + is_squash_merge='', ): # These are mutually exclusive assert not (all_files and files) @@ -78,6 +80,7 @@ def run_opts( color=color, verbose=verbose, hook=hook, + remote_branch=remote_branch, from_ref=from_ref, to_ref=to_ref, remote_name=remote_name, @@ -86,6 +89,7 @@ def run_opts( show_diff_on_failure=show_diff_on_failure, commit_msg_filename=commit_msg_filename, checkout_type=checkout_type, + is_squash_merge=is_squash_merge, ) diff --git a/tests/clientlib_test.py b/tests/clientlib_test.py index bfb754b66..ff3cce38d 100644 --- a/tests/clientlib_test.py +++ b/tests/clientlib_test.py @@ -166,7 +166,86 @@ def test_validate_warn_on_unknown_keys_at_top_level(tmpdir, caplog): ] -def test_validate_optional_sensible_regex(caplog): +def test_ci_map_key_allowed_at_top_level(caplog): + cfg = { + 'ci': {'skip': ['foo']}, + 'repos': [{'repo': 'meta', 'hooks': [{'id': 'identity'}]}], + } + cfgv.validate(cfg, CONFIG_SCHEMA) + assert not caplog.record_tuples + + +def test_ci_key_must_be_map(): + with pytest.raises(cfgv.ValidationError): + cfgv.validate({'ci': 'invalid', 'repos': []}, CONFIG_SCHEMA) + + +@pytest.mark.parametrize( + 'rev', + ( + 'v0.12.4', + 'b27f281', + 'b27f281eb9398fc8504415d7fbdabf119ea8c5e1', + '19.10b0', + '4.3.21-2', + ), +) +def test_warn_mutable_rev_ok(caplog, rev): + config_obj = { + 'repo': 'https://gitlab.com/pycqa/flake8', + 'rev': rev, + 'hooks': [{'id': 'flake8'}], + } + cfgv.validate(config_obj, CONFIG_REPO_DICT) + + assert caplog.record_tuples == [] + + +@pytest.mark.parametrize( + 'rev', + ( + '', + 'HEAD', + 'stable', + 'master', + 'some_branch_name', + ), +) +def test_warn_mutable_rev_invalid(caplog, rev): + config_obj = { + 'repo': 'https://gitlab.com/pycqa/flake8', + 'rev': rev, + 'hooks': [{'id': 'flake8'}], + } + cfgv.validate(config_obj, CONFIG_REPO_DICT) + + assert caplog.record_tuples == [ + ( + 'pre_commit', + logging.WARNING, + "The 'rev' field of repo 'https://gitlab.com/pycqa/flake8' " + 'appears to be a mutable reference (moving tag / branch). ' + 'Mutable references are never updated after first install and are ' + 'not supported. ' + 'See https://pre-commit.com/#using-the-latest-version-for-a-repository ' # noqa: E501 + 'for more details. ' + 'Hint: `pre-commit autoupdate` often fixes this.', + ), + ] + + +def test_warn_mutable_rev_conditional(): + config_obj = { + 'repo': 'meta', + 'rev': '3.7.7', + 'hooks': [{'id': 'flake8'}], + } + + with pytest.raises(cfgv.ValidationError): + cfgv.validate(config_obj, CONFIG_REPO_DICT) + + +def test_validate_optional_sensible_regex_at_hook_level(caplog): config_obj = { 'id': 'flake8', 'files': 'dir/*.py', @@ -183,6 +262,23 @@ def test_validate_optional_sensible_regex(caplog): ] +def test_validate_optional_sensible_regex_at_top_level(caplog): + config_obj = { + 'files': 'dir/*.py', + 'repos': [], + } + cfgv.validate(config_obj, CONFIG_SCHEMA) + + assert caplog.record_tuples == [ + ( + 'pre_commit', + logging.WARNING, + "The top-level 'files' field is a regex, not a glob -- matching " + "'/*' probably isn't what you want here", + ), + ] + + @pytest.mark.parametrize('fn', (validate_config_main, validate_manifest_main)) def test_mains_not_ok(tmpdir, fn): not_yaml = tmpdir.join('f.notyaml') diff --git a/tests/commands/hook_impl_test.py b/tests/commands/hook_impl_test.py index 2fc014686..c38b9caa1 100644 --- a/tests/commands/hook_impl_test.py +++ b/tests/commands/hook_impl_test.py @@ -97,6 +97,7 @@ def call(*_, **__): ('pre-push', ['branch_name', 'remote_name']), ('commit-msg', ['.git/COMMIT_EDITMSG']), ('post-commit', []), + ('post-merge', ['1']), ('post-checkout', ['old_head', 'new_head', '1']), # multiple choices for commit-editmsg ('prepare-commit-msg', ['.git/COMMIT_EDITMSG']), @@ -157,6 +158,14 @@ def test_run_ns_post_commit(): assert ns.color is True +def test_run_ns_post_merge(): + ns = hook_impl._run_ns('post-merge', True, ('1',), b'') + assert ns is not None + assert ns.hook_stage == 'post-merge' + assert ns.color is True + assert ns.is_squash_merge == '1' + + def test_run_ns_post_checkout(): ns = hook_impl._run_ns('post-checkout', True, ('a', 'b', 'c'), b'') assert ns is not None diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py index 7a4b90635..bd28654ff 100644 --- a/tests/commands/install_uninstall_test.py +++ b/tests/commands/install_uninstall_test.py @@ -259,7 +259,10 @@ def _path_without_us(): 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()] + after = [ + x for x in parts + if x.lower().rstrip(os.sep) != os.path.dirname(exe).lower() + ] if parts == after: raise AssertionError(exe, parts) env['PATH'] = os.pathsep.join(after) @@ -759,6 +762,48 @@ def test_post_commit_integration(tempdir_factory, store): assert os.path.exists('post-commit.tmp') +def test_post_merge_integration(tempdir_factory, store): + path = git_dir(tempdir_factory) + config = [ + { + 'repo': 'local', + 'hooks': [{ + 'id': 'post-merge', + 'name': 'Post merge', + 'entry': 'touch post-merge.tmp', + 'language': 'system', + 'always_run': True, + 'verbose': True, + 'stages': ['post-merge'], + }], + }, + ] + write_config(path, config) + with cwd(path): + # create a simple diamond of commits for a non-trivial merge + open('init', 'a').close() + cmd_output('git', 'add', '.') + git_commit() + + open('master', 'a').close() + cmd_output('git', 'add', '.') + git_commit() + + cmd_output('git', 'checkout', '-b', 'branch', 'HEAD^') + open('branch', 'a').close() + cmd_output('git', 'add', '.') + git_commit() + + cmd_output('git', 'checkout', 'master') + install(C.CONFIG_FILE, store, hook_types=['post-merge']) + retc, stdout, stderr = cmd_output_mocked_pre_commit_home( + 'git', 'merge', 'branch', + tempdir_factory=tempdir_factory, + ) + assert retc == 0 + assert os.path.exists('post-merge.tmp') + + def test_post_checkout_integration(tempdir_factory, store): path = git_dir(tempdir_factory) config = [ diff --git a/tests/commands/run_test.py b/tests/commands/run_test.py index 914d567a9..4cd70fd43 100644 --- a/tests/commands/run_test.py +++ b/tests/commands/run_test.py @@ -486,6 +486,7 @@ def test_from_ref_to_ref_error_msg_error( def test_all_push_options_ok(cap_out, store, repo_with_passing_hook): args = run_opts( from_ref='master', to_ref='master', + remote_branch='master', remote_name='origin', remote_url='https://example.com/repo', ) ret, printed = _do_run(cap_out, store, repo_with_passing_hook, args) @@ -493,6 +494,15 @@ def test_all_push_options_ok(cap_out, store, repo_with_passing_hook): assert b'Specify both --from-ref and --to-ref.' not in printed +def test_is_squash_merge(cap_out, store, repo_with_passing_hook): + args = run_opts(is_squash_merge='1') + environ: MutableMapping[str, str] = {} + ret, printed = _do_run( + cap_out, store, repo_with_passing_hook, args, environ, + ) + assert environ['PRE_COMMIT_IS_SQUASH_MERGE'] == '1' + + def test_checkout_type(cap_out, store, repo_with_passing_hook): args = run_opts(from_ref='', to_ref='', checkout_type='1') environ: MutableMapping[str, str] = {} diff --git a/tests/conftest.py b/tests/conftest.py index 335d2614f..b36ce5ac5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -261,15 +261,6 @@ def cap_out(): 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')) diff --git a/tests/git_test.py b/tests/git_test.py index fafd4a6e3..51d5f8c43 100644 --- a/tests/git_test.py +++ b/tests/git_test.py @@ -3,6 +3,7 @@ import pytest from pre_commit import git +from pre_commit.error_handler import FatalError from pre_commit.util import cmd_output from testing.util import git_commit @@ -18,6 +19,36 @@ def test_get_root_deeper(in_git_dir): assert os.path.normcase(git.get_root()) == expected +def test_in_exactly_dot_git(in_git_dir): + with in_git_dir.join('.git').as_cwd(), pytest.raises(FatalError): + git.get_root() + + +def test_get_root_bare_worktree(tmpdir): + src = tmpdir.join('src').ensure_dir() + cmd_output('git', 'init', str(src)) + git_commit(cwd=str(src)) + + bare = tmpdir.join('bare.git').ensure_dir() + cmd_output('git', 'clone', '--bare', str(src), str(bare)) + + cmd_output('git', 'worktree', 'add', 'foo', 'HEAD', cwd=bare) + + with bare.join('foo').as_cwd(): + assert git.get_root() == os.path.abspath('.') + + +def test_get_root_worktree_in_git(tmpdir): + src = tmpdir.join('src').ensure_dir() + cmd_output('git', 'init', str(src)) + git_commit(cwd=str(src)) + + cmd_output('git', 'worktree', 'add', '.git/trees/foo', 'HEAD', cwd=src) + + with src.join('.git/trees/foo').as_cwd(): + assert git.get_root() == os.path.abspath('.') + + def test_get_staged_files_deleted(in_git_dir): in_git_dir.join('test').ensure() cmd_output('git', 'add', 'test') diff --git a/tests/languages/r_test.py b/tests/languages/r_test.py new file mode 100644 index 000000000..5c046efed --- /dev/null +++ b/tests/languages/r_test.py @@ -0,0 +1,104 @@ +import os.path + +import pytest + +from pre_commit.languages import r +from testing.fixtures import make_config_from_repo +from testing.fixtures import make_repo +from tests.repository_test import _get_hook_no_install + + +def _test_r_parsing( + tempdir_factory, + store, + hook_id, + expected_hook_expr={}, + expected_args={}, +): + repo_path = 'r_hooks_repo' + path = make_repo(tempdir_factory, repo_path) + config = make_config_from_repo(path) + hook = _get_hook_no_install(config, store, hook_id) + ret = r._cmd_from_hook(hook) + expected_cmd = 'Rscript' + expected_opts = ( + '--no-save', '--no-restore', '--no-site-file', '--no-environ', + ) + expected_path = os.path.join( + hook.prefix.prefix_dir, '.'.join([hook_id, 'R']), + ) + expected = ( + expected_cmd, + *expected_opts, + *(expected_hook_expr or (expected_path,)), + *expected_args, + ) + assert ret == expected + + +def test_r_parsing_file_no_opts_no_args(tempdir_factory, store): + hook_id = 'parse-file-no-opts-no-args' + _test_r_parsing(tempdir_factory, store, hook_id) + + +def test_r_parsing_file_opts_no_args(tempdir_factory, store): + with pytest.raises(ValueError) as excinfo: + r._entry_validate(['Rscript', '--no-init', '/path/to/file']) + + msg = excinfo.value.args + assert msg == ( + 'The only valid syntax is `Rscript -e {expr}`', + 'or `Rscript path/to/hook/script`', + ) + + +def test_r_parsing_file_no_opts_args(tempdir_factory, store): + hook_id = 'parse-file-no-opts-args' + expected_args = ['--no-cache'] + _test_r_parsing( + tempdir_factory, store, hook_id, expected_args=expected_args, + ) + + +def test_r_parsing_expr_no_opts_no_args1(tempdir_factory, store): + hook_id = 'parse-expr-no-opts-no-args-1' + _test_r_parsing( + tempdir_factory, store, hook_id, expected_hook_expr=('-e', '1+1'), + ) + + +def test_r_parsing_expr_no_opts_no_args2(tempdir_factory, store): + with pytest.raises(ValueError) as execinfo: + r._entry_validate(['Rscript', '-e', '1+1', '-e', 'letters']) + msg = execinfo.value.args + assert msg == ('You can supply at most one expression.',) + + +def test_r_parsing_expr_opts_no_args2(tempdir_factory, store): + with pytest.raises(ValueError) as execinfo: + r._entry_validate( + [ + 'Rscript', '--vanilla', '-e', '1+1', '-e', 'letters', + ], + ) + msg = execinfo.value.args + assert msg == ( + 'The only valid syntax is `Rscript -e {expr}`', + 'or `Rscript path/to/hook/script`', + ) + + +def test_r_parsing_expr_args_in_entry2(tempdir_factory, store): + with pytest.raises(ValueError) as execinfo: + r._entry_validate(['Rscript', '-e', 'expr1', '--another-arg']) + + msg = execinfo.value.args + assert msg == ('You can supply at most one expression.',) + + +def test_r_parsing_expr_non_Rscirpt(tempdir_factory, store): + with pytest.raises(ValueError) as execinfo: + r._entry_validate(['AnotherScript', '-e', '{{}}']) + + msg = execinfo.value.args + assert msg == ('entry must start with `Rscript`.',) diff --git a/tests/languages/ruby_test.py b/tests/languages/ruby_test.py index 6c0c9e5e0..0c6cfede4 100644 --- a/tests/languages/ruby_test.py +++ b/tests/languages/ruby_test.py @@ -1,4 +1,5 @@ import os.path +import tarfile from unittest import mock import pytest @@ -8,6 +9,7 @@ from pre_commit.languages import ruby from pre_commit.prefix import Prefix from pre_commit.util import cmd_output +from pre_commit.util import resource_bytesio from testing.util import xfailif_windows @@ -72,3 +74,14 @@ def test_install_ruby_with_version(fake_gem_prefix): # Should be able to activate and use rbenv install with ruby.in_env(fake_gem_prefix, '2.7.2'): cmd_output('rbenv', 'install', '--help') + + +@pytest.mark.parametrize( + 'filename', + ('rbenv.tar.gz', 'ruby-build.tar.gz', 'ruby-download.tar.gz'), +) +def test_archive_root_stat(filename): + with resource_bytesio(filename) as f: + with tarfile.open(fileobj=f) as tarf: + root, _, _ = filename.partition('.') + assert oct(tarf.getmember(root).mode) == '0o755' diff --git a/tests/main_test.py b/tests/main_test.py index 6738df683..1ad8d418e 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -7,7 +7,9 @@ import pre_commit.constants as C from pre_commit import main from pre_commit.errors import FatalError +from pre_commit.util import cmd_output from testing.auto_namedtuple import auto_namedtuple +from testing.util import cwd @pytest.mark.parametrize( @@ -35,11 +37,6 @@ def test_adjust_args_and_chdir_not_in_git_dir(in_tmpdir): 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) @@ -59,6 +56,17 @@ def test_adjust_args_and_chdir_relative_things(in_git_dir): assert args.files == [os.path.join('foo', 'f1'), os.path.join('foo', 'f2')] +@pytest.mark.skipif(os.name != 'nt', reason='windows feature') +def test_install_on_subst(in_git_dir, store): # pragma: posix no cover + assert not os.path.exists('Z:') + cmd_output('subst', 'Z:', str(in_git_dir)) + try: + with cwd('Z:'): + test_adjust_args_and_chdir_noop('Z:\\') + finally: + cmd_output('subst', '/d', 'Z:') + + def test_adjust_args_and_chdir_non_relative_config(in_git_dir): in_git_dir.join('foo').ensure_dir().chdir() diff --git a/tests/make_archives_test.py b/tests/make_archives_test.py deleted file mode 100644 index 6ae2f8e74..000000000 --- a/tests/make_archives_test.py +++ /dev/null @@ -1,46 +0,0 @@ -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/repository_test.py b/tests/repository_test.py index d513cb71f..b6f7fb254 100644 --- a/tests/repository_test.py +++ b/tests/repository_test.py @@ -10,6 +10,7 @@ import re_assert 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.envcontext import envcontext @@ -278,6 +279,54 @@ def test_node_hook_with_npm_userconfig_set(tempdir_factory, store, tmpdir): test_run_a_node_hook(tempdir_factory, store) +def test_r_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'r_hooks_repo', + 'hello-world', [os.devnull], + b'Hello, World, from R!\n', + ) + + +def test_r_inline_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'r_hooks_repo', + 'hello-world-inline', ['some-file'], + b'Hi-there, some-file, from R!\n', + ) + + +def test_r_with_additional_dependencies_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'r_hooks_repo', + 'additional-deps', [os.devnull], + b'OK\n', + config_kwargs={ + 'hooks': [{ + 'id': 'additional-deps', + 'additional_dependencies': ['cachem@1.0.4'], + }], + }, + ) + + +def test_r_local_with_additional_dependencies_hook(store): + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'local-r', + 'name': 'local-r', + 'entry': 'Rscript -e', + 'language': 'r', + 'args': ['if (packageVersion("R6") == "2.1.3") cat("OK\n")'], + 'additional_dependencies': ['R6@2.1.3'], + }], + } + hook = _get_hook(config, store, 'local-r') + ret, out = _hook_run(hook, (), color=False) + assert ret == 0 + assert _norm_out(out) == b'OK\n' + + def test_run_a_ruby_hook(tempdir_factory, store): _test_hook_repo( tempdir_factory, store, 'ruby_hooks_repo', @@ -346,6 +395,59 @@ def test_golang_hook_still_works_when_gobin_is_set(tempdir_factory, store): assert os.listdir(gobin_dir) == [] +def test_golang_with_recursive_submodule(tmpdir, tempdir_factory, store): + sub_go = '''\ +package sub + +import "fmt" + +func Func() { + fmt.Println("hello hello world") +} +''' + sub = tmpdir.join('sub').ensure_dir() + sub.join('sub.go').write(sub_go) + cmd_output('git', '-C', str(sub), 'init', '.') + cmd_output('git', '-C', str(sub), 'add', '.') + git.commit(str(sub)) + + pre_commit_hooks = '''\ +- id: example + name: example + entry: example + language: golang + verbose: true +''' + go_mod = '''\ +module github.com/asottile/example + +go 1.14 +''' + main_go = '''\ +package main + +import "github.com/asottile/example/sub" + +func main() { + sub.Func() +} +''' + repo = tmpdir.join('repo').ensure_dir() + repo.join('.pre-commit-hooks.yaml').write(pre_commit_hooks) + repo.join('go.mod').write(go_mod) + repo.join('main.go').write(main_go) + cmd_output('git', '-C', str(repo), 'init', '.') + cmd_output('git', '-C', str(repo), 'add', '.') + cmd_output('git', '-C', str(repo), 'submodule', 'add', str(sub), 'sub') + git.commit(str(repo)) + + config = make_config_from_repo(str(repo)) + hook = _get_hook(config, store, 'example') + ret, out = _hook_run(hook, (), color=False) + assert ret == 0 + assert _norm_out(out) == b'hello hello world\n' + + def test_rust_hook(tempdir_factory, store): _test_hook_repo( tempdir_factory, store, 'rust_hooks_repo', @@ -569,7 +671,7 @@ def test_additional_golang_dependencies_installed( path = make_repo(tempdir_factory, 'golang_hooks_repo') config = make_config_from_repo(path) # A small go package - deps = ['github.com/golang/example/hello'] + deps = ['golang.org/x/example/hello'] config['hooks'][0]['additional_dependencies'] = deps hook = _get_hook(config, store, 'golang-hook') binaries = os.listdir( @@ -590,7 +692,7 @@ def test_local_golang_additional_dependencies(store): 'name': 'hello', 'entry': 'hello', 'language': 'golang', - 'additional_dependencies': ['github.com/golang/example/hello'], + 'additional_dependencies': ['golang.org/x/example/hello'], }], } hook = _get_hook(config, store, 'hello') @@ -640,7 +742,7 @@ def test_fail_hooks(store): ) -def test_unknown_keys(store, fake_log_handler): +def test_unknown_keys(store, caplog): config = { 'repo': 'local', 'hooks': [{ @@ -653,8 +755,8 @@ def test_unknown_keys(store, fake_log_handler): }], } _get_hook(config, store, 'too-much') - expected = 'Unexpected key(s) present on local => too-much: foo, hello' - assert fake_log_handler.handle.call_args[0][0].msg == expected + msg, = caplog.messages + assert msg == 'Unexpected key(s) present on local => too-much: foo, hello' def test_reinstall(tempdir_factory, store, log_info_mock): @@ -832,27 +934,28 @@ def test_default_stages(store, local_python_config): assert hook.stages == ['push'] -def test_hook_id_not_present(tempdir_factory, store, fake_log_handler): +def test_hook_id_not_present(tempdir_factory, store, caplog): 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 == ( + _, msg = caplog.messages + assert 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): +def test_too_new_version(tempdir_factory, store, caplog): 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 + _, msg = caplog.messages pattern = re_assert.Matches( r'^The hook `bash_hook` requires pre-commit version 999\.0\.0 but ' r'version \d+\.\d+\.\d+ is installed. ' @@ -898,7 +1001,7 @@ def test_manifest_hooks(tempdir_factory, store): require_serial=False, stages=( 'commit', 'merge-commit', 'prepare-commit-msg', 'commit-msg', - 'post-commit', 'manual', 'post-checkout', 'push', + 'post-commit', 'manual', 'post-checkout', 'push', 'post-merge', ), types=['file'], types_or=[], @@ -942,3 +1045,49 @@ def test_dotnet_hook(tempdir_factory, store, repo): tempdir_factory, store, repo, 'dotnet example hook', [], b'Hello from dotnet!\n', ) + + +def test_non_installable_hook_error_for_language_version(store, caplog): + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'system-hook', + 'name': 'system-hook', + 'language': 'system', + 'entry': 'python3 -c "import sys; print(sys.version)"', + 'language_version': 'python3.10', + }], + } + with pytest.raises(SystemExit) as excinfo: + _get_hook(config, store, 'system-hook') + assert excinfo.value.code == 1 + + msg, = caplog.messages + assert msg == ( + 'The hook `system-hook` specifies `language_version` but is using ' + 'language `system` which does not install an environment. ' + 'Perhaps you meant to use a specific language?' + ) + + +def test_non_installable_hook_error_for_additional_dependencies(store, caplog): + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'system-hook', + 'name': 'system-hook', + 'language': 'system', + 'entry': 'python3 -c "import sys; print(sys.version)"', + 'additional_dependencies': ['astpretty'], + }], + } + with pytest.raises(SystemExit) as excinfo: + _get_hook(config, store, 'system-hook') + assert excinfo.value.code == 1 + + msg, = caplog.messages + assert msg == ( + 'The hook `system-hook` specifies `additional_dependencies` but is ' + 'using language `system` which does not install an environment. ' + 'Perhaps you meant to use a specific language?' + )