diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e91992f..04b0dde 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,6 +28,19 @@ repos: hooks: - id: codespell + # Python linting and formatting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.9 + hooks: + - id: ruff + - id: ruff-format + + - repo: https://github.com/asottile/pyupgrade + rev: v3.9.0 + hooks: + - id: pyupgrade + args: [--py310-plus] + # CMake linting and formatting - repo: https://github.com/BlankSpruce/gersemi rev: 0.26.1 diff --git a/README.md b/README.md index 4b7a86b..fbc539a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ By default, you get pre-commit behavior without annoying failing commits (except markdown or things not fixable automatically) and manual amends or separate formatting commits. +The shipped example config also includes Python hooks (ruff + pyupgrade). + One drawback: the custom commit hook won't work properly for code changes within submodules. To help a bit with that, repos typically developed as submodules can be configured to get @@ -138,15 +140,15 @@ On **CMake configure**, `mb_pre_commit_setup()` can: - Ensure a **project-local Python venv** exists (default: `/.venv`). - **Pin and install** the `pre-commit` package with pip (`PRE_COMMIT_VERSION`, default `4.5.1`), upgrading only when the version does not match. -- Install a **Git `pre-commit` hook** under `.git/hooks/` in the project you point at (`PROJECT_SOURCE_DIR`, default - `CMAKE_SOURCE_DIR`). +- Install a **Git `pre-commit` hook** into the effective hooks directory reported by Git (works for normal checkouts and + worktrees) in the project you point at (`PROJECT_SOURCE_DIR`, default `CMAKE_SOURCE_DIR`). - Register a **`mb-pre-commit-sweep`** CMake target that runs **pre-commit on all files** (`pre-commit run --all-files`) from `PROJECT_SOURCE_DIR` (see above). - Optionally, register **another** sweep target (`PRE_COMMIT_TOOL_SWEEP_TARGET`) when **this** checkout is not the same tree as `PROJECT_SOURCE_DIR` (or when the main sweep is off); see **Optional: sweep this package’s checkout** under [Run pre-commit on all files](#run-pre-commit-on-all-files-mb-pre-commit-sweep). -If the tree is not a normal Git checkout (no `.git` or `.git/hooks`), setup is **skipped** with a status +If the tree is not a Git checkout (or Git cannot resolve a hooks directory there), setup is **skipped** with a status message—configure still succeeds. **Requirements:** `find_package(Python3 REQUIRED COMPONENTS Interpreter)` must succeed (used to create the venv). Git @@ -177,8 +179,9 @@ Relative paths for `PROJECT_SOURCE_DIR` / `PROJECT_BINARY_DIR` / `PRE_COMMIT_VEN ### `CUSTOM` (default) -CMake **configures** `cmake/pre-commit.in` into `${PROJECT_BINARY_DIR}/pre-commit`, then **copies** it to -`${PROJECT_SOURCE_DIR}/.git/hooks/pre-commit` when the content differs. On non-Windows hosts the hook is marked +CMake **configures** `cmake/pre-commit.in` into `${PROJECT_BINARY_DIR}/pre-commit`, then **copies** it to the hooks +directory from `git rev-parse --git-path hooks` (for example `${PROJECT_SOURCE_DIR}/.git/hooks/pre-commit`) when the +content differs. On non-Windows hosts the hook is marked executable. The hook is a small Bash script that: diff --git a/configs/v4/.pre-commit-config.yaml b/configs/v4/.pre-commit-config.yaml index e91992f..04b0dde 100644 --- a/configs/v4/.pre-commit-config.yaml +++ b/configs/v4/.pre-commit-config.yaml @@ -28,6 +28,19 @@ repos: hooks: - id: codespell + # Python linting and formatting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.9 + hooks: + - id: ruff + - id: ruff-format + + - repo: https://github.com/asottile/pyupgrade + rev: v3.9.0 + hooks: + - id: pyupgrade + args: [--py310-plus] + # CMake linting and formatting - repo: https://github.com/BlankSpruce/gersemi rev: 0.26.1 diff --git a/python/mb-pre-commit-clang-format.py b/python/mb-pre-commit-clang-format.py index d3ed339..2b0fc37 100755 --- a/python/mb-pre-commit-clang-format.py +++ b/python/mb-pre-commit-clang-format.py @@ -62,7 +62,9 @@ def _iter_repo_dirs(cache_root: Path) -> list[Path]: if not cache_root.is_dir(): return [] return sorted( - child for child in cache_root.iterdir() if child.is_dir() and child.name.startswith("repo") + child + for child in cache_root.iterdir() + if child.is_dir() and child.name.startswith("repo") ) @@ -109,7 +111,11 @@ def _iter_executables(repo_dir: Path) -> list[Path]: for child in sorted(repo_dir.iterdir()): if not child.is_dir() or not child.name.startswith("py_env-"): continue - for rel in ("bin/clang-format", "Scripts/clang-format.exe", "Scripts/clang-format"): + for rel in ( + "bin/clang-format", + "Scripts/clang-format.exe", + "Scripts/clang-format", + ): candidate = child / rel if candidate.is_file(): executables.append(candidate) diff --git a/python/mb-pre-commit-setup.py b/python/mb-pre-commit-setup.py index 6b76fb3..eee0615 100644 --- a/python/mb-pre-commit-setup.py +++ b/python/mb-pre-commit-setup.py @@ -51,7 +51,9 @@ def _onexc(func, apath: str, exc: BaseException) -> None: except OSError as e2: raise exc from e2 - def _onerror(func: object, apath: str, exc_info: tuple[type, BaseException, object]) -> None: + def _onerror( + func: object, apath: str, exc_info: tuple[type, BaseException, object] + ) -> None: _onexc(func, apath, exc_info[1]) # type: ignore[arg-type] try: @@ -63,7 +65,9 @@ def _onerror(func: object, apath: str, exc_info: tuple[type, BaseException, obje pass if os.path.exists(p): if os.name != "nt": - subprocess.run(["/bin/chmod", "-R", "u+w", p], check=False, capture_output=True) + subprocess.run( + ["/bin/chmod", "-R", "u+w", p], check=False, capture_output=True + ) subprocess.run(["/bin/rm", "-rf", p], check=False) else: subprocess.run( @@ -190,13 +194,32 @@ def _which_git() -> str | None: return shutil.which("git") +def _git_hooks_dir(project_source: Path, git_exe: str) -> Path | None: + """Resolve the effective hooks directory, including Git worktrees.""" + r = subprocess.run( + [git_exe, "rev-parse", "--git-path", "hooks"], + cwd=str(project_source), + capture_output=True, + text=True, + check=False, + ) + if r.returncode != 0: + return None + raw = (r.stdout or "").strip() + if raw == "": + return None + hooks = Path(raw) + if not hooks.is_absolute(): + hooks = (project_source / hooks).resolve() + return hooks + + def _git_ok(project_source: Path) -> bool: git = _which_git() if not git: return False - git_dir = project_source / ".git" - hooks_dir = git_dir / "hooks" - return git_dir.is_dir() and hooks_dir.is_dir() + hooks_dir = _git_hooks_dir(project_source, git) + return hooks_dir is not None and hooks_dir.is_dir() def _resolve_path(p: str | Path, base: Path) -> Path: @@ -366,7 +389,10 @@ def _install_custom_hook( "(As it wouldn't make sense to install something that won't be used.)\n" "Hint: `git config --unset-all core.hooksPath`" ) - hook_target = project_source / ".git" / "hooks" / "pre-commit" + hooks_dir = _git_hooks_dir(project_source, git_exe) + if hooks_dir is None: + _fatal(f"Could not resolve Git hooks directory in {project_source}") + hook_target = hooks_dir / "pre-commit" _copy_if_different(generated_hook, hook_target) _chmod_exec_unix(hook_target) _status(f"Installed custom pre-commit hook: {hook_target}") @@ -410,7 +436,9 @@ def _clang_format_launcher_path(venv_dir: Path) -> Path: return bindir / "mb-pre-commit-clang-format" -def _install_clang_format_launcher(tool_root: Path, venv_dir: Path, venv_python: Path) -> Path: +def _install_clang_format_launcher( + tool_root: Path, venv_dir: Path, venv_python: Path +) -> Path: wrapper = _clang_format_wrapper_path(tool_root) if not wrapper.is_file(): _fatal(f"mb_pre_commit_setup: clang-format wrapper not found: {wrapper}") @@ -419,15 +447,15 @@ def _install_clang_format_launcher(tool_root: Path, venv_dir: Path, venv_python: if os.name == "nt": text = ( "@echo off\r\n" - f"\"{_cmd_quote(str(venv_python))}\" " - f"\"{_cmd_quote(str(wrapper))}\" %*\r\n" + f'"{_cmd_quote(str(venv_python))}" ' + f'"{_cmd_quote(str(wrapper))}" %*\r\n' ) wrote = _write_text_if_different(launcher, text, newline="\r\n") else: text = ( "#!/usr/bin/env sh\n" f"exec {_sh_single_quote(str(venv_python))} " - f"{_sh_single_quote(str(wrapper))} \"$@\"\n" + f'{_sh_single_quote(str(wrapper))} "$@"\n' ) wrote = _write_text_if_different(launcher, text, newline="\n") _chmod_exec_unix(launcher) @@ -484,9 +512,7 @@ def _install_example_configs( best_n, best_src = picked dest = project_source / ".pre-commit-config.yaml" shutil.copyfile(best_src, dest) - _status( - f"Installed example pre-commit config (configs/v{best_n}) -> {dest}" - ) + _status(f"Installed example pre-commit config (configs/v{best_n}) -> {dest}") md_src = best_src.parent / ".markdownlint.yaml" if md_src.is_file(): md_dest = project_source / ".markdownlint.yaml" @@ -624,9 +650,7 @@ def main(argv: list[str] | None = None) -> int: args = _build_arg_parser().parse_args(argv) tool_root = ( - Path(args.tool_root).resolve() - if args.tool_root - else _default_tool_root() + Path(args.tool_root).resolve() if args.tool_root else _default_tool_root() ) python_for_venv = ( Path(args.python_for_venv).resolve() diff --git a/scripts/git-tag b/scripts/git-tag index 7770821..8aa99b3 100755 --- a/scripts/git-tag +++ b/scripts/git-tag @@ -32,7 +32,7 @@ def main() -> int: "(`git tag -d TAG`) or push again when the network is healthy." ) ) - p.add_argument("tag", help='e.g. v1.2.0') + p.add_argument("tag", help="e.g. v1.2.0") p.add_argument( "--no-push", action="store_true",