Compare commits

..

26 commits
v4.4.0 ... main

Author SHA1 Message Date
anthony sottile
8416413a0e
Merge pull request #3599 from pre-commit/pre-commit-ci-update-config
Some checks failed
languages / vars (push) Has been cancelled
main / main-windows (push) Has been cancelled
main / main-linux (push) Has been cancelled
languages / language (push) Has been cancelled
languages / collector (push) Has been cancelled
[pre-commit.ci] pre-commit autoupdate
2025-12-22 16:55:46 -05:00
pre-commit-ci[bot]
37a879e65e
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/setup-cfg-fmt: v3.1.0 → v3.2.0](https://github.com/asottile/setup-cfg-fmt/compare/v3.1.0...v3.2.0)
2025-12-22 20:26:26 +00:00
Anthony Sottile
8a0630ca1a v4.5.1
Some checks failed
main / main-linux (push) Has been cancelled
languages / vars (push) Has been cancelled
main / main-windows (push) Has been cancelled
languages / language (push) Has been cancelled
languages / collector (push) Has been cancelled
2025-12-16 16:13:56 -05:00
anthony sottile
fcbc745744
Merge pull request #3597 from pre-commit/empty-setup-py
fix python local template when artifact dirs are present
2025-12-16 14:56:40 -06:00
Anthony Sottile
51592eecec fix python local template when artifact dirs are present 2025-12-16 15:45:01 -05:00
anthony sottile
67e8faf80b
Merge pull request #3596 from pre-commit/pre-commit-ci-update-config
Some checks are pending
languages / language (push) Blocked by required conditions
languages / collector (push) Blocked by required conditions
languages / vars (push) Waiting to run
main / main-windows (push) Waiting to run
main / main-linux (push) Waiting to run
[pre-commit.ci] pre-commit autoupdate
2025-12-15 16:04:01 -06:00
pre-commit-ci[bot]
c251e6b6d0
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-mypy: v1.19.0 → v1.19.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.19.0...v1.19.1)
2025-12-15 20:48:45 +00:00
anthony sottile
98ccafa3ce
Merge pull request #3593 from pre-commit/pre-commit-ci-update-config
Some checks failed
languages / vars (push) Has been cancelled
main / main-windows (push) Has been cancelled
main / main-linux (push) Has been cancelled
languages / language (push) Has been cancelled
languages / collector (push) Has been cancelled
[pre-commit.ci] pre-commit autoupdate
2025-12-01 16:13:49 -05:00
pre-commit-ci[bot]
48953556d0
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-mypy: v1.18.2 → v1.19.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.18.2...v1.19.0)
2025-12-01 21:05:15 +00:00
anthony sottile
2cedd58e69
Merge pull request #3588 from pre-commit/pre-commit-ci-update-config
Some checks failed
languages / vars (push) Has been cancelled
main / main-windows (push) Has been cancelled
main / main-linux (push) Has been cancelled
languages / language (push) Has been cancelled
languages / collector (push) Has been cancelled
[pre-commit.ci] pre-commit autoupdate
2025-11-25 10:52:12 -05:00
pre-commit-ci[bot]
465192d7de
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.21.1 → v3.21.2](https://github.com/asottile/pyupgrade/compare/v3.21.1...v3.21.2)
2025-11-24 20:53:38 +00:00
anthony sottile
fd42f96874
Merge pull request #3586 from pre-commit/zipapp-sha256-file-not-needed
Some checks failed
languages / vars (push) Has been cancelled
main / main-windows (push) Has been cancelled
main / main-linux (push) Has been cancelled
languages / language (push) Has been cancelled
languages / collector (push) Has been cancelled
remove sha256 file from zipapp script
2025-11-22 16:15:39 -05:00
anthony sottile
8ea2b790d8 remove sha256 file from zipapp script
github displays the checksum for us now!
2025-11-22 16:06:27 -05:00
anthony sottile
1af6c8fa95 v4.5.0 2025-11-22 16:02:16 -05:00
anthony sottile
3358a3b540
Merge pull request #3585 from pre-commit/hazmat
add pre-commit hazmat
2025-11-22 14:03:09 -05:00
anthony sottile
bdf68790b7 add pre-commit hazmat 2025-11-22 13:53:53 -05:00
anthony sottile
e436690f14
Merge pull request #3584 from pre-commit/exitstack
Some checks are pending
languages / vars (push) Waiting to run
languages / language (push) Blocked by required conditions
languages / collector (push) Blocked by required conditions
main / main-windows (push) Waiting to run
main / main-linux (push) Waiting to run
use ExitStack instead of start + stop
2025-11-21 15:19:53 -05:00
anthony sottile
8d34f95308 use ExitStack instead of start + stop 2025-11-21 15:09:41 -05:00
anthony sottile
9c7ea88ab9
Merge pull request #3583 from pre-commit/forward-compat-map-manifest
Some checks failed
main / main-linux (push) Has been cancelled
languages / language (push) Has been cancelled
languages / vars (push) Has been cancelled
main / main-windows (push) Has been cancelled
languages / collector (push) Has been cancelled
add forward-compat error message
2025-11-19 15:10:28 -05:00
Anthony Sottile
844dacc168 add forward-compat error message 2025-11-19 14:57:01 -05:00
anthony sottile
6a1d543e52
Merge pull request #3582 from pre-commit/move-gc-back
move logic for gc back to commands.gc
2025-11-19 14:44:46 -05:00
Anthony Sottile
66278a9a0b move logic for gc back to commands.gc 2025-11-19 14:32:09 -05:00
anthony sottile
1b32c50bc7
Merge pull request #3579 from pre-commit/pre-commit-ci-update-config
Some checks failed
languages / vars (push) Has been cancelled
main / main-windows (push) Has been cancelled
main / main-linux (push) Has been cancelled
languages / language (push) Has been cancelled
languages / collector (push) Has been cancelled
[pre-commit.ci] pre-commit autoupdate
2025-11-10 16:53:56 -05:00
pre-commit-ci[bot]
063229aee7
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.21.0 → v3.21.1](https://github.com/asottile/pyupgrade/compare/v3.21.0...v3.21.1)
2025-11-10 20:59:54 +00:00
anthony sottile
49e28eea48
Merge pull request #3578 from pre-commit/store-gc-refactor
Some checks are pending
languages / vars (push) Waiting to run
languages / language (push) Blocked by required conditions
languages / collector (push) Blocked by required conditions
main / main-windows (push) Waiting to run
main / main-linux (push) Waiting to run
refactor gc into store
2025-11-09 17:16:27 -05:00
Anthony Sottile
d5c273a2ba refactor gc into store
this will make refactoring this easier later and limits the api surface of Store
2025-11-09 17:03:43 -05:00
19 changed files with 376 additions and 87 deletions

View file

@ -10,7 +10,7 @@ repos:
- id: name-tests-test - id: name-tests-test
- id: requirements-txt-fixer - id: requirements-txt-fixer
- repo: https://github.com/asottile/setup-cfg-fmt - repo: https://github.com/asottile/setup-cfg-fmt
rev: v3.1.0 rev: v3.2.0
hooks: hooks:
- id: setup-cfg-fmt - id: setup-cfg-fmt
- repo: https://github.com/asottile/reorder-python-imports - repo: https://github.com/asottile/reorder-python-imports
@ -24,7 +24,7 @@ repos:
hooks: hooks:
- id: add-trailing-comma - id: add-trailing-comma
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.21.0 rev: v3.21.2
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py310-plus] args: [--py310-plus]
@ -37,7 +37,7 @@ repos:
hooks: hooks:
- id: flake8 - id: flake8
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.18.2 rev: v1.19.1
hooks: hooks:
- id: mypy - id: mypy
additional_dependencies: [types-pyyaml] additional_dependencies: [types-pyyaml]

View file

@ -1,3 +1,17 @@
4.5.1 - 2025-12-16
==================
### Fixes
- Fix `language: python` with `repo: local` without `additional_dependencies`.
- #3597 PR by @asottile.
4.5.0 - 2025-11-22
==================
### Features
- Add `pre-commit hazmat`.
- #3585 PR by @asottile.
4.4.0 - 2025-11-08 4.4.0 - 2025-11-08
================== ==================

View file

@ -270,10 +270,19 @@ class InvalidManifestError(FatalError):
pass pass
def _load_manifest_forward_compat(contents: str) -> object:
obj = yaml_load(contents)
if isinstance(obj, dict):
check_min_version('5')
raise AssertionError('unreachable')
else:
return obj
load_manifest = functools.partial( load_manifest = functools.partial(
cfgv.load_from_filename, cfgv.load_from_filename,
schema=MANIFEST_SCHEMA, schema=MANIFEST_SCHEMA,
load_strategy=yaml_load, load_strategy=_load_manifest_forward_compat,
exc_tp=InvalidManifestError, exc_tp=InvalidManifestError,
) )

View file

@ -12,6 +12,7 @@ from pre_commit.clientlib import load_manifest
from pre_commit.clientlib import LOCAL from pre_commit.clientlib import LOCAL
from pre_commit.clientlib import META from pre_commit.clientlib import META
from pre_commit.store import Store from pre_commit.store import Store
from pre_commit.util import rmtree
def _mark_used_repos( def _mark_used_repos(
@ -26,7 +27,8 @@ def _mark_used_repos(
for hook in repo['hooks']: for hook in repo['hooks']:
deps = hook.get('additional_dependencies') deps = hook.get('additional_dependencies')
unused_repos.discard(( unused_repos.discard((
store.db_repo_name(repo['repo'], deps), C.LOCAL_REPO_VERSION, store.db_repo_name(repo['repo'], deps),
C.LOCAL_REPO_VERSION,
)) ))
else: else:
key = (repo['repo'], repo['rev']) key = (repo['repo'], repo['rev'])
@ -56,34 +58,41 @@ def _mark_used_repos(
)) ))
def _gc_repos(store: Store) -> int: def _gc(store: Store) -> int:
configs = store.select_all_configs() with store.exclusive_lock(), store.connect() as db:
repos = store.select_all_repos() store._create_configs_table(db)
# delete config paths which do not exist repos = db.execute('SELECT repo, ref, path FROM repos').fetchall()
dead_configs = [p for p in configs if not os.path.exists(p)] all_repos = {(repo, ref): path for repo, ref, path in repos}
live_configs = [p for p in configs if os.path.exists(p)] unused_repos = set(all_repos)
all_repos = {(repo, ref): path for repo, ref, path in repos} configs_rows = db.execute('SELECT path FROM configs').fetchall()
unused_repos = set(all_repos) configs = [path for path, in configs_rows]
for config_path in live_configs:
try:
config = load_config(config_path)
except InvalidConfigError:
dead_configs.append(config_path)
continue
else:
for repo in config['repos']:
_mark_used_repos(store, all_repos, unused_repos, repo)
store.delete_configs(dead_configs) dead_configs = []
for db_repo_name, ref in unused_repos: for config_path in configs:
store.delete_repo(db_repo_name, ref, all_repos[(db_repo_name, ref)]) try:
return len(unused_repos) config = load_config(config_path)
except InvalidConfigError:
dead_configs.append(config_path)
continue
else:
for repo in config['repos']:
_mark_used_repos(store, all_repos, unused_repos, repo)
paths = [(path,) for path in dead_configs]
db.executemany('DELETE FROM configs WHERE path = ?', paths)
db.executemany(
'DELETE FROM repos WHERE repo = ? and ref = ?',
sorted(unused_repos),
)
for k in unused_repos:
rmtree(all_repos[k])
return len(unused_repos)
def gc(store: Store) -> int: def gc(store: Store) -> int:
with store.exclusive_lock(): output.write_line(f'{_gc(store)} repo(s) removed.')
repos_removed = _gc_repos(store)
output.write_line(f'{repos_removed} repo(s) removed.')
return 0 return 0

View file

@ -0,0 +1,95 @@
from __future__ import annotations
import argparse
import subprocess
from collections.abc import Sequence
from pre_commit.parse_shebang import normalize_cmd
def add_parsers(parser: argparse.ArgumentParser) -> None:
subparsers = parser.add_subparsers(dest='tool')
cd_parser = subparsers.add_parser(
'cd', help='cd to a subdir and run the command',
)
cd_parser.add_argument('subdir')
cd_parser.add_argument('cmd', nargs=argparse.REMAINDER)
ignore_exit_code_parser = subparsers.add_parser(
'ignore-exit-code', help='run the command but ignore the exit code',
)
ignore_exit_code_parser.add_argument('cmd', nargs=argparse.REMAINDER)
n1_parser = subparsers.add_parser(
'n1', help='run the command once per filename',
)
n1_parser.add_argument('cmd', nargs=argparse.REMAINDER)
def _cmd_filenames(cmd: tuple[str, ...]) -> tuple[
tuple[str, ...],
tuple[str, ...],
]:
for idx, val in enumerate(reversed(cmd)):
if val == '--':
split = len(cmd) - idx
break
else:
raise SystemExit('hazmat entry must end with `--`')
return cmd[:split - 1], cmd[split:]
def cd(subdir: str, cmd: tuple[str, ...]) -> int:
cmd, filenames = _cmd_filenames(cmd)
prefix = f'{subdir}/'
new_filenames = []
for filename in filenames:
if not filename.startswith(prefix):
raise SystemExit(f'unexpected file without {prefix=}: {filename}')
else:
new_filenames.append(filename.removeprefix(prefix))
cmd = normalize_cmd(cmd)
return subprocess.call((*cmd, *new_filenames), cwd=subdir)
def ignore_exit_code(cmd: tuple[str, ...]) -> int:
cmd = normalize_cmd(cmd)
subprocess.call(cmd)
return 0
def n1(cmd: tuple[str, ...]) -> int:
cmd, filenames = _cmd_filenames(cmd)
cmd = normalize_cmd(cmd)
ret = 0
for filename in filenames:
ret |= subprocess.call((*cmd, filename))
return ret
def impl(args: argparse.Namespace) -> int:
args.cmd = tuple(args.cmd)
if args.tool == 'cd':
return cd(args.subdir, args.cmd)
elif args.tool == 'ignore-exit-code':
return ignore_exit_code(args.cmd)
elif args.tool == 'n1':
return n1(args.cmd)
else:
raise NotImplementedError(f'unexpected tool: {args.tool}')
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser()
add_parsers(parser)
args = parser.parse_args(argv)
return impl(args)
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -5,6 +5,7 @@ import os
import random import random
import re import re
import shlex import shlex
import sys
from collections.abc import Generator from collections.abc import Generator
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any from typing import Any
@ -171,7 +172,10 @@ def run_xargs(
def hook_cmd(entry: str, args: Sequence[str]) -> tuple[str, ...]: def hook_cmd(entry: str, args: Sequence[str]) -> tuple[str, ...]:
return (*shlex.split(entry), *args) cmd = shlex.split(entry)
if cmd[:2] == ['pre-commit', 'hazmat']:
cmd = [sys.executable, '-m', 'pre_commit.commands.hazmat', *cmd[2:]]
return (*cmd, *args)
def basic_run_hook( def basic_run_hook(

View file

@ -10,6 +10,7 @@ import pre_commit.constants as C
from pre_commit import clientlib from pre_commit import clientlib
from pre_commit import git from pre_commit import git
from pre_commit.color import add_color_option from pre_commit.color import add_color_option
from pre_commit.commands import hazmat
from pre_commit.commands.autoupdate import autoupdate from pre_commit.commands.autoupdate import autoupdate
from pre_commit.commands.clean import clean from pre_commit.commands.clean import clean
from pre_commit.commands.gc import gc from pre_commit.commands.gc import gc
@ -41,7 +42,7 @@ os.environ.pop('__PYVENV_LAUNCHER__', None)
os.environ.pop('PYTHONEXECUTABLE', None) os.environ.pop('PYTHONEXECUTABLE', None)
COMMANDS_NO_GIT = { COMMANDS_NO_GIT = {
'clean', 'gc', 'init-templatedir', 'sample-config', 'clean', 'gc', 'hazmat', 'init-templatedir', 'sample-config',
'validate-config', 'validate-manifest', 'validate-config', 'validate-manifest',
} }
@ -245,6 +246,11 @@ def main(argv: Sequence[str] | None = None) -> int:
_add_cmd('gc', help='Clean unused cached repos.') _add_cmd('gc', help='Clean unused cached repos.')
hazmat_parser = _add_cmd(
'hazmat', help='Composable tools for rare use in hook `entry`.',
)
hazmat.add_parsers(hazmat_parser)
init_templatedir_parser = _add_cmd( init_templatedir_parser = _add_cmd(
'init-templatedir', 'init-templatedir',
help=( help=(
@ -389,6 +395,8 @@ def main(argv: Sequence[str] | None = None) -> int:
return clean(store) return clean(store)
elif args.command == 'gc': elif args.command == 'gc':
return gc(store) return gc(store)
elif args.command == 'hazmat':
return hazmat.impl(args)
elif args.command == 'hook-impl': elif args.command == 'hook-impl':
return hook_impl( return hook_impl(
store, store,

View file

@ -1,4 +1,4 @@
from setuptools import setup from setuptools import setup
setup(name='pre-commit-placeholder-package', version='0.0.0') setup(name='pre-commit-placeholder-package', version='0.0.0', py_modules=[])

View file

@ -17,7 +17,6 @@ from pre_commit.util import CalledProcessError
from pre_commit.util import clean_path_on_failure from pre_commit.util import clean_path_on_failure
from pre_commit.util import cmd_output_b from pre_commit.util import cmd_output_b
from pre_commit.util import resource_text from pre_commit.util import resource_text
from pre_commit.util import rmtree
logger = logging.getLogger('pre_commit') logger = logging.getLogger('pre_commit')
@ -96,7 +95,7 @@ class Store:
' PRIMARY KEY (repo, ref)' ' PRIMARY KEY (repo, ref)'
');', ');',
) )
self._create_config_table(db) self._create_configs_table(db)
# Atomic file move # Atomic file move
os.replace(tmpfile, self.db_path) os.replace(tmpfile, self.db_path)
@ -215,7 +214,7 @@ class Store:
'local', C.LOCAL_REPO_VERSION, deps, _make_local_repo, 'local', C.LOCAL_REPO_VERSION, deps, _make_local_repo,
) )
def _create_config_table(self, db: sqlite3.Connection) -> None: def _create_configs_table(self, db: sqlite3.Connection) -> None:
db.executescript( db.executescript(
'CREATE TABLE IF NOT EXISTS configs (' 'CREATE TABLE IF NOT EXISTS configs ('
' path TEXT NOT NULL,' ' path TEXT NOT NULL,'
@ -232,28 +231,5 @@ class Store:
return return
with self.connect() as db: with self.connect() as db:
# TODO: eventually remove this and only create in _create # TODO: eventually remove this and only create in _create
self._create_config_table(db) self._create_configs_table(db)
db.execute('INSERT OR IGNORE INTO configs VALUES (?)', (path,)) db.execute('INSERT OR IGNORE INTO configs VALUES (?)', (path,))
def select_all_configs(self) -> list[str]:
with self.connect() as db:
self._create_config_table(db)
rows = db.execute('SELECT path FROM configs').fetchall()
return [path for path, in rows]
def delete_configs(self, configs: list[str]) -> None:
with self.connect() as db:
rows = [(path,) for path in configs]
db.executemany('DELETE FROM configs WHERE path = ?', rows)
def select_all_repos(self) -> list[tuple[str, str, str]]:
with self.connect() as db:
return db.execute('SELECT repo, ref, path from repos').fetchall()
def delete_repo(self, db_repo_name: str, ref: str, path: str) -> None:
with self.connect() as db:
db.execute(
'DELETE FROM repos WHERE repo = ? and ref = ?',
(db_repo_name, ref),
)
rmtree(path)

View file

@ -1,6 +1,6 @@
[metadata] [metadata]
name = pre_commit name = pre_commit
version = 4.4.0 version = 4.5.1
description = A framework for managing and maintaining multi-language pre-commit hooks. description = A framework for managing and maintaining multi-language pre-commit hooks.
long_description = file: README.md long_description = file: README.md
long_description_content_type = text/markdown long_description_content_type = text/markdown

View file

@ -107,9 +107,6 @@ def main() -> int:
shebang = '/usr/bin/env python3' shebang = '/usr/bin/env python3'
zipapp.create_archive(tmpdir, filename, interpreter=shebang) zipapp.create_archive(tmpdir, filename, interpreter=shebang)
with open(f'{filename}.sha256sum', 'w') as f:
subprocess.check_call(('sha256sum', filename), stdout=f)
return 0 return 0

View file

@ -12,6 +12,8 @@ from pre_commit.clientlib import CONFIG_HOOK_DICT
from pre_commit.clientlib import CONFIG_REPO_DICT from pre_commit.clientlib import CONFIG_REPO_DICT
from pre_commit.clientlib import CONFIG_SCHEMA from pre_commit.clientlib import CONFIG_SCHEMA
from pre_commit.clientlib import DEFAULT_LANGUAGE_VERSION from pre_commit.clientlib import DEFAULT_LANGUAGE_VERSION
from pre_commit.clientlib import InvalidManifestError
from pre_commit.clientlib import load_manifest
from pre_commit.clientlib import MANIFEST_HOOK_DICT from pre_commit.clientlib import MANIFEST_HOOK_DICT
from pre_commit.clientlib import MANIFEST_SCHEMA from pre_commit.clientlib import MANIFEST_SCHEMA
from pre_commit.clientlib import META_HOOK_DICT from pre_commit.clientlib import META_HOOK_DICT
@ -588,3 +590,18 @@ def test_config_hook_stages_defaulting():
'id': 'fake-hook', 'id': 'fake-hook',
'stages': ['commit-msg', 'pre-push', 'pre-commit', 'pre-merge-commit'], 'stages': ['commit-msg', 'pre-push', 'pre-commit', 'pre-merge-commit'],
} }
def test_manifest_v5_forward_compat(tmp_path):
manifest = tmp_path.joinpath('.pre-commit-hooks.yaml')
manifest.write_text('hooks: {}')
with pytest.raises(InvalidManifestError) as excinfo:
load_manifest(manifest)
assert str(excinfo.value) == (
f'\n'
f'==> File {manifest}\n'
f'=====> \n'
f'=====> pre-commit version 5 is required but version {C.VERSION} '
f'is installed. Perhaps run `pip install --upgrade pre-commit`.'
)

View file

@ -19,11 +19,13 @@ from testing.util import git_commit
def _repo_count(store): def _repo_count(store):
return len(store.select_all_repos()) with store.connect() as db:
return db.execute('SELECT COUNT(1) FROM repos').fetchone()[0]
def _config_count(store): def _config_count(store):
return len(store.select_all_configs()) with store.connect() as db:
return db.execute('SELECT COUNT(1) FROM configs').fetchone()[0]
def _remove_config_assert_cleared(store, cap_out): def _remove_config_assert_cleared(store, cap_out):
@ -153,7 +155,8 @@ def test_invalid_manifest_gcd(tempdir_factory, store, in_git_dir, cap_out):
install_hooks(C.CONFIG_FILE, store) install_hooks(C.CONFIG_FILE, store)
# we'll "break" the manifest to simulate an old version clone # we'll "break" the manifest to simulate an old version clone
(_, _, path), = store.select_all_repos() with store.connect() as db:
path, = db.execute('SELECT path FROM repos').fetchone()
os.remove(os.path.join(path, C.MANIFEST_FILE)) os.remove(os.path.join(path, C.MANIFEST_FILE))
assert _config_count(store) == 1 assert _config_count(store) == 1
@ -162,3 +165,11 @@ def test_invalid_manifest_gcd(tempdir_factory, store, in_git_dir, cap_out):
assert _config_count(store) == 1 assert _config_count(store) == 1
assert _repo_count(store) == 0 assert _repo_count(store) == 0
assert cap_out.get().splitlines()[-1] == '1 repo(s) removed.' assert cap_out.get().splitlines()[-1] == '1 repo(s) removed.'
def test_gc_pre_1_14_roll_forward(store, cap_out):
with store.connect() as db: # simulate pre-1.14.0
db.executescript('DROP TABLE configs')
assert not gc(store)
assert cap_out.get() == '0 repo(s) removed.\n'

View file

@ -0,0 +1,99 @@
from __future__ import annotations
import sys
import pytest
from pre_commit.commands.hazmat import _cmd_filenames
from pre_commit.commands.hazmat import main
from testing.util import cwd
def test_cmd_filenames_no_dash_dash():
with pytest.raises(SystemExit) as excinfo:
_cmd_filenames(('no', 'dashdash', 'here'))
msg, = excinfo.value.args
assert msg == 'hazmat entry must end with `--`'
def test_cmd_filenames_no_filenames():
cmd, filenames = _cmd_filenames(('hello', 'world', '--'))
assert cmd == ('hello', 'world')
assert filenames == ()
def test_cmd_filenames_some_filenames():
cmd, filenames = _cmd_filenames(('hello', 'world', '--', 'f1', 'f2'))
assert cmd == ('hello', 'world')
assert filenames == ('f1', 'f2')
def test_cmd_filenames_multiple_dashdash():
cmd, filenames = _cmd_filenames(('hello', '--', 'arg', '--', 'f1', 'f2'))
assert cmd == ('hello', '--', 'arg')
assert filenames == ('f1', 'f2')
def test_cd_unexpected_filename():
with pytest.raises(SystemExit) as excinfo:
main(('cd', 'subdir', 'cmd', '--', 'subdir/1', 'not-subdir/2'))
msg, = excinfo.value.args
assert msg == "unexpected file without prefix='subdir/': not-subdir/2"
def _norm(out):
return out.replace('\r\n', '\n')
def test_cd(tmp_path, capfd):
subdir = tmp_path.joinpath('subdir')
subdir.mkdir()
subdir.joinpath('a').write_text('a')
subdir.joinpath('b').write_text('b')
with cwd(tmp_path):
ret = main((
'cd', 'subdir',
sys.executable, '-c',
'import os; print(os.getcwd());'
'import sys; [print(open(f).read()) for f in sys.argv[1:]]',
'--',
'subdir/a', 'subdir/b',
))
assert ret == 0
out, err = capfd.readouterr()
assert _norm(out) == f'{subdir}\na\nb\n'
assert err == ''
def test_ignore_exit_code(capfd):
ret = main((
'ignore-exit-code', sys.executable, '-c', 'raise SystemExit("bye")',
))
assert ret == 0
out, err = capfd.readouterr()
assert out == ''
assert _norm(err) == 'bye\n'
def test_n1(capfd):
ret = main((
'n1', sys.executable, '-c', 'import sys; print(sys.argv[1:])',
'--',
'foo', 'bar', 'baz',
))
assert ret == 0
out, err = capfd.readouterr()
assert _norm(out) == "['foo']\n['bar']\n['baz']\n"
assert err == ''
def test_n1_some_error_code():
ret = main((
'n1', sys.executable, '-c',
'import sys; raise SystemExit(sys.argv[1] == "error")',
'--',
'ok', 'error', 'ok',
))
assert ret == 1

View file

@ -164,3 +164,15 @@ def test_basic_run_hook(tmp_path):
assert ret == 0 assert ret == 0
out = out.replace(b'\r\n', b'\n') out = out.replace(b'\r\n', b'\n')
assert out == b'hi hello file file file\n' assert out == b'hi hello file file file\n'
def test_hook_cmd():
assert lang_base.hook_cmd('echo hi', ()) == ('echo', 'hi')
def test_hook_cmd_hazmat():
ret = lang_base.hook_cmd('pre-commit hazmat cd a echo -- b', ())
assert ret == (
sys.executable, '-m', 'pre_commit.commands.hazmat',
'cd', 'a', 'echo', '--', 'b',
)

View file

@ -10,6 +10,8 @@ import pre_commit.constants as C
from pre_commit.envcontext import envcontext from pre_commit.envcontext import envcontext
from pre_commit.languages import python from pre_commit.languages import python
from pre_commit.prefix import Prefix from pre_commit.prefix import Prefix
from pre_commit.store import _make_local_repo
from pre_commit.util import cmd_output_b
from pre_commit.util import make_executable from pre_commit.util import make_executable
from pre_commit.util import win_exe from pre_commit.util import win_exe
from testing.auto_namedtuple import auto_namedtuple from testing.auto_namedtuple import auto_namedtuple
@ -351,3 +353,15 @@ def test_python_hook_weird_setup_cfg(tmp_path):
ret = run_language(tmp_path, python, 'socks', [os.devnull]) ret = run_language(tmp_path, python, 'socks', [os.devnull])
assert ret == (0, f'[{os.devnull!r}]\nhello hello\n'.encode()) assert ret == (0, f'[{os.devnull!r}]\nhello hello\n'.encode())
def test_local_repo_with_other_artifacts(tmp_path):
cmd_output_b('git', 'init', tmp_path)
_make_local_repo(str(tmp_path))
# pretend a rust install also ran here
tmp_path.joinpath('target').mkdir()
ret, out = run_language(tmp_path, python, 'python --version')
assert ret == 0
assert out.startswith(b'Python ')

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import contextlib
import os.path import os.path
from unittest import mock from unittest import mock
@ -8,6 +9,7 @@ import pytest
import pre_commit.constants as C import pre_commit.constants as C
from pre_commit import main from pre_commit import main
from pre_commit.commands import hazmat
from pre_commit.errors import FatalError from pre_commit.errors import FatalError
from pre_commit.util import cmd_output from pre_commit.util import cmd_output
from testing.auto_namedtuple import auto_namedtuple from testing.auto_namedtuple import auto_namedtuple
@ -97,11 +99,9 @@ CMDS = tuple(fn.replace('_', '-') for fn in FNS)
@pytest.fixture @pytest.fixture
def mock_commands(): def mock_commands():
mcks = {fn: mock.patch.object(main, fn).start() for fn in FNS} with contextlib.ExitStack() as ctx:
ret = auto_namedtuple(**mcks) mcks = {f: ctx.enter_context(mock.patch.object(main, f)) for f in FNS}
yield ret yield auto_namedtuple(**mcks)
for mck in ret:
mck.stop()
@pytest.fixture @pytest.fixture
@ -158,6 +158,17 @@ def test_all_cmds(command, mock_commands, mock_store_dir):
assert_only_one_mock_called(mock_commands) assert_only_one_mock_called(mock_commands)
def test_hazmat(mock_store_dir):
with mock.patch.object(hazmat, 'impl') as mck:
main.main(('hazmat', 'cd', 'subdir', '--', 'cmd', '--', 'f1', 'f2'))
assert mck.call_count == 1
(arg,), dct = mck.call_args
assert dct == {}
assert arg.tool == 'cd'
assert arg.subdir == 'subdir'
assert arg.cmd == ['cmd', '--', 'f1', 'f2']
def test_try_repo(mock_store_dir): def test_try_repo(mock_store_dir):
with mock.patch.object(main, 'try_repo') as patch: with mock.patch.object(main, 'try_repo') as patch:
main.main(('try-repo', '.')) main.main(('try-repo', '.'))

View file

@ -506,3 +506,14 @@ def test_args_with_spaces_and_quotes(tmp_path):
expected = b"['i have spaces', 'and\"\\'quotes', '$and !this']\n" expected = b"['i have spaces', 'and\"\\'quotes', '$and !this']\n"
assert ret == (0, expected) assert ret == (0, expected)
def test_hazmat(tmp_path):
ret = run_language(
tmp_path, unsupported,
f'pre-commit hazmat ignore-exit-code {shlex.quote(sys.executable)} '
f"-c 'import sys; raise SystemExit(sys.argv[1:])'",
('f1', 'f2'),
)
expected = b"['f1', 'f2']\n"
assert ret == (0, expected)

View file

@ -22,6 +22,17 @@ from testing.util import git_commit
from testing.util import xfailif_windows from testing.util import xfailif_windows
def _select_all_configs(store: Store) -> list[str]:
with store.connect() as db:
rows = db.execute('SELECT * FROM configs').fetchall()
return [path for path, in rows]
def _select_all_repos(store: Store) -> list[tuple[str, str, str]]:
with store.connect() as db:
return db.execute('SELECT repo, ref, path FROM repos').fetchall()
def test_our_session_fixture_works(): def test_our_session_fixture_works():
"""There's a session fixture which makes `Store` invariantly raise to """There's a session fixture which makes `Store` invariantly raise to
prevent writing to the home directory. prevent writing to the home directory.
@ -91,7 +102,7 @@ def test_clone(store, tempdir_factory, caplog):
assert git.head_rev(ret) == rev assert git.head_rev(ret) == rev
# Assert there's an entry in the sqlite db for this # Assert there's an entry in the sqlite db for this
assert store.select_all_repos() == [(path, rev, ret)] assert _select_all_repos(store) == [(path, rev, ret)]
def test_warning_for_deprecated_stages_on_init(store, tempdir_factory, caplog): def test_warning_for_deprecated_stages_on_init(store, tempdir_factory, caplog):
@ -217,7 +228,7 @@ def test_clone_shallow_failure_fallback_to_complete(
assert git.head_rev(ret) == rev assert git.head_rev(ret) == rev
# Assert there's an entry in the sqlite db for this # Assert there's an entry in the sqlite db for this
assert store.select_all_repos() == [(path, rev, ret)] assert _select_all_repos(store) == [(path, rev, ret)]
def test_clone_tag_not_on_mainline(store, tempdir_factory): def test_clone_tag_not_on_mainline(store, tempdir_factory):
@ -265,7 +276,7 @@ def test_mark_config_as_used(store, tmpdir):
with tmpdir.as_cwd(): with tmpdir.as_cwd():
f = tmpdir.join('f').ensure() f = tmpdir.join('f').ensure()
store.mark_config_used('f') store.mark_config_used('f')
assert store.select_all_configs() == [f.strpath] assert _select_all_configs(store) == [f.strpath]
def test_mark_config_as_used_idempotent(store, tmpdir): def test_mark_config_as_used_idempotent(store, tmpdir):
@ -275,21 +286,12 @@ def test_mark_config_as_used_idempotent(store, tmpdir):
def test_mark_config_as_used_does_not_exist(store): def test_mark_config_as_used_does_not_exist(store):
store.mark_config_used('f') store.mark_config_used('f')
assert store.select_all_configs() == [] assert _select_all_configs(store) == []
def _simulate_pre_1_14_0(store):
with store.connect() as db:
db.executescript('DROP TABLE configs')
def test_select_all_configs_roll_forward(store):
_simulate_pre_1_14_0(store)
assert store.select_all_configs() == []
def test_mark_config_as_used_roll_forward(store, tmpdir): def test_mark_config_as_used_roll_forward(store, tmpdir):
_simulate_pre_1_14_0(store) with store.connect() as db: # simulate pre-1.14.0
db.executescript('DROP TABLE configs')
test_mark_config_as_used(store, tmpdir) test_mark_config_as_used(store, tmpdir)
@ -314,7 +316,7 @@ def test_mark_config_as_used_readonly(tmpdir):
assert store.readonly assert store.readonly
# should be skipped due to readonly # should be skipped due to readonly
store.mark_config_used(str(cfg)) store.mark_config_used(str(cfg))
assert store.select_all_configs() == [] assert _select_all_configs(store) == []
def test_clone_with_recursive_submodules(store, tmp_path): def test_clone_with_recursive_submodules(store, tmp_path):