Repository: j178/prek Branch: master Commit: 9328863e032f Files: 256 Total size: 2.3 MB Directory structure: gitextract_9_ydfirn/ ├── .config/ │ ├── nextest.toml │ └── taplo.toml ├── .devcontainer/ │ └── devcontainer.json ├── .gitattributes ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ └── bug_report.yaml │ ├── codecov.yml │ ├── copilot-instructions.md │ ├── renovate.json5 │ ├── workflows/ │ │ ├── build-binaries.yml │ │ ├── build-docker.yml │ │ ├── ci.yml │ │ ├── performance.yml │ │ ├── publish-crates.yml │ │ ├── publish-docs.yml │ │ ├── publish-homebrew.yml │ │ ├── publish-npm.yml │ │ ├── publish-prek-action.yml │ │ ├── publish-pypi.yml │ │ ├── publish-winget.yml │ │ ├── release.yml │ │ ├── setup-dev-drive.ps1 │ │ ├── sync-identify.yml │ │ └── zizmor.yml │ └── zizmor.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── clippy.toml ├── crates/ │ ├── prek/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── src/ │ │ │ ├── archive.rs │ │ │ ├── cleanup.rs │ │ │ ├── cli/ │ │ │ │ ├── auto_update.rs │ │ │ │ ├── cache_clean.rs │ │ │ │ ├── cache_gc.rs │ │ │ │ ├── cache_size.rs │ │ │ │ ├── completion.rs │ │ │ │ ├── hook_impl.rs │ │ │ │ ├── identify.rs │ │ │ │ ├── install.rs │ │ │ │ ├── list.rs │ │ │ │ ├── list_builtins.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── reporter.rs │ │ │ │ ├── run/ │ │ │ │ │ ├── filter.rs │ │ │ │ │ ├── keeper.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── run.rs │ │ │ │ │ └── selector.rs │ │ │ │ ├── sample_config.rs │ │ │ │ ├── self_update.rs │ │ │ │ ├── try_repo.rs │ │ │ │ ├── validate.rs │ │ │ │ └── yaml_to_toml.rs │ │ │ ├── config.rs │ │ │ ├── fs.rs │ │ │ ├── git.rs │ │ │ ├── hook.rs │ │ │ ├── hooks/ │ │ │ │ ├── builtin_hooks/ │ │ │ │ │ ├── check_json5.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── meta_hooks.rs │ │ │ │ ├── mod.rs │ │ │ │ └── pre_commit_hooks/ │ │ │ │ ├── check_added_large_files.rs │ │ │ │ ├── check_case_conflict.rs │ │ │ │ ├── check_executables_have_shebangs.rs │ │ │ │ ├── check_json.rs │ │ │ │ ├── check_merge_conflict.rs │ │ │ │ ├── check_symlinks.rs │ │ │ │ ├── check_toml.rs │ │ │ │ ├── check_xml.rs │ │ │ │ ├── check_yaml.rs │ │ │ │ ├── detect_private_key.rs │ │ │ │ ├── fix_byte_order_marker.rs │ │ │ │ ├── fix_end_of_file.rs │ │ │ │ ├── fix_trailing_whitespace.rs │ │ │ │ ├── mixed_line_ending.rs │ │ │ │ ├── mod.rs │ │ │ │ └── no_commit_to_branch.rs │ │ │ ├── http.rs │ │ │ ├── install_source.rs │ │ │ ├── languages/ │ │ │ │ ├── bun/ │ │ │ │ │ ├── bun.rs │ │ │ │ │ ├── installer.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── version.rs │ │ │ │ ├── deno/ │ │ │ │ │ ├── deno.rs │ │ │ │ │ ├── installer.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── version.rs │ │ │ │ ├── docker.rs │ │ │ │ ├── docker_image.rs │ │ │ │ ├── fail.rs │ │ │ │ ├── golang/ │ │ │ │ │ ├── golang.rs │ │ │ │ │ ├── gomod.rs │ │ │ │ │ ├── installer.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── version.rs │ │ │ │ ├── haskell.rs │ │ │ │ ├── julia.rs │ │ │ │ ├── lua.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── node/ │ │ │ │ │ ├── installer.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── node.rs │ │ │ │ │ └── version.rs │ │ │ │ ├── pygrep/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── pygrep.rs │ │ │ │ │ └── script.py │ │ │ │ ├── python/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── pep723.rs │ │ │ │ │ ├── pyproject.rs │ │ │ │ │ ├── python.rs │ │ │ │ │ ├── uv.rs │ │ │ │ │ └── version.rs │ │ │ │ ├── ruby/ │ │ │ │ │ ├── gem.rs │ │ │ │ │ ├── installer.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── ruby.rs │ │ │ │ │ └── version.rs │ │ │ │ ├── rust/ │ │ │ │ │ ├── installer.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── rust.rs │ │ │ │ │ ├── rustup.rs │ │ │ │ │ └── version.rs │ │ │ │ ├── script.rs │ │ │ │ ├── swift.rs │ │ │ │ ├── system.rs │ │ │ │ └── version.rs │ │ │ ├── main.rs │ │ │ ├── printer.rs │ │ │ ├── process.rs │ │ │ ├── profiler.rs │ │ │ ├── resource_limit.rs │ │ │ ├── run.rs │ │ │ ├── schema.rs │ │ │ ├── snapshots/ │ │ │ │ ├── prek__config__tests__language_version.snap │ │ │ │ ├── prek__config__tests__meta_hooks-5.snap │ │ │ │ ├── prek__config__tests__numeric_rev_is_parsed_as_string.snap │ │ │ │ ├── prek__config__tests__parse_hooks-3.snap │ │ │ │ ├── prek__config__tests__parse_repos-2.snap │ │ │ │ ├── prek__config__tests__parse_repos-3.snap │ │ │ │ ├── prek__config__tests__parse_repos-4.snap │ │ │ │ ├── prek__config__tests__parse_repos-6.snap │ │ │ │ ├── prek__config__tests__parse_repos.snap │ │ │ │ ├── prek__config__tests__read_config_with_merge_keys.snap │ │ │ │ ├── prek__config__tests__read_config_with_nested_merge_keys.snap │ │ │ │ ├── prek__config__tests__read_manifest.snap │ │ │ │ ├── prek__config__tests__read_toml_config.snap │ │ │ │ └── prek__config__tests__read_yaml_config.snap │ │ │ ├── store.rs │ │ │ ├── version.rs │ │ │ ├── warnings.rs │ │ │ ├── workspace.rs │ │ │ └── yaml.rs │ │ └── tests/ │ │ ├── auto_update.rs │ │ ├── builtin_hooks.rs │ │ ├── cache.rs │ │ ├── common/ │ │ │ └── mod.rs │ │ ├── fixtures/ │ │ │ ├── go.yaml │ │ │ ├── issue227.yaml │ │ │ ├── issue253/ │ │ │ │ ├── biome.json │ │ │ │ ├── input.json │ │ │ │ └── issue253.yaml │ │ │ ├── issue265.yaml │ │ │ ├── node-dependencies.yaml │ │ │ ├── node-version.yaml │ │ │ ├── python-version.yaml │ │ │ ├── repeated-repos.yaml │ │ │ ├── uv-pre-commit-config.yaml │ │ │ └── uv-pre-commit-hooks.yaml │ │ ├── hook_impl.rs │ │ ├── identify.rs │ │ ├── install.rs │ │ ├── languages/ │ │ │ ├── bun.rs │ │ │ ├── deno.rs │ │ │ ├── docker.rs │ │ │ ├── docker_image.rs │ │ │ ├── fail.rs │ │ │ ├── golang.rs │ │ │ ├── haskell.rs │ │ │ ├── julia.rs │ │ │ ├── lua.rs │ │ │ ├── main.rs │ │ │ ├── node.rs │ │ │ ├── pygrep.rs │ │ │ ├── python.rs │ │ │ ├── ruby.rs │ │ │ ├── rust.rs │ │ │ ├── script.rs │ │ │ ├── swift.rs │ │ │ ├── unimplemented.rs │ │ │ └── unsupported.rs │ │ ├── list.rs │ │ ├── list_builtins.rs │ │ ├── meta_hooks.rs │ │ ├── run.rs │ │ ├── sample_config.rs │ │ ├── skipped_hooks.rs │ │ ├── try_repo.rs │ │ ├── validate.rs │ │ ├── workspace.rs │ │ └── yaml_to_toml.rs │ ├── prek-consts/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── env_vars.rs │ │ └── lib.rs │ ├── prek-identify/ │ │ ├── Cargo.toml │ │ ├── gen.py │ │ └── src/ │ │ ├── lib.rs │ │ └── tags.rs │ └── prek-pty/ │ ├── Cargo.toml │ ├── LICENSE │ └── src/ │ ├── error.rs │ ├── lib.rs │ ├── pty.rs │ ├── sys.rs │ └── types.rs ├── dist-workspace.toml ├── docs/ │ ├── assets/ │ │ └── badge-v0.json │ ├── authoring-hooks.md │ ├── benchmark.md │ ├── builtin.md │ ├── changelog.md │ ├── cli.md │ ├── compatibility.md │ ├── configuration.md │ ├── debugging.md │ ├── diff.md │ ├── faq.md │ ├── index.md │ ├── installation.md │ ├── integrations.md │ ├── languages.md │ ├── proposals/ │ │ └── concurrency.md │ ├── quickstart.md │ ├── requirements.in │ ├── requirements.txt │ └── workspace.md ├── licenses/ │ ├── LICENSE.identify.txt │ └── LICENSE.pre-commit.txt ├── mise.toml ├── mkdocs.yml ├── prek.schema.json ├── pyproject.toml ├── python/ │ └── prek/ │ ├── __init__.py │ ├── __main__.py │ └── _find_prek.py ├── rust-toolchain.toml └── scripts/ ├── hyperfine-run-benchmarks.sh ├── hyperfine-setup-test-env.sh ├── macports/ │ └── Portfile └── update-macports-portfile.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .config/nextest.toml ================================================ [profile.ci-core] # Exclude language-specific integration tests from the main CI runs (except unimplemented/unsupported/script/fail/pygrep). default-filter = "not binary_id(prek::languages) or (binary_id(prek::languages) and (test(unimplemented::) or test(unsupported::) or test(script::) or test(fail::) or test(pygrep::)))" status-level = "skip" final-status-level = "slow" failure-output = "immediate" fail-fast = false test-threads = 8 [profile.lang-bun] inherits = "ci-core" default-filter = "binary_id(prek::languages) and test(bun::)" [profile.lang-deno] inherits = "ci-core" default-filter = "binary_id(prek::languages) and test(deno::)" [profile.lang-docker] inherits = "ci-core" default-filter = "binary_id(prek::languages) and (test(docker::) or test(docker_image::))" [profile.lang-golang] inherits = "ci-core" default-filter = "binary_id(prek::languages) and test(golang::)" [profile.lang-haskell] inherits = "ci-core" default-filter = "binary_id(prek::languages) and test(haskell::)" [profile.lang-julia] inherits = "ci-core" default-filter = "binary_id(prek::languages) and test(julia::)" [profile.lang-lua] inherits = "ci-core" default-filter = "binary_id(prek::languages) and test(lua::)" # LuaRocks can hit a race condition when multiple processes are installing the same package; run Lua tests serially. threads-required = "num-cpus" [profile.lang-node] inherits = "ci-core" default-filter = "binary_id(prek::languages) and test(node::)" [profile.lang-python] inherits = "ci-core" default-filter = "binary_id(prek::languages) and test(python::)" [profile.lang-ruby] inherits = "ci-core" default-filter = "binary_id(prek::languages) and test(ruby::)" [profile.lang-rust] inherits = "ci-core" default-filter = "binary_id(prek::languages) and test(rust::)" [profile.lang-swift] inherits = "ci-core" default-filter = "binary_id(prek::languages) and test(swift::)" ================================================ FILE: .config/taplo.toml ================================================ [formatting] align_comments = false ================================================ FILE: .devcontainer/devcontainer.json ================================================ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/rust { "name": "prek", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "image": "mcr.microsoft.com/devcontainers/rust:1-1-bookworm", "hostRequirements": { "cpus": 4, "memory": "16gb", "storage": "32gb" }, "features": { "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers-extra/features/mise:1": {}, "ghcr.io/devcontainers-extra/features/uv:1": {}, } // Use 'mounts' to make the cargo cache persistent in a Docker Volume. // "mounts": [ // { // "source": "devcontainer-cargo-cache-${devcontainerId}", // "target": "/usr/local/cargo", // "type": "volume" // } // ] // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. // "postCreateCommand": "rustc --version", // Configure tool-specific properties. // "customizations": {}, // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" } ================================================ FILE: .gitattributes ================================================ # Ensure consistent line endings across platforms (avoid LF -> CRLF on Windows) * text=auto eol=lf prek.schema.json linguist-generated=true scripts/macports/Portfile linguist-generated=true ================================================ FILE: .github/CODEOWNERS ================================================ * @j178 ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yaml ================================================ name: Bug report description: Create a report to help us improve prek body: - type: textarea attributes: label: Summary description: | A clear and concise description of the bug, including a minimal reproducible example. If we cannot reproduce the bug, it is unlikely that we will be able to help you. validations: required: true - type: checkboxes attributes: label: Willing to submit a PR? description: | If you have time, we welcome pull requests. options: - label: Yes — I’m willing to open a PR to fix this. - type: input attributes: label: Platform description: What operating system and architecture are you using? (see `uname -orsm`) placeholder: e.g., macOS 14 arm64, Windows 11 x86_64, Ubuntu 20.04 amd64 validations: required: true - type: input attributes: label: Version description: What version of prek are you using? (see `prek -V`) placeholder: e.g., prek 0.2.3 (7fe75a86d 2025-09-29) validations: required: true - type: textarea attributes: label: .pre-commit-config.yaml description: | Please attach or paste the contents of your `.pre-commit-config.yaml` file if relevant. value: | ```yaml ``` validations: required: true - type: textarea attributes: label: Log file description: | Please attach or paste the contents of the trace log file located at: - Linux/macOS: `~/.cache/prek/prek.log` - Windows: `%LOCALAPPDATA%\prek\prek.log` If the log file doesn't exist or is empty, please run your command with increased verbosity: ```bash prek -vvv [your command] ``` value: | ``` ``` validations: required: true ================================================ FILE: .github/codecov.yml ================================================ coverage: status: project: default: target: auto threshold: 1% informational: true patch: default: target: auto threshold: 1% informational: true ignore: - "tests/**/*" - "examples/**/*" - "target/**/*" - "crates/prek-pty/**/*" - "crates/prek-yaml/**/*" ================================================ FILE: .github/copilot-instructions.md ================================================ # Copilot instructions for `prek` ## Code requirements - Concise, idiomatic Rust (2024 edition). - Proper error handling (no unwraps, panics, etc. in app code). - Clear separation of concerns (e.g., config parsing vs. execution). - Thorough test coverage (unit + integration tests, snapshot testing where appropriate). ## Big picture - Rust workspace under `crates/*`. The main CLI binary is `crates/prek` (`src/main.rs`). - `prek` is a Rust reimplementation of `pre-commit`: configuration parsing lives in `crates/prek/src/config.rs`, execution/dispatch is in `crates/prek/src/run.rs`, and integration tests exercise the CLI end-to-end under `crates/prek/tests/`. - User-facing output is centralized: - Warnings go through `warn_user!` / `warn_user_once!` in `crates/prek/src/warnings.rs` (can be disabled via `-q` / `-qq`). - Progress/output selection is via `Printer` in `crates/prek/src/printer.rs`. - Cross-process coordination uses a store under `$PREK_HOME` (see `Store::from_settings` / `Store::lock_async` in `crates/prek/src/store.rs`). ## Developer workflows (preferred) - Lint/format like CI: `mise run lint` (runs `cargo fmt` + `cargo clippy --all-targets --all-features --workspace -- -D warnings`). - Run all tests: `mise run test` (workspace, all targets/features). - Snapshot-first test workflow (insta): - Unit/bin tests with review UI: `mise run test-unit -- ` or `mise run test-all-unit`. - Integration tests with review UI: `mise run test-integration [filter]` or `mise run test-all-integration`. - DO NOT run `cargo test -p prek` while testing, they are slow. Use `cargo test -p prek --lib -- --exact` (or `cargo test -p prek --bin prek -- --exact`) for unit tests and `cargo test -p prek --test -- ` for integration tests. - Use `cargo insta review --accept` to accept snapshot changes after running tests locally. ## Project-specific conventions - Prefer `fs-err` / `fs-err::tokio` over `std::fs` / `tokio::fs` for filesystem operations (see many modules, e.g. `crates/prek/src/store.rs`). - Prefer `anyhow::Result` for app-level flows and `thiserror` for typed errors when there’s a clear domain (see `crates/prek/src/store.rs`). - Logging uses `tracing`; default behavior is configured in `crates/prek/src/main.rs` and can be overridden via `RUST_LOG`. - CLI is defined with `clap` under `crates/prek/src/cli/` (entry in `crates/prek/src/cli/mod.rs`). If adding a command, keep wiring consistent with existing `cli/*` modules. ## Tests and fixtures - Integration tests use `TestContext` helpers in `crates/prek/tests/common/mod.rs` and snapshot macros like `cmd_snapshot!`. - Tests often normalize paths with regex filters; prefer using `context.filters()` for stable snapshots. ## Docs + generated artifacts - Docs are built with Zensical (see `mkdocs.yml`); run locally via `mise run build-docs`. - CLI reference + JSON schema are generated via `mise run generate` (see tasks in `mise.toml`). ## When changing behavior - If a change affects user-visible output, update the relevant snapshot(s) under `crates/prek/tests/` (use the `cargo insta` review flow). - Keep output stable and routed through existing printer/warning macros rather than printing directly. ================================================ FILE: .github/renovate.json5 ================================================ { $schema: 'https://docs.renovatebot.com/renovate-schema.json', extends: [ 'config:recommended', // https://docs.renovatebot.com/presets-default/#configmigration ':configMigration', // https://docs.renovatebot.com/presets-customManagers/#custommanagersgithubactionsversions // Update _VERSION environment variables in GitHub Action files. 'customManagers:githubActionsVersions', ], schedule: [ '* 1-3 * * 1' ], // release.yml is generated by cargo-dist and should not be updated. ignorePaths: [ '.github/workflows/release.yml' ], prHourlyLimit: 10, labels: [ 'internal' ], semanticCommits: 'disabled', // pre-commit is currently in beta testing, must opt in 'pre-commit': { enabled: true }, enabledManagers: [ 'github-actions', 'pre-commit', 'cargo', 'custom.regex', ], customManagers: [ // Update `uv` version in `crates/prek/src/languages/python/uv.rs` and CI workflows. { customType: 'regex', managerFilePatterns: [ '/src/languages/python/uv.rs$/', '/.github/workflows/.*\\.yml$/', ], datasourceTemplate: 'pypi', packageNameTemplate: 'uv', matchStrings: [ 'const\\s+CUR_UV_VERSION\\s*:\\s*&str\\s*=\\s*"(?\\d+\\.\\d+\\.\\d+)"', 'UV_VERSION:\\s*"(?\\d+\\.\\d+\\.\\d+)"', ] }, // Update major GitHub actions references in documentation. { customType: 'regex', managerFilePatterns: [ '/README\\.md$/', '/^docs/.*\\.md$/' ], matchStrings: [ '\\suses: (?[\\w-]+/[\\w-]+)(?/.*)?@(?.+?)\\s', ], datasourceTemplate: 'github-tags', versioningTemplate: 'regex:^v(?\\d+)$', }, // Minimum supported Rust toolchain version { customType: "regex", managerFilePatterns: ["/(^|/)Cargo\\.toml$/"], matchStrings: [ 'rust-version\\s*=\\s*"(?\\d+\\.\\d+(\\.\\d+)?)"', ], depNameTemplate: "msrv", packageNameTemplate: "rust-lang/rust", datasourceTemplate: "github-releases", }, // Rust toolchain version { customType: "regex", managerFilePatterns: ["/(^|/)rust-toolchain\\.toml$/"], matchStrings: [ 'channel\\s*=\\s*"(?\\d+\\.\\d+(\\.\\d+)?)"', ], depNameTemplate: "rust", packageNameTemplate: "rust-lang/rust", datasourceTemplate: "github-releases", } ], packageRules: [ // Group all GitHub Actions updates together { groupName: 'GitHub Actions', matchManagers: ['github-actions'], matchDepTypes: ['action'], // Pin GitHub Actions to immutable SHAs pinDigests: true }, { groupName: 'pre-commit', matchManagers: ['pre-commit'], matchFileNames: ['.pre-commit-config.yaml'] }, // Annotate GitHub Actions SHAs with a SemVer version. { extends: [ 'helpers:pinGitHubActionDigests' ], extractVersion: '^(?v?\\d+\\.\\d+\\.\\d+)$', versioning: 'regex:^v?(?\\d+)(\\.(?\\d+)\\.(?\\d+))?$' }, // Disable updates for GitHub runners: we'd only pin them to a specific version // if there was a deliberate reason to do so { groupName: 'GitHub runners', matchManagers: [ 'github-actions' ], matchDatasources: [ 'github-runners' ], description: "Disable PRs updating GitHub runners (e.g. 'runs-on: macos-14')", enabled: false }, // Disable updates for the msvc-dev-cmd action, the latest version does not work. { matchManagers: [ "github-actions" ], matchDepTypes: [ "action" ], matchDepNames: [ "ilammy/msvc-dev-cmd" ], enabled: false }, { matchManagers: ["custom.regex"], matchDepNames: ["rust"], commitMessageTopic: "Rust", }, { matchManagers: [ 'cargo', 'pre-commit', 'github-actions', 'custom.regex' ], minimumReleaseAge: '7 days' }, { commitMessageTopic: "MSRV", matchManagers: ["custom.regex"], matchDepNames: ["msrv"], // We have a rolling support policy for the MSRV // 2 releases back * 6 weeks per release * 7 days per week + 1 minimumReleaseAge: "85 days", internalChecksFilter: "strict", groupName: "MSRV", } ] } ================================================ FILE: .github/workflows/build-binaries.yml ================================================ # Build prek on all platforms. # # Generates both wheels (for PyPI) and archived binaries (for GitHub releases). # Assumed to run as a subworkflow of .github/workflows/release.yml; specifically, as a local # artifacts job within `cargo-dist`. # # Adapted from https://github.com/astral-sh/uv/blob/main/.github/workflows/build-binaries.yml name: "Build binaries" on: workflow_call: inputs: plan: required: true type: string pull_request: paths: # When we change pyproject.toml, we want to ensure that the maturin builds still work. - pyproject.toml # And when we change this workflow itself... - .github/workflows/build-binaries.yml permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: CARGO_INCREMENTAL: 0 CARGO_NET_RETRY: 10 CARGO_TERM_COLOR: always RUSTUP_MAX_RETRIES: 10 # renovate: datasource=github-releases depName=PyO3/maturin versioning=semver MATURIN_VERSION: "v1.12.6" jobs: sdist: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Build sdist uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1 with: maturin-version: ${{ env.MATURIN_VERSION }} command: sdist args: --out dist - name: "Upload sdist" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: wheels-sdist path: dist windows: runs-on: windows-latest strategy: matrix: platform: - target: x86_64-pc-windows-msvc arch: x64 - target: i686-pc-windows-msvc arch: x86 - target: aarch64-pc-windows-msvc arch: x64 # not relevant here steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Install NASM" # NASM is required for x86/x86-64 Windows targets by aws-lc-sys. # On aarch64-pc-windows-msvc, it uses clang-cl instead. # See: https://aws.github.io/aws-lc-rs/requirements/windows.html#build-requirements if: contains(matrix.platform.target, 'x86') || contains(matrix.platform.target, 'i686') run: | winget install NASM.NASM --accept-source-agreements --accept-package-agreements echo "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Append - name: "Build wheels" uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1 with: maturin-version: ${{ env.MATURIN_VERSION }} target: ${{ matrix.platform.target }} args: --profile dist --locked --out dist --features self-update sccache: 'true' env: # Disable prebuilt NASM objects so we always compile assembly from source. AWS_LC_SYS_PREBUILT_NASM: "0" - name: "Upload wheels" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: wheels-windows-${{ matrix.platform.target }} path: dist - name: "Archive binary" shell: bash run: | ARCHIVE_FILE=prek-${{ matrix.platform.target }}.zip 7z a $ARCHIVE_FILE ./target/${{ matrix.platform.target }}/dist/prek.exe sha256sum $ARCHIVE_FILE > $ARCHIVE_FILE.sha256 - name: "Upload binary" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: artifacts-${{ matrix.platform.target }} path: | *.zip *.sha256 macos: runs-on: ${{ matrix.platform.runner }} strategy: matrix: platform: - runner: macos-15 target: x86_64-apple-darwin - runner: macos-15 target: aarch64-apple-darwin steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Build wheels" uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1 with: maturin-version: ${{ env.MATURIN_VERSION }} target: ${{ matrix.platform.target }} args: --profile dist --locked --out dist --features self-update sccache: 'true' - name: "Upload wheels" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: wheels-macos-${{ matrix.platform.target }} path: dist - name: "Archive binary" run: | TARGET=${{ matrix.platform.target }} ARCHIVE_NAME=prek-$TARGET ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz mkdir -p $ARCHIVE_NAME cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek tar czvf $ARCHIVE_FILE $ARCHIVE_NAME shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256 - name: "Upload binary" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: artifacts-${{ matrix.platform.target }} path: | *.tar.gz *.sha256 linux: runs-on: ubuntu-latest strategy: matrix: include: - { target: "i686-unknown-linux-gnu", cc: "gcc -m32" } - { target: "x86_64-unknown-linux-gnu", cc: "gcc" } steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Build wheels" uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1 with: maturin-version: ${{ env.MATURIN_VERSION }} target: ${{ matrix.target }} # Generally, we try to build in a target docker container. In this case however, a # 32-bit compiler runs out of memory (4GB memory limit for 32-bit), so we cross compile # from 64-bit version of the container, breaking the pattern from other builds. container: quay.io/pypa/manylinux2014 args: --profile dist --locked --out dist --features self-update # See: https://github.com/sfackler/rust-openssl/issues/2036#issuecomment-1724324145 before-script-linux: | # Install the 32-bit cross target on 64-bit (noop if we're already on 64-bit) rustup target add ${{ matrix.target }} # If we're running on rhel centos, install needed packages. if command -v yum &> /dev/null; then yum update -y && yum install -y perl-core openssl openssl-devel pkgconfig libatomic # If we're running on i686 we need to symlink libatomic # in order to build openssl with -latomic flag. if [[ ! -d "/usr/lib64" ]]; then ln -s /usr/lib/libatomic.so.1 /usr/lib/libatomic.so else # Support cross-compiling from 64-bit to 32-bit yum install -y glibc-devel.i686 libstdc++-devel.i686 fi else # If we're running on debian-based system. apt update -y && apt-get install -y libssl-dev openssl pkg-config fi sccache: 'true' manylinux: auto env: CC: ${{ matrix.cc }} - name: "Upload wheels" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: wheels-linux-${{ matrix.target }} path: dist - name: "Archive binary" shell: bash run: | set -euo pipefail TARGET=${{ matrix.target }} ARCHIVE_NAME=prek-$TARGET ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz mkdir -p $ARCHIVE_NAME cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek tar czvf $ARCHIVE_FILE $ARCHIVE_NAME shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256 - name: "Upload binary" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: artifacts-${{ matrix.target }} path: | *.tar.gz *.sha256 linux-arm: runs-on: ubuntu-latest timeout-minutes: 30 strategy: matrix: platform: - target: aarch64-unknown-linux-gnu arch: aarch64 # see https://github.com/astral-sh/ruff/issues/3791 # and https://github.com/gnzlbg/jemallocator/issues/170#issuecomment-1503228963 maturin_docker_options: -e JEMALLOC_SYS_WITH_LG_PAGE=16 - target: armv7-unknown-linux-gnueabihf arch: armv7 - target: arm-unknown-linux-musleabihf arch: arm steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Build wheels" uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1 with: maturin-version: ${{ env.MATURIN_VERSION }} target: ${{ matrix.platform.target }} # On `aarch64`, use `manylinux: 2_28`; otherwise, use `manylinux: auto`. manylinux: ${{ matrix.platform.arch == 'aarch64' && '2_28' || 'auto' }} docker-options: ${{ matrix.platform.maturin_docker_options }} args: --profile dist --locked --out dist --features self-update - name: "Upload wheels" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: wheels-linux-${{ matrix.platform.target }} path: dist - name: "Archive binary" shell: bash run: | set -euo pipefail TARGET=${{ matrix.platform.target }} ARCHIVE_NAME=prek-$TARGET ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz mkdir -p $ARCHIVE_NAME cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek tar czvf $ARCHIVE_FILE $ARCHIVE_NAME shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256 - name: "Upload binary" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: artifacts-${{ matrix.platform.target }} path: | *.tar.gz *.sha256 linux-s390x: runs-on: ubuntu-latest timeout-minutes: 30 strategy: matrix: platform: - target: s390x-unknown-linux-gnu arch: s390x steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Build wheels" uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1 with: maturin-version: ${{ env.MATURIN_VERSION }} target: ${{ matrix.platform.target }} manylinux: auto args: --profile dist --locked --out dist --features self-update rust-toolchain: ${{ matrix.platform.toolchain || null }} env: CFLAGS_s390x_unknown_linux_gnu: -march=z10 - name: "Upload wheels" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: wheels-linux-${{ matrix.platform.target }} path: dist - name: "Archive binary" shell: bash run: | set -euo pipefail TARGET=${{ matrix.platform.target }} ARCHIVE_NAME=prek-$TARGET ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz mkdir -p $ARCHIVE_NAME cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek tar czvf $ARCHIVE_FILE $ARCHIVE_NAME shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256 - name: "Upload binary" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: artifacts-${{ matrix.platform.target }} path: | *.tar.gz *.sha256 linux-riscv64: runs-on: ubuntu-latest timeout-minutes: 30 strategy: matrix: platform: - target: riscv64gc-unknown-linux-gnu arch: riscv64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Build wheels" uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1 with: maturin-version: ${{ env.MATURIN_VERSION }} target: ${{ matrix.platform.target }} manylinux: auto args: --profile dist --locked --out dist --features self-update - name: "Upload wheels" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: wheels-linux-${{ matrix.platform.target }} path: dist - name: "Archive binary" shell: bash run: | set -euo pipefail TARGET=${{ matrix.platform.target }} ARCHIVE_NAME=prek-$TARGET ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz mkdir -p $ARCHIVE_NAME cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek tar czvf $ARCHIVE_FILE $ARCHIVE_NAME shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256 - name: "Upload binary" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: artifacts-${{ matrix.platform.target }} path: | *.tar.gz *.sha256 musllinux: runs-on: ubuntu-latest strategy: matrix: target: - x86_64-unknown-linux-musl - i686-unknown-linux-musl steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Build wheels" uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1 with: maturin-version: ${{ env.MATURIN_VERSION }} target: ${{ matrix.target }} manylinux: musllinux_1_1 args: --profile dist --locked --out dist --features self-update - name: "Upload wheels" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: wheels-linux-${{ matrix.target }} path: dist - name: "Archive binary" shell: bash run: | set -euo pipefail TARGET=${{ matrix.target }} ARCHIVE_NAME=prek-$TARGET ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz mkdir -p $ARCHIVE_NAME cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek tar czvf $ARCHIVE_FILE $ARCHIVE_NAME shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256 - name: "Upload binary" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: artifacts-${{ matrix.target }} path: | *.tar.gz *.sha256 musllinux-cross: runs-on: ubuntu-latest strategy: matrix: platform: - target: aarch64-unknown-linux-musl arch: aarch64 maturin_docker_options: -e JEMALLOC_SYS_WITH_LG_PAGE=16 - target: armv7-unknown-linux-musleabihf arch: armv7 fail-fast: false steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Build wheels" uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1 with: maturin-version: ${{ env.MATURIN_VERSION }} target: ${{ matrix.platform.target }} manylinux: musllinux_1_1 args: --profile dist --locked --out dist --features self-update ${{ matrix.platform.arch == 'aarch64' && '--compatibility 2_17' || ''}} docker-options: ${{ matrix.platform.maturin_docker_options }} rust-toolchain: ${{ matrix.platform.toolchain || null }} - name: "Upload wheels" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: wheels-linux-${{ matrix.platform.target }} path: dist - name: "Archive binary" shell: bash run: | set -euo pipefail TARGET=${{ matrix.platform.target }} ARCHIVE_NAME=prek-$TARGET ARCHIVE_FILE=$ARCHIVE_NAME.tar.gz mkdir -p $ARCHIVE_NAME cp target/$TARGET/dist/prek $ARCHIVE_NAME/prek tar czvf $ARCHIVE_FILE $ARCHIVE_NAME shasum -a 256 $ARCHIVE_FILE > $ARCHIVE_FILE.sha256 - name: "Upload binary" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: artifacts-${{ matrix.platform.target }} path: | *.tar.gz *.sha256 ================================================ FILE: .github/workflows/build-docker.yml ================================================ # Build and publish a Docker image. # # Assumed to run as a subworkflow of .github/workflows/release.yml; specifically, as a local # artifacts job within `cargo-dist`. # # Adapted from https://github.com/astral-sh/ty/blob/main/.github/workflows/build-docker.yml name: "Build Docker image" on: workflow_call: inputs: plan: required: true type: string pull_request: paths: - .github/workflows/build-docker.yml env: PREK_BASE_IMG: ghcr.io/${{ github.repository_owner }}/prek permissions: contents: read packages: write # zizmor: ignore[excessive-permissions] jobs: docker-build: name: Build Docker image ghcr.io/j178/prek for ${{ matrix.platform }} runs-on: ubuntu-latest environment: name: release strategy: fail-fast: false matrix: platform: - linux/amd64 - linux/arm64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Check tag consistency if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} env: TAG: ${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || 'dry-run' }} run: | version=$(grep -m 1 "^version = " pyproject.toml | sed -e 's/version = "\(.*\)"/\1/g') if [ "${TAG}" != "v${version}" ]; then echo "The input tag does not match the version from pyproject.toml:" >&2 echo "${TAG}" >&2 echo "${version}" >&2 exit 1 else echo "Releasing ${version}" fi - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 env: DOCKER_METADATA_ANNOTATIONS_LEVELS: index with: images: ${{ env.PREK_BASE_IMG }} # Defining this makes sure the org.opencontainers.image.version OCI label becomes the actual release version and not the branch name tags: | type=raw,value=dry-run,enable=${{ inputs.plan == '' || fromJson(inputs.plan).announcement_tag_is_implicit }} type=semver,pattern={{ raw }},value=${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || 'dry-run' }},enable=${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} - name: Normalize Platform Pair (replace / with -) run: | platform=${{ matrix.platform }} echo "PLATFORM_TUPLE=${platform//\//-}" >> "$GITHUB_ENV" # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/ - name: Build and push by digest id: build uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . platforms: ${{ matrix.platform }} cache-from: type=gha,scope=prek-${{ env.PLATFORM_TUPLE }} cache-to: type=gha,mode=min,scope=prek-${{ env.PLATFORM_TUPLE }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=${{ env.PREK_BASE_IMG }},push-by-digest=true,name-canonical=true,push=${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} - name: Export digests env: digest: ${{ steps.build.outputs.digest }} run: | mkdir -p /tmp/digests touch "/tmp/digests/${digest#sha256:}" - name: Upload digests uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: digests-${{ env.PLATFORM_TUPLE }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 docker-publish: name: Publish Docker image (ghcr.io/j178/prek) runs-on: ubuntu-latest environment: name: release needs: - docker-build permissions: contents: read packages: write id-token: write attestations: write if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} steps: - name: Download digests uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: path: /tmp/digests pattern: digests-* merge-multiple: true - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 env: DOCKER_METADATA_ANNOTATIONS_LEVELS: index with: images: ${{ env.PREK_BASE_IMG }} # Order is on purpose such that the label org.opencontainers.image.version has the first pattern with the full version tags: | type=raw,value=${{ fromJson(inputs.plan).announcement_tag }} type=semver,pattern=v{{ major }}.{{ minor }},value=${{ fromJson(inputs.plan).announcement_tag }} - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/ - name: Create manifest list and push working-directory: /tmp/digests # The jq command expands the docker/metadata json "tags" array entry to `-t tag1 -t tag2 ...` for each tag in the array # The printf will expand the base image with the `@sha256: ...` for each sha256 in the directory # The final command becomes `docker buildx imagetools create -t tag1 -t tag2 ... @sha256: @sha256: ...` run: | # shellcheck disable=SC2046 readarray -t lines <<< "$DOCKER_METADATA_OUTPUT_ANNOTATIONS"; annotations=(); for line in "${lines[@]}"; do annotations+=(--annotation "$line"); done docker buildx imagetools create \ "${annotations[@]}" \ $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf "${PREK_BASE_IMG}@sha256:%s " *) - name: Export manifest digest id: manifest-digest env: IMAGE: ${{ env.PREK_BASE_IMG }} VERSION: ${{ steps.meta.outputs.version }} run: | digest="$( docker buildx imagetools inspect \ "${IMAGE}:${VERSION}" \ --format '{{json .Manifest}}' \ | jq -r '.digest' )" echo "digest=${digest}" >> "$GITHUB_OUTPUT" - name: Generate artifact attestation uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-name: ${{ env.PREK_BASE_IMG }} subject-digest: ${{ steps.manifest-digest.outputs.digest }} ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [master] pull_request: workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true permissions: {} env: # UV_VERSION should not greater than MAX_UV_VERSION in `languages/python/uv`. # Otherwise, tests jobs will install their own uv, and it will encounter concurrency issue. UV_VERSION: "0.10.9" NODE_VERSION: "20" BUN_VERSION: "1.3" GO_VERSION: "1.24" PYTHON_VERSION: "3.12" RUBY_VERSION: "3.4" LUA_VERSION: "5.4" LUAROCKS_VERSION: "3.12.2" GHC_VERSION: "9.14.1" # Preinstalled in ubuntu-24.04 runner image CABAL_VERSION: "3.16.1.0" JULIA_VERSION: "1.12.4" DENO_VERSION: "2" # Cargo env vars CARGO_INCREMENTAL: 0 CARGO_NET_RETRY: 10 CARGO_TERM_COLOR: always RUSTUP_MAX_RETRIES: 10 jobs: plan: runs-on: ubuntu-latest outputs: test-code: ${{ !contains(github.event.pull_request.labels.*.name, 'test:skip') && (steps.changed.outputs.any_code_changed == 'true' || github.ref == 'refs/heads/master') }} save-rust-cache: ${{ github.ref == 'refs/heads/master' || steps.changed.outputs.cache_changed == 'true' }} # Run benchmarks if Rust code or benchmark-related files changed run-bench: ${{ !contains(github.event.pull_request.labels.*.name, 'test:skip') && (steps.changed.outputs.rust_code_changed == 'true' || steps.changed.outputs.bench_related_changed == 'true' || github.ref == 'refs/heads/master') }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: false - name: "Determine changed files" id: changed shell: bash run: | CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha || 'origin/master' }}...HEAD) ANY_CODE_CHANGED=false CACHE_CHANGED=false RUST_CODE_CHANGED=false BENCH_RELATED_CHANGED=false while IFS= read -r file; do # Check if cache-relevant files changed (Cargo files, toolchain, workflows) if [[ "${file}" == "Cargo.lock" || "${file}" == "Cargo.toml" || "${file}" == "rust-toolchain.toml" || "${file}" == ".cargo/config.toml" || "${file}" =~ ^crates/.*/Cargo\.toml$ || "${file}" =~ ^\.github/workflows/.*\.yml$ ]]; then echo "Detected cache-relevant change: ${file}" CACHE_CHANGED=true fi # Check if Rust code changed (for benchmarks) if [[ "${file}" =~ \.rs$ ]] || [[ "${file}" =~ Cargo\.toml$ ]] || [[ "${file}" == "Cargo.lock" ]] || [[ "${file}" == "rust-toolchain.toml" ]] || [[ "${file}" =~ ^\.cargo/ ]]; then echo "Detected Rust code change: ${file}" RUST_CODE_CHANGED=true fi if [[ "${file}" == ".github/workflows/performance.yml" ]] || [[ "${file}" =~ ^scripts/hyperfine-.*\.sh$ ]]; then echo "Detected benchmark-related change: ${file}" BENCH_RELATED_CHANGED=true fi if [[ "${file}" =~ ^docs/ ]]; then echo "Skipping ${file} (matches docs/ pattern)" continue fi if [[ "${file}" =~ ^mkdocs.*\.yml$ ]]; then echo "Skipping ${file} (matches mkdocs*.yml pattern)" continue fi if [[ "${file}" =~ \.md$ ]]; then echo "Skipping ${file} (matches *.md pattern)" continue fi echo "Detected code change in: ${file}" ANY_CODE_CHANGED=true done <<< "${CHANGED_FILES}" echo "any_code_changed=${ANY_CODE_CHANGED}" >> "${GITHUB_OUTPUT}" echo "cache_changed=${CACHE_CHANGED}" >> "${GITHUB_OUTPUT}" echo "rust_code_changed=${RUST_CODE_CHANGED}" >> "${GITHUB_OUTPUT}" echo "bench_related_changed=${BENCH_RELATED_CHANGED}" >> "${GITHUB_OUTPUT}" lint: name: "lint" timeout-minutes: 10 runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Install Rustfmt" run: rustup component add rustfmt - name: "rustfmt" run: cargo fmt --all --check - name: Run prek checks uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0 env: PREK_SKIP: cargo-fmt,cargo-clippy check-release: name: "check release" needs: plan timeout-minutes: 10 runs-on: ubuntu-latest env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Install dist shell: bash run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" - name: Run dist plan run: | dist plan --output-format=json > plan-dist-manifest.json echo "dist plan completed successfully" cat plan-dist-manifest.json cargo-clippy-linux: name: "cargo clippy | ubuntu" needs: plan if: ${{ needs.plan.outputs.test-code == 'true' }} timeout-minutes: 10 runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }} - name: "Install Rust toolchain" run: rustup component add clippy - name: "Clippy" run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings cargo-clippy-windows: name: "cargo clippy | windows" needs: plan if: ${{ needs.plan.outputs.test-code == 'true' }} timeout-minutes: 15 runs-on: windows-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Create Dev Drive run: ${{ github.workspace }}/.github/workflows/setup-dev-drive.ps1 # actions/checkout does not let us clone into anywhere outside ${{ github.workspace }}, so we have to copy the clone... - name: Copy Git Repo to Dev Drive run: | Copy-Item -Path "${{ github.workspace }}" -Destination "$Env:PREK_WORKSPACE" -Recurse - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: ${{ env.PREK_WORKSPACE }} save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }} - name: "Install Rust toolchain" run: rustup component add clippy - name: "Clippy" working-directory: ${{ env.PREK_WORKSPACE }} run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings cargo-shear: name: "cargo shear" needs: plan if: ${{ needs.plan.outputs.test-code == 'true' }} timeout-minutes: 10 runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Install cargo shear" uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26 with: tool: cargo-shear - run: cargo shear cargo-test-without-uv: needs: plan if: ${{ needs.plan.outputs.test-code == 'true' }} timeout-minutes: 5 runs-on: ubuntu-latest name: "cargo test | without uv" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }} - name: "Install Rust toolchain" run: rustup component add llvm-tools-preview - name: "Install cargo nextest" uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26 with: tool: cargo-nextest - name: "Install cargo-llvm-cov" uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26 with: tool: cargo-llvm-cov - name: "Cargo test without uv" run: | echo "::group::Test install uv with auto select" cargo llvm-cov nextest \ --profile ci-core \ --cargo-profile fast-build \ --no-report \ -E 'binary_id(prek::run) and test(run_basic)' echo "::endgroup::" for source in github pypi aliyun pip invalid; do echo "::group::Test install uv from $source" export PREK_UV_SOURCE=$source cargo llvm-cov nextest \ --profile ci-core \ --cargo-profile fast-build \ --no-report \ -E 'binary_id(prek::run) and test(run_basic)' echo "::endgroup::" done cargo llvm-cov report --profile fast-build --lcov --output-path lcov.info - name: "Upload coverage reports to Codecov" uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: lcov.info cargo-test: needs: plan if: ${{ needs.plan.outputs.test-code == 'true' }} timeout-minutes: 10 runs-on: ${{ matrix.os }} name: "cargo test | ${{ matrix.os }}" strategy: fail-fast: false matrix: os: - ubuntu-latest - macos-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 if: ${{ matrix.os == 'ubuntu-latest' }} - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }} - name: "Install Rust toolchain" run: rustup component add llvm-tools-preview - name: "Install cargo nextest" uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26 with: tool: cargo-nextest - name: "Install cargo-llvm-cov" uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26 with: tool: cargo-llvm-cov - name: "Install uv" uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 with: version: ${{ env.UV_VERSION }} - name: "Install Python" uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.PYTHON_VERSION }} - name: "Cargo test" run: | cargo llvm-cov nextest \ --lcov \ --output-path lcov.info \ --workspace \ --cargo-profile fast-build \ --profile ci-core \ --features schemars - name: "Upload coverage reports to Codecov" uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: lcov.info cargo-test-windows: name: "cargo test | windows" needs: plan if: ${{ needs.plan.outputs.test-code == 'true' }} runs-on: windows-latest timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Create Dev Drive run: ${{ github.workspace }}/.github/workflows/setup-dev-drive.ps1 # actions/checkout does not let us clone into anywhere outside ${{ github.workspace }}, so we have to copy the clone... - name: Copy Git Repo to Dev Drive run: | Copy-Item -Path "${{ github.workspace }}" -Destination "$Env:PREK_WORKSPACE" -Recurse - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: ${{ env.PREK_WORKSPACE }} save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }} - name: "Install Rust toolchain" run: rustup component add llvm-tools-preview - name: "Install cargo nextest" uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26 with: tool: cargo-nextest - name: "Install cargo-llvm-cov" uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26 with: tool: cargo-llvm-cov - name: "Install uv" uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 with: version: ${{ env.UV_VERSION }} cache-local-path: ${{ env.DEV_DRIVE }}/uv-cache - name: "Install Python" uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.PYTHON_VERSION }} - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # windows only - name: "Cargo test" working-directory: ${{ env.PREK_WORKSPACE }} shell: pwsh run: | # Remove msys64 from PATH for Rust compilation $env:PATH = ($env:PATH -split ';' | Where-Object { $_ -notmatch '\\msys64\\' }) -join ';' cargo llvm-cov nextest ` --lcov ` --output-path lcov.info ` --workspace ` --cargo-profile fast-build ` --features schemars ` --profile ci-core if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: "Upload coverage reports to Codecov" uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: ${{ env.PREK_WORKSPACE }}/lcov.info language-tests: name: "language tests | ${{ matrix.language }} | ${{ matrix.os }}" needs: plan if: ${{ needs.plan.outputs.test-code == 'true' }} timeout-minutes: 15 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: - ubuntu-latest - macos-latest - windows-latest language: - bun - deno - docker - golang - haskell - julia - lua - node - python - ruby - rust - swift exclude: # Docker is only available on ubuntu-latest - os: macos-latest language: docker - os: windows-latest language: docker # Swift is preinstalled on ubuntu and macOS; Windows requires setup which is slow - os: windows-latest language: swift # GHC is not preinstalled on macOS, and installing it takes a long time - os: macos-latest language: haskell steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 if: ${{ matrix.os == 'ubuntu-latest' }} - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }} - name: "Install Rust toolchain" run: rustup component add llvm-tools-preview - name: "Install cargo nextest" uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26 with: tool: cargo-nextest - name: "Install cargo-llvm-cov" uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26 with: tool: cargo-llvm-cov - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # windows only if: ${{ matrix.os == 'windows-latest' }} - name: "Install uv" if: ${{ matrix.language == 'python' }} uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 with: version: ${{ env.UV_VERSION }} - name: "Install Python" if: ${{ matrix.language == 'python' }} uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.PYTHON_VERSION }} - name: "Install Node.js" if: ${{ matrix.language == 'node' }} uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: ${{ env.NODE_VERSION }} cache: npm # Dummy dependency path to satisfy required input while enabling caching cache-dependency-path: LICENSE - name: "Install Go" if: ${{ matrix.language == 'golang' }} uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: ${{ env.GO_VERSION }} # Dummy dependency path to satisfy required input while enabling caching cache-dependency-path: LICENSE - name: "Install Lua" if: ${{ matrix.language == 'lua' }} uses: leafo/gh-actions-lua@8c9e175e7a3d77e21f809eefbee34a19b858641b # v12 with: luaVersion: ${{ env.LUA_VERSION }} - name: "Install LuaRocks" if: ${{ matrix.language == 'lua' }} uses: luarocks/gh-actions-luarocks@7c85eeff60655651b444126f2a78be784e836a0a #v6 with: luaRocksVersion: ${{ env.LUAROCKS_VERSION }} - name: "Install Ruby" if: ${{ matrix.language == 'ruby' }} uses: ruby/setup-ruby@6ca151fd1bfcfd6fe0c4eb6837eb0584d0134a0c # v1.290.0 with: ruby-version: ${{ env.RUBY_VERSION }} - name: "Install Bun" if: ${{ matrix.language == 'bun' }} uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2.1.3 with: bun-version: ${{ env.BUN_VERSION }} - name: "Install Deno" if: ${{ matrix.language == 'deno' }} uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3 with: deno-version: ${{ env.DENO_VERSION }} - name: "Install GHC and Cabal" if: ${{ matrix.language == 'haskell' }} uses: haskell-actions/setup@f9150cb1d140e9a9271700670baa38991e6fa25c # v2.10.3 with: ghc-version: ${{ env.GHC_VERSION }} cabal-version: ${{ env.CABAL_VERSION }} - name: "Install Julia" if: ${{ matrix.language == 'julia' }} uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7.0 with: version: ${{ env.JULIA_VERSION }} - name: "Run language tests" if: ${{ matrix.os != 'windows-latest' }} env: # Ruby auto_download test queries the GitHub Releases API; without a # token, shared runners quickly hit the 60 req/hour rate limit. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | cargo llvm-cov nextest \ --lcov \ --output-path lcov.info \ --workspace \ --cargo-profile fast-build \ --features schemars \ --profile lang-${{ matrix.language }} - name: "Run language tests (windows)" if: ${{ matrix.os == 'windows-latest' }} shell: pwsh env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # Remove msys64 from PATH for Rust compilation $env:PATH = ($env:PATH -split ';' | Where-Object { $_ -notmatch '\\msys64\\' }) -join ';' cargo llvm-cov nextest ` --lcov ` --output-path lcov.info ` --workspace ` --cargo-profile fast-build ` --features schemars ` --profile lang-${{ matrix.language }} if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: "Upload coverage reports to Codecov" uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: lcov.info performance: needs: plan if: ${{ needs.plan.outputs.run-bench == 'true' }} uses: ./.github/workflows/performance.yml with: save-rust-cache: ${{ needs.plan.outputs.save-rust-cache }} ecosystem-cpython: name: "ecosystem | cpython" needs: plan if: ${{ needs.plan.outputs.test-code == 'true' }} timeout-minutes: 10 runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Checkout python/cpython uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: python/cpython ref: f3759d21dd5e6510361d7409a1df53f35ebd9a58 path: cpython fetch-depth: 1 persist-credentials: false - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }} - name: Run prek on cpython working-directory: cpython run: cargo run -p prek -- --all-files build-binary-msrv: needs: plan if: ${{ needs.plan.outputs.test-code == 'true' }} name: "build binary | msrv" runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Read MSRV from Cargo.toml" id: msrv run: | MSRV=$(grep -m1 'rust-version' Cargo.toml | sed 's/.*"\([^"]*\)".*/\1/') echo "value=$MSRV" >> "$GITHUB_OUTPUT" - name: "Install Rust toolchain" run: rustup default ${MSRV} env: MSRV: ${{ steps.msrv.outputs.value }} - name: "Install mold" uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }} - run: cargo +${MSRV} build --profile no-debug --bin prek env: MSRV: ${{ steps.msrv.outputs.value }} - run: ./target/no-debug/prek --version build-binary-linux-libc: needs: plan if: ${{ needs.plan.outputs.test-code == 'true' }} timeout-minutes: 10 runs-on: ubuntu-latest name: "build binary | linux libc" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }} - name: "Build" run: cargo build --profile no-debug --bin prek - name: "Upload binary" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: prek-linux-libc-${{ github.sha }} path: | ./target/no-debug/prek retention-days: 1 build-binary-macos-aarch64: needs: plan if: ${{ needs.plan.outputs.test-code == 'true' }} timeout-minutes: 10 runs-on: macos-latest name: "build binary | macos aarch64" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }} - name: "Build" run: cargo build --profile no-debug --bin prek - name: "Upload binary" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: prek-macos-aarch64-${{ github.sha }} path: | ./target/no-debug/prek retention-days: 1 build-binary-windows-x86_64: needs: plan if: ${{ needs.plan.outputs.test-code == 'true' }} timeout-minutes: 10 runs-on: windows-latest name: "build binary | windows x86_64" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Setup Dev Drive run: ${{ github.workspace }}/.github/workflows/setup-dev-drive.ps1 # actions/checkout does not let us clone into anywhere outside ${{ github.workspace }}, so we have to copy the clone... - name: Copy Git Repo to Dev Drive run: | Copy-Item -Path "${{ github.workspace }}" -Destination "$Env:PREK_WORKSPACE" -Recurse - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: ${{ env.PREK_WORKSPACE }} save-if: ${{ needs.plan.outputs.save-rust-cache == 'true' }} - name: "Build" working-directory: ${{ env.PREK_WORKSPACE }} run: cargo build --profile no-debug --bin prek - name: "Upload binary" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: prek-windows-x86_64-${{ github.sha }} path: | ${{ env.PREK_WORKSPACE }}/target/no-debug/prek.exe retention-days: 1 ================================================ FILE: .github/workflows/performance.yml ================================================ name: Performance on: workflow_call: inputs: save-rust-cache: required: false type: string default: "true" permissions: {} env: # Cargo env vars CARGO_INCREMENTAL: 0 CARGO_NET_RETRY: 10 CARGO_TERM_COLOR: always RUSTUP_MAX_RETRIES: 10 concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: bloat-check: runs-on: ubuntu-latest name: "bloat check" timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false fetch-depth: 0 - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: save-if: ${{ inputs.save-rust-cache == 'true' }} - uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26 with: tool: cargo-bloat - name: Build head branch id: bloat_head run: | # Build head branch cargo bloat --release | tee bloat_head.txt >&1 - name: Checkout base run: | git checkout ${{ github.event.pull_request.base.sha }} - name: Build base branch id: bloat_base run: | # Build base branch cargo bloat --release | tee bloat_base.txt >&1 - name: Compare bloat results shell: python run: | import re from pathlib import Path def parse_size(text): match = re.search(r'\.text section size.*?([\d.]+)\s*([KMGT]i?B)', text) if not match: raise ValueError("Could not find .text section size") value, unit = float(match.group(1)), match.group(2) multipliers = {'B': 1, 'KiB': 1024, 'MiB': 1024**2, 'GiB': 1024**3, 'TiB': 1024**4} size = value * multipliers.get(unit, 1) return size, f"{value} {unit}" head_text = Path('bloat_head.txt').read_text() base_text = Path('bloat_base.txt').read_text() head_bytes, head_size = parse_size(head_text) base_bytes, base_size = parse_size(base_text) pct_change = ((head_bytes - base_bytes) / base_bytes) * 100 pct_display = f"{pct_change:+.2f}%" comparison = f"""\ ### 📦 Cargo Bloat Comparison **Binary size change:** {pct_display} ({base_size} → {head_size})
Expand for cargo-bloat output #### Head Branch Results ``` {head_text} ``` #### Base Branch Results ``` {base_text} ```
""" Path("bloat-comparison.txt").write_text(comparison) - name: Upload bloat results uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: # NOTE: https://github.com/j178/prek-ci-bot uses this artifact name to post comments on PRs. # Make sure to update the bot if you rename the artifact. name: bloat-check-results path: bloat-comparison.txt hyperfine-benchmark: runs-on: ubuntu-latest name: "hyperfine benchmark" timeout-minutes: 10 env: HYPERFINE_BENCHMARK_WORKSPACE: /tmp/prek-bench HYPERFINE_BIN_DIR: ${{ github.workspace }}/.hyperfine-bin HYPERFINE_RESULTS_FILE: ${{ github.workspace }}/hyperfine-benchmark.md HYPERFINE_HEAD_BINARY: prek-head HYPERFINE_BASE_BINARY: prek-base steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: false - name: Add hyperfine bin dir to PATH run: | mkdir -p "$HYPERFINE_BIN_DIR" echo "$HYPERFINE_BIN_DIR" >> "$GITHUB_PATH" - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: save-if: ${{ inputs.save-rust-cache == 'true' }} - id: base-binary-cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ${{ env.HYPERFINE_BIN_DIR }}/${{ env.HYPERFINE_BASE_BINARY }} key: prek-hyperfine-base-${{ github.event.pull_request.base.sha }}-${{ hashFiles('Cargo.lock') }}-${{ runner.os }}-${{ runner.arch }} - name: Build base version if: ${{ steps.base-binary-cache.outputs.cache-hit != 'true' }} env: BASE_VERSION: ${{ github.event.pull_request.base.sha }} run: | git checkout ${{ github.event.pull_request.base.sha }} cargo build --profile profiling && mv target/profiling/prek "$HYPERFINE_BIN_DIR/$HYPERFINE_BASE_BINARY" git checkout ${{ github.sha }} - name: Build head version run: | cargo build --profile profiling && mv target/profiling/prek "$HYPERFINE_BIN_DIR/$HYPERFINE_HEAD_BINARY" - name: Install hyperfine uses: taiki-e/install-action@64c5c20c872907b6f7cd50994ac189e7274160f2 # v2.68.26 with: tool: hyperfine - name: Setup test environment for builtin hooks run: scripts/hyperfine-setup-test-env.sh - name: Run benchmarks run: scripts/hyperfine-run-benchmarks.sh - name: Upload benchmark results uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: # NOTE: https://github.com/j178/prek-ci-bot uses this artifact name to post comments on PRs. # Make sure to update the bot if you rename the artifact. name: hyperfine-benchmark-results path: ${{ env.HYPERFINE_RESULTS_FILE }} ================================================ FILE: .github/workflows/publish-crates.yml ================================================ name: "Publish to crates.io" on: workflow_call: inputs: plan: required: true type: string jobs: publish: name: Upload to crates.io runs-on: ubuntu-latest environment: name: release permissions: # For crates.io's trusted publishing. id-token: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: save-if: ${{ github.ref == 'refs/heads/master' }} - name: "Install Rust toolchain" run: rustup show - uses: rust-lang/crates-io-auth-action@b7e9a28eded4986ec6b1fa40eeee8f8f165559ec # v1.0.3 id: auth - name: "Publish to crates.io" run: cargo publish --workspace env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} ================================================ FILE: .github/workflows/publish-docs.yml ================================================ name: Deploy Documentation on: workflow_dispatch: inputs: ref: description: "The commit SHA, tag, or branch to publish. Uses the default branch if not specified." default: "" type: string workflow_call: inputs: plan: required: true type: string concurrency: group: "pages" cancel-in-progress: false permissions: {} env: UV_VERSION: "0.10.9" # Match version used to compile `docs/requirements.txt` PYTHON_VERSION: "3.14" jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: false - uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 with: version: ${{ env.UV_VERSION }} python-version: ${{ env.PYTHON_VERSION }} cache-dependency-glob: | **/docs/requirements.txt - name: Build documentation run: | uvx --with-requirements docs/requirements.txt zensical build uvx --with-requirements docs/requirements.txt llmstxt-standalone build - name: Upload artifact uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 with: path: ./site deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} permissions: contents: read pages: write id-token: write runs-on: ubuntu-latest needs: build if: github.ref == 'refs/heads/master' steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 ================================================ FILE: .github/workflows/publish-homebrew.yml ================================================ name: "Publish Homebrew formula" on: workflow_call: inputs: plan: required: true type: string secrets: HOMEBREW_TAP_TOKEN: required: true jobs: publish: name: Publish Homebrew formula runs-on: ubuntu-latest environment: name: release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PLAN: ${{ inputs.plan }} GITHUB_USER: "axo bot" GITHUB_EMAIL: "admin+bot@axo.dev" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: true repository: "j178/homebrew-tap" token: ${{ secrets.HOMEBREW_TAP_TOKEN }} - name: Fetch homebrew formulae uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: pattern: artifacts-* path: Formula/ merge-multiple: true - name: Commit formula files run: | git config --global user.name "${GITHUB_USER}" git config --global user.email "${GITHUB_EMAIL}" for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) name=$(echo "$filename" | sed "s/\.rb$//") version=$(echo "$release" | jq .app_version --raw-output) export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" brew update # Avoid reformatting user-provided metadata such as homepage and description. brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true git add "Formula/${filename}" git commit -m "${name} ${version}" done git push ================================================ FILE: .github/workflows/publish-npm.yml ================================================ name: "Publish to npmjs registry" on: workflow_call: inputs: plan: required: true type: string jobs: publish: name: Upload to npmjs registry runs-on: ubuntu-latest environment: name: release permissions: # For npm's trusted publishing. id-token: write packages: write env: PLAN: ${{ inputs.plan }} steps: - name: Fetch npm packages uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: pattern: artifacts-build-global path: npm/ merge-multiple: true - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: "24.x" registry-url: "https://registry.npmjs.org" - name: Update npm run: npm install -g npm@latest - run: | for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith("-npm-package.tar.gz")] | any)'); do pkg=$(echo "$release" | jq '.artifacts[] | select(endswith("-npm-package.tar.gz"))' --raw-output) prerelease=$(echo "$PLAN" | jq ".announcement_is_prerelease") if [ "$prerelease" = "true" ]; then npm publish --tag beta --provenance --access public "./npm/${pkg}" else npm publish --provenance --access public "./npm/${pkg}" fi done ================================================ FILE: .github/workflows/publish-prek-action.yml ================================================ name: "Publish prek-action known versions" on: workflow_call: inputs: plan: required: true type: string secrets: PREK_ACTION_TOKEN: required: true permissions: {} jobs: publish: name: Publish prek-action known versions runs-on: ubuntu-latest environment: name: release env: PLAN: ${{ inputs.plan }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: j178/prek-action ref: main token: ${{ secrets.PREK_ACTION_TOKEN }} persist-credentials: false - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 24 cache: npm - name: Install dependencies run: npm ci - name: Update known versions id: refresh env: GITHUB_TOKEN: ${{ secrets.PREK_ACTION_TOKEN }} run: npm run update-known-versions - name: Check whether known version files changed id: changes run: | if git diff --quiet -- version-manifest.json src/known-checksums.ts; then echo "known_versions_changed=false" >> "$GITHUB_OUTPUT" else echo "known_versions_changed=true" >> "$GITHUB_OUTPUT" fi - name: Run tests if: steps.changes.outputs.known_versions_changed == 'true' run: npm test - name: Rebuild dist if: steps.changes.outputs.known_versions_changed == 'true' run: npm run bundle - name: Create pull request id: cpr if: steps.changes.outputs.known_versions_changed == 'true' uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: token: ${{ secrets.PREK_ACTION_TOKEN }} add-paths: | version-manifest.json src/known-checksums.ts dist/index.cjs dist/post/index.cjs branch: automation/update-known-versions base: main commit-message: ${{ steps.refresh.outputs.pr_title }} title: ${{ steps.refresh.outputs.pr_title }} body: | Automated update from the prek release workflow. Added releases: ${{ steps.refresh.outputs.added_versions_markdown }} - name: Merge pull request if: steps.cpr.outputs.pull-request-number != '' env: GH_TOKEN: ${{ secrets.PREK_ACTION_TOKEN }} PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} shell: bash run: | sleep 10 gh pr merge --squash --repo j178/prek-action "$PR_NUMBER" ================================================ FILE: .github/workflows/publish-pypi.yml ================================================ name: "Publish to PyPI" on: workflow_call: inputs: plan: required: true type: string jobs: publish: name: Upload to PyPI runs-on: ubuntu-latest environment: name: release permissions: # For PyPI's trusted publishing. id-token: write steps: - name: "Install uv" uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: pattern: wheels-* path: wheels merge-multiple: true - name: Publish to PyPi run: uv publish -v wheels/* ================================================ FILE: .github/workflows/publish-winget.yml ================================================ name: "Publish to winget" on: workflow_dispatch: inputs: tag: description: "The release tag to publish (e.g., vX.Y.Z)." required: true type: string workflow_call: inputs: plan: required: true type: string secrets: WINGET_TOKEN: required: true permissions: {} jobs: winget: name: Publish to winget runs-on: ubuntu-latest environment: name: release if: >- ${{ inputs.plan == '' || !fromJson(inputs.plan).announcement_is_prerelease }} steps: - name: Determine release tag id: tag env: INPUT_TAG: ${{ inputs.tag }} PLAN_TAG: ${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || '' }} run: | if [ -n "$INPUT_TAG" ]; then echo "value=$INPUT_TAG" >> "$GITHUB_OUTPUT" else echo "value=$PLAN_TAG" >> "$GITHUB_OUTPUT" fi shell: bash - name: Publish to winget uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 with: identifier: j178.Prek release-tag: ${{ steps.tag.outputs.value }} installers-regex: 'prek-.*windows.*\.zip$' token: ${{ secrets.WINGET_TOKEN }} fork-user: prek-bot ================================================ FILE: .github/workflows/release.yml ================================================ # This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist # # Copyright 2022-2024, axodotdev # SPDX-License-Identifier: MIT or Apache-2.0 # # CI that: # # * checks for a Git Tag that looks like a release # * builds artifacts with dist (archives, installers, hashes) # * uploads those artifacts to temporary workflow zip # * on success, uploads the artifacts to a GitHub Release # # Note that the GitHub Release will be created with a generated # title/body based on your changelogs. name: Release permissions: "contents": "write" # This task will run whenever you workflow_dispatch with a tag that looks like a version # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION # must be a Cargo-style SemVer Version (must have at least major.minor.patch). # # If PACKAGE_NAME is specified, then the announcement will be for that # package (erroring out if it doesn't have the given version or isn't dist-able). # # If PACKAGE_NAME isn't specified, then the announcement will be for all # (dist-able) packages in the workspace with that version (this mode is # intended for workspaces with only one dist-able package, or with all dist-able # packages versioned/released in lockstep). # # If you push multiple tags at once, separate instances of this workflow will # spin up, creating an independent announcement for each one. However, GitHub # will hard limit this to 3 tags per commit, as it will assume more tags is a # mistake. # # If there's a prerelease-style suffix to the version, then the release(s) # will be marked as a prerelease. on: workflow_dispatch: inputs: tag: description: Release Tag required: true default: dry-run type: string jobs: # Run 'dist plan' (or host) to determine what tasks we need to do plan: runs-on: "ubuntu-latest" outputs: val: ${{ steps.plan.outputs.manifest }} tag: ${{ (inputs.tag != 'dry-run' && inputs.tag) || '' }} tag-flag: ${{ inputs.tag && inputs.tag != 'dry-run' && format('--tag={0}', inputs.tag) || '' }} publishing: ${{ inputs.tag && inputs.tag != 'dry-run' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive - name: Install dist # we specify bash to get pipefail; it guards against the `curl` command # failing. otherwise `sh` won't catch that `curl` returned non-0 shell: bash run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" - name: Cache dist uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: cargo-dist-cache path: ~/.cargo/bin/dist # sure would be cool if github gave us proper conditionals... # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible # functionality based on whether this is a pull_request, and whether it's from a fork. # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* # but also really annoying to build CI around when it needs secrets to work right.) - id: plan run: | dist ${{ (inputs.tag && inputs.tag != 'dry-run' && format('host --steps=create --tag={0}', inputs.tag)) || 'plan' }} --output-format=json > plan-dist-manifest.json echo "dist ran successfully" cat plan-dist-manifest.json echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: artifacts-plan-dist-manifest path: plan-dist-manifest.json custom-build-binaries: needs: - plan if: ${{ needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload' || inputs.tag == 'dry-run' }} uses: ./.github/workflows/build-binaries.yml with: plan: ${{ needs.plan.outputs.val }} secrets: inherit custom-build-docker: needs: - plan if: ${{ needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload' || inputs.tag == 'dry-run' }} uses: ./.github/workflows/build-docker.yml with: plan: ${{ needs.plan.outputs.val }} secrets: inherit permissions: "attestations": "write" "contents": "read" "id-token": "write" "packages": "write" # Build and package all the platform-agnostic(ish) things build-global-artifacts: needs: - plan - custom-build-binaries - custom-build-docker runs-on: "ubuntu-latest" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive - name: Install cached dist uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Get all the local artifacts for the global tasks to use (for e.g. checksums) - name: Fetch local artifacts uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 with: pattern: artifacts-* path: target/distrib/ merge-multiple: true - id: cargo-dist shell: bash run: | dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json echo "dist ran successfully" # Parse out what we just built and upload it to scratch storage echo "paths<> "$GITHUB_OUTPUT" jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: artifacts-build-global path: | ${{ steps.cargo-dist.outputs.paths }} ${{ env.BUILD_MANIFEST_NAME }} # Determines if we should publish/announce host: needs: - plan - custom-build-binaries - custom-build-docker - build-global-artifacts # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.custom-build-binaries.result == 'skipped' || needs.custom-build-binaries.result == 'success') && (needs.custom-build-docker.result == 'skipped' || needs.custom-build-docker.result == 'success') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} runs-on: "ubuntu-latest" outputs: val: ${{ steps.host.outputs.manifest }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive - name: Install cached dist uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Fetch artifacts from scratch-storage - name: Fetch artifacts uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 with: pattern: artifacts-* path: target/distrib/ merge-multiple: true # This is a harmless no-op for GitHub Releases, hosting for that happens in "announce" - id: host shell: bash run: | dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json echo "artifacts uploaded and released successfully" cat dist-manifest.json echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: # Overwrite the previous copy name: artifacts-dist-manifest path: dist-manifest.json custom-publish-crates: needs: - plan - host if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} uses: ./.github/workflows/publish-crates.yml with: plan: ${{ needs.plan.outputs.val }} secrets: inherit # publish jobs get escalated permissions permissions: "id-token": "write" custom-publish-pypi: needs: - plan - host if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} uses: ./.github/workflows/publish-pypi.yml with: plan: ${{ needs.plan.outputs.val }} secrets: inherit # publish jobs get escalated permissions permissions: "id-token": "write" custom-publish-npm: needs: - plan - host if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} uses: ./.github/workflows/publish-npm.yml with: plan: ${{ needs.plan.outputs.val }} secrets: inherit # publish jobs get escalated permissions permissions: "id-token": "write" # Create a GitHub Release while uploading all files to it announce: needs: - plan - host - custom-publish-crates - custom-publish-pypi - custom-publish-npm # use "always() && ..." to allow us to wait for all publish jobs while # still allowing individual publish jobs to skip themselves (for prereleases). # "host" however must run to completion, no skipping allowed! if: ${{ always() && needs.host.result == 'success' && (needs.custom-publish-crates.result == 'skipped' || needs.custom-publish-crates.result == 'success') && (needs.custom-publish-pypi.result == 'skipped' || needs.custom-publish-pypi.result == 'success') && (needs.custom-publish-npm.result == 'skipped' || needs.custom-publish-npm.result == 'success') }} runs-on: "ubuntu-latest" permissions: "attestations": "write" "contents": "write" "id-token": "write" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive # Create a GitHub Release while uploading all files to it - name: "Download GitHub Artifacts" uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 with: pattern: artifacts-* path: artifacts merge-multiple: true - name: Cleanup run: | # Remove the granular manifests rm -f artifacts/*-dist-manifest.json - name: Attest uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 with: subject-path: | artifacts/* - name: Create GitHub Release env: PRERELEASE_FLAG: "${{ fromJson(needs.host.outputs.val).announcement_is_prerelease && '--prerelease' || '' }}" ANNOUNCEMENT_TITLE: "${{ fromJson(needs.host.outputs.val).announcement_title }}" ANNOUNCEMENT_BODY: "${{ fromJson(needs.host.outputs.val).announcement_github_body }}" RELEASE_COMMIT: "${{ github.sha }}" run: | # Write and read notes from a file to avoid quoting breaking things echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* custom-publish-docs: needs: - plan - announce uses: ./.github/workflows/publish-docs.yml with: plan: ${{ needs.plan.outputs.val }} secrets: inherit permissions: "contents": "read" "id-token": "write" "pages": "write" custom-publish-homebrew: needs: - plan - announce uses: ./.github/workflows/publish-homebrew.yml with: plan: ${{ needs.plan.outputs.val }} secrets: inherit custom-publish-prek-action: needs: - plan - announce uses: ./.github/workflows/publish-prek-action.yml with: plan: ${{ needs.plan.outputs.val }} secrets: inherit custom-publish-winget: needs: - plan - announce uses: ./.github/workflows/publish-winget.yml with: plan: ${{ needs.plan.outputs.val }} secrets: inherit ================================================ FILE: .github/workflows/setup-dev-drive.ps1 ================================================ # This creates a 10GB dev drive, and exports all required environment # variables so that rustup, prek and others all use the dev drive as much # as possible. # $Volume = New-VHD -Path C:/prek_dev_drive.vhdx -SizeBytes 10GB | # Mount-VHD -Passthru | # Initialize-Disk -Passthru | # New-Partition -AssignDriveLetter -UseMaximumSize | # Format-Volume -FileSystem ReFS -Confirm:$false -Force # # Write-Output $Volume $Drive = "D:" $Tmp = "$($Drive)\prek-tmp" # Create the directory ahead of time in an attempt to avoid race-conditions New-Item $Tmp -ItemType Directory # Move Cargo to the dev drive New-Item -Path "$($Drive)/.cargo/bin" -ItemType Directory -Force if (Test-Path "C:/Users/runneradmin/.cargo") { Copy-Item -Path "C:/Users/runneradmin/.cargo/*" -Destination "$($Drive)/.cargo/" -Recurse -Force } Write-Output ` "DEV_DRIVE=$($Drive)" ` "TMP=$($Tmp)" ` "TEMP=$($Tmp)" ` "PREK_INTERNAL__TEST_DIR=$($Tmp)" ` "RUSTUP_HOME=$($Drive)/.rustup" ` "CARGO_HOME=$($Drive)/.cargo" ` "PREK_WORKSPACE=$($Drive)/prek" ` "PATH=$($Drive)/.cargo/bin;$env:PATH" ` >> $env:GITHUB_ENV ================================================ FILE: .github/workflows/sync-identify.yml ================================================ name: "Sync pre-commit identify tags" on: workflow_dispatch: schedule: - cron: "0 0 * * *" permissions: {} jobs: sync: if: github.repository == 'j178/prek' runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 with: version: "latest" enable-cache: true - name: "Sync identify tags" run: uv run --upgrade gen.py working-directory: ./crates/prek-identify - name: "Create Pull Request" uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: commit-message: "Sync latest identify tags" add-paths: | crates/prek-identify/src/tags.rs crates/prek-identify/gen.py.lock branch: "sync-identify-tags" title: "Sync latest identify tags" body: "Automated update for identify tags." base: "master" draft: true ================================================ FILE: .github/workflows/zizmor.yml ================================================ name: Run zizmor on: push: branches: ["master"] pull_request: branches: ["**"] permissions: {} jobs: zizmor: name: Run zizmor runs-on: ubuntu-latest permissions: security-events: write steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Run zizmor uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 ================================================ FILE: .github/zizmor.yml ================================================ # Configuration for the zizmor static analysis tool, run via pre-commit in CI # https://woodruffw.github.io/zizmor/configuration/ # release.yml is generated by cargo-dist, ignore its findings there. rules: template-injection: ignore: - release.yml excessive-permissions: ignore: - release.yml secrets-inherit: ignore: - release.yml secrets-outside-env: ignore: - ci.yml # CODECOV_TOKEN is not important ================================================ FILE: .gitignore ================================================ /target .cache __pycache__/ site/ # Insta snapshots. *.pending-snap # JetBrains IDE .idea # Vscode IDE .vscode # macOS **/.DS_Store # profiling flamegraphs *.flamegraph.svg ================================================ FILE: .pre-commit-config.yaml ================================================ fail_fast: true default_install_hook_types: [pre-push] exclude: glob: '**/snapshots/**' repos: - repo: builtin hooks: - id: trailing-whitespace exclude: glob: CHANGELOG.md - id: mixed-line-ending - id: check-yaml - id: check-toml - id: end-of-file-fixer - repo: https://github.com/crate-ci/typos rev: v1.44.0 hooks: - id: typos - repo: https://github.com/executablebooks/mdformat rev: '1.0.0' hooks: - id: mdformat language: python # ensures that Renovate can update additional_dependencies args: [--number, --compact-tables, --align-semantic-breaks-in-lists] additional_dependencies: - mdformat-mkdocs==5.1.4 - mdformat-simple-breaks==0.1.0 - repo: https://github.com/python-jsonschema/check-jsonschema rev: 0.37.0 hooks: - id: check-metaschema files: glob: prek.schema.json - id: check-github-workflows - id: check-renovate additional_dependencies: ['json5'] - repo: local hooks: - id: taplo-fmt name: taplo fmt entry: taplo fmt --config .config/taplo.toml language: python additional_dependencies: ["taplo==0.9.3"] types: [toml] - repo: local hooks: - id: cargo-fmt name: cargo fmt entry: cargo fmt -- language: system types: [rust] pass_filenames: false # This makes it a lot faster - id: cargo-clippy name: cargo clippy language: system types: [rust] pass_filenames: false entry: cargo clippy --all-targets --all-features -- -D warnings ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## 0.3.6 Released on 2026-03-16. ### Enhancements - Allow selectors for hook ids containing colons ([#1782](https://github.com/j178/prek/pull/1782)) - Rename `prek install-hooks` to `prek prepare-hooks` and `prek install --install-hooks` to `prek install --prepare-hooks` ([#1766](https://github.com/j178/prek/pull/1766)) - Retry auth-failed repo clones with terminal prompts enabled ([#1761](https://github.com/j178/prek/pull/1761)) ### Performance - Optimize `detect_private_key` by chunked reading and using aho-corasick ([#1791](https://github.com/j178/prek/pull/1791)) - Optimize `fix_byte_order_marker` by shifting file contents in place ([#1790](https://github.com/j178/prek/pull/1790)) ### Bug fixes - Align stage defaulting behavior with pre-commit ([#1788](https://github.com/j178/prek/pull/1788)) - Make sure child output is drained in the PTY subprocess ([#1768](https://github.com/j178/prek/pull/1768)) - fix(golang): use `GOTOOLCHAIN=local` when probing system go ([#1797](https://github.com/j178/prek/pull/1797)) ### Documentation - Disambiguate “hook” terminology by renaming "Git hooks" to "Git shims" ([#1776](https://github.com/j178/prek/pull/1776)) - Document compatibility with pre-commit ([#1767](https://github.com/j178/prek/pull/1767)) - Update configuration.md with TOML 1.1 notes ([#1764](https://github.com/j178/prek/pull/1764)) ### Other changes - Sync latest identify tags ([#1798](https://github.com/j178/prek/pull/1798)) ### Contributors - @github-actions - @j178 - @pcastellazzi - @deadnews - @copilot-swe-agent ## 0.3.5 Released on 2026-03-09. ### Enhancements - Add automatic Ruby download support using rv binaries ([#1668](https://github.com/j178/prek/pull/1668)) - Adjust open file limit on process startup ([#1705](https://github.com/j178/prek/pull/1705)) - Allow parallel gem retry ([#1732](https://github.com/j178/prek/pull/1732)) - Enable system-proxy feature on reqwest ([#1738](https://github.com/j178/prek/pull/1738)) - Expose `--git-dir` to force hook installation target ([#1723](https://github.com/j178/prek/pull/1723)) - Pass `--quiet`, `--verbose`, and `--no-progress` through `prek install` into generated hook scripts ([#1753](https://github.com/j178/prek/pull/1753)) - Respect `core.sharedRepository` for hook permissions ([#1755](https://github.com/j178/prek/pull/1755)) - Support legacy mode hook script ([#1706](https://github.com/j178/prek/pull/1706)) - rust: support `cli:` git dependency 4th segment package disambiguation ([#1747](https://github.com/j178/prek/pull/1747)) ### Bug fixes - Fix Python `__main__.py` entry ([#1741](https://github.com/j178/prek/pull/1741)) - python: strip `UV_SYSTEM_PYTHON` from `uv venv` and `pip install` commands ([#1756](https://github.com/j178/prek/pull/1756)) ### Other changes - Sync latest identify tags ([#1733](https://github.com/j178/prek/pull/1733)) ### Contributors - @Dev-iL - @tennox - @shaanmajid - @is-alnilam - @github-actions - @j178 ## 0.3.4 Released on 2026-02-28. ### Enhancements - Allow `pass_filenames` to accept a positive integer ([#1698](https://github.com/j178/prek/pull/1698)) - Install and compile gems in parallel ([#1674](https://github.com/j178/prek/pull/1674)) - Sync identify file-type mappings with pre-commit identify ([#1660](https://github.com/j178/prek/pull/1660)) - Use `--locked` for Rust `cargo install` commands ([#1661](https://github.com/j178/prek/pull/1661)) - Add `PREK_MAX_CONCURRENCY` environment variable for configuring maximum concurrency ([#1697](https://github.com/j178/prek/pull/1697)) - Add `PREK_LOG_TRUNCATE_LIMIT` environment variable for configuring log truncation ([#1679](https://github.com/j178/prek/pull/1679)) - Add support for `python -m prek` ([#1686](https://github.com/j178/prek/pull/1686)) ### Bug fixes - Skip invalid Rust toolchains instead of failing ([#1699](https://github.com/j178/prek/pull/1699)) ### Performance - Bitset-based TagSet refactor: precompute tag masks and speed up hook type filtering ([#1665](https://github.com/j178/prek/pull/1665)) ### Documentation - Document `winget install j178.Prek` ([#1670](https://github.com/j178/prek/pull/1670)) ### Contributors - @uplsh580 - @Svecco - @dbast - @drichardson - @JP-Ellis - @j178 - @is-alnilam - @copilot-swe-agent ## 0.3.3 Released on 2026-02-15. ### Enhancements - Read Python version specifier from hook repo `pyproject.toml` ([#1596](https://github.com/j178/prek/pull/1596)) - Add `#:schema` directives to generated prek.toml ([#1597](https://github.com/j178/prek/pull/1597)) - Add `prek util list-builtins` command ([#1600](https://github.com/j178/prek/pull/1600)) - Expand install source detection to `mise`, `uv tool`, `pipx`, and `asdf` ([#1605](https://github.com/j178/prek/pull/1605), [#1607](https://github.com/j178/prek/pull/1607)) - Add progress bar to `cache clean` and show removal summary ([#1616](https://github.com/j178/prek/pull/1616)) - Make `yaml-to-toml` CONFIG argument optional ([#1593](https://github.com/j178/prek/pull/1593)) - `prek uninstall` removes legacy scripts too ([#1622](https://github.com/j178/prek/pull/1622)) ### Bug fixes - Fix underflow when formatting summary output ([#1626](https://github.com/j178/prek/pull/1626)) - Match `files/exclude` filter against relative path of nested project ([#1624](https://github.com/j178/prek/pull/1624)) - Select `musllinux` wheel tag for uv on musl-based distros ([#1628](https://github.com/j178/prek/pull/1628)) ### Documentation - Clarify `prek list` description ([#1604](https://github.com/j178/prek/pull/1604)) ### Contributors - @ichoosetoaccept - @shaanmajid - @soraxas - @9999years - @j178 ## 0.3.2 Released on 2026-02-06. ### Highlights - **`prek.toml` is here!** You can now use `prek.toml` as an alternative to `.pre-commit-config.yaml` for configuring prek. `prek.toml` mirrors the structure of `.pre-commit-config.yaml`, but TOML is less error-prone. Your existing `.pre-commit-config.yaml` will continue to work, but for new users and new projects, `prek.toml` may make more sense. If you want to switch, run `prek util yaml-to-toml` to convert YAML configs to `prek.toml`. See [configuration docs](configuration.md) for details. For example, this config: ```yaml repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-yaml ``` Can be written as `prek.toml` like this: ```toml [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v6.0.0" hooks = [ { id = "check-yaml" } ] ``` - **`serde-yaml` has been replaced with `serde-saphyr`** We replaced the long-deprecated `serde-yaml` crate with [`serde-saphyr`](https://crates.io/crates/serde-saphyr) for YAML parsing. It is written in safe Rust and has better error messages, performance, and security. This lets us provide precise location information for configuration parsing errors, which should make it easier to fix config issues. For example, this invalid config: ```yaml repos: - repo: https://github.com/crate-ci/typos hooks: - id: typos ``` Before: ```console $ prek run error: Failed to parse `.pre-commit-config.yaml` caused by: Invalid remote repo: missing field `rev` ``` Now: ```console $ prek run error: Failed to parse `.pre-commit-config.yaml` caused by: error: line 2 column 5: missing field `rev` at line 2, column 5 --> :2:5 | 1 | repos: 2 | - repo: https://github.com/crate-ci/typos | ^ missing field `rev` at line 2, column 5 3 | hooks: 4 | - id: typos | ``` - **`prek util` subcommands** We added a new `prek util` top-level command for miscellaneous utilities that don't fit into other categories. The first two utilities are: - `prek util identify`: shows the identification tags of files that prek uses for file filtering, which can be useful for debugging and writing `types/types_or/exclude_types` filters. - `prek util yaml-to-toml`: converts `.pre-commit-config.yaml` to `prek.toml`. We also moved `prek init-template-dir` under `prek util` for better organization. The old `prek init-template-dir` command is still available (hidden) as an alias for backward compatibility. ### Enhancements - Add `prek util identify` subcommand ([#1554](https://github.com/j178/prek/pull/1554)) - Add `prek util yaml-to-toml` to convert `.pre-commit-config.yaml` to `prek.toml` ([#1584](https://github.com/j178/prek/pull/1584)) - Detect install source for actionable upgrade hints ([#1540](https://github.com/j178/prek/pull/1540)) - Detect prek installed by the standalone installer ([#1545](https://github.com/j178/prek/pull/1545)) - Implement `serialize_yaml_scalar` using `serde-saphyr` ([#1534](https://github.com/j178/prek/pull/1534)) - Improve max cli arguments length calculation ([#1518](https://github.com/j178/prek/pull/1518)) - Move `identify` and `init-template-dir` under the `prek util` top-level command ([#1574](https://github.com/j178/prek/pull/1574)) - Replace serde-yaml with serde-saphyr (again) ([#1520](https://github.com/j178/prek/pull/1520)) - Show precise location for config parsing error ([#1530](https://github.com/j178/prek/pull/1530)) - Support `Julia` language ([#1519](https://github.com/j178/prek/pull/1519)) - Support `prek.toml` ([#1271](https://github.com/j178/prek/pull/1271)) - Added `PREK_QUIET` environment variable support ([#1513](https://github.com/j178/prek/pull/1513)) - Remove upper bound constraint of uv version ([#1588](https://github.com/j178/prek/pull/1588)) ### Bug fixes - Do not make the child a session leader ([#1586](https://github.com/j178/prek/pull/1586)) - Fix FilePattern schema to accept plain strings ([#1564](https://github.com/j178/prek/pull/1564)) - Use semver fallback sort when tag timestamps are equal ([#1579](https://github.com/j178/prek/pull/1579)) ### Documentation - Add `OpenClaw` to the list of users ([#1517](https://github.com/j178/prek/pull/1517)) - Add `cachix/devenv`, `apache/lucene`, `copper-project/copper-rs` as projects using prek ([#1531](https://github.com/j178/prek/pull/1531), [#1514](https://github.com/j178/prek/pull/1514), [#1569](https://github.com/j178/prek/pull/1569)) - Add document about authoring remote hooks ([#1571](https://github.com/j178/prek/pull/1571)) - Add `llms.txt` generation for LLM-friendly documentation ([#1553](https://github.com/j178/prek/pull/1553)) - Document using `--refresh` to pick up `.prekignore` changes ([#1575](https://github.com/j178/prek/pull/1575)) - Fix PowerShell completion instruction syntax ([#1568](https://github.com/j178/prek/pull/1568)) - Update quick start to use `prek.toml` ([#1576](https://github.com/j178/prek/pull/1576)) ### Other changes - Include `prek.toml` in run hint for config filename ([#1578](https://github.com/j178/prek/pull/1578)) ### Contributors - @fatelei - @domenkozar - @makeecat - @fllesser - @j178 - @copilot-swe-agent - @oopscompiled - @rmuir - @shaanmajid ## 0.3.1 Released on 2026-01-31. ### Enhancements - Add `language: swift` support ([#1463](https://github.com/j178/prek/pull/1463)) - Add `language: haskell` support ([#1484](https://github.com/j178/prek/pull/1484)) - Extract go version constraint from `go.mod` ([#1457](https://github.com/j178/prek/pull/1457)) - Warn when config file exists but fails to parse ([#1487](https://github.com/j178/prek/pull/1487)) - Add GitHub artifact attestations to release and build-docker workflow ([#1494](https://github.com/j178/prek/pull/1494), [#1497](https://github.com/j178/prek/pull/1497)) - Allow `GIT_CONFIG_PARAMETERS` for private repository authentication ([#1472](https://github.com/j178/prek/pull/1472)) - Show progress bar when running builtin hooks ([#1504](https://github.com/j178/prek/pull/1504)) ### Bug fixes - Cap ARG_MAX at `1<<19` for safety ([#1506](https://github.com/j178/prek/pull/1506)) - Don't check Python executable path in health check ([#1496](https://github.com/j178/prek/pull/1496)) ### Documentation - Include `CocoIndex` as a project using prek ([#1477](https://github.com/j178/prek/pull/1477)) - Add commands for artifact verification using GitHub Attestations ([#1500](https://github.com/j178/prek/pull/1500)) ### Contributors - @halms - @Haleshot - @simono - @tisonkun - @fllesser - @j178 - @shaanmajid ## 0.3.0 Released on 2026-01-22. ### Highlights - `prek cache gc` (also available via `prek gc` for pre-commit compatibility) is finally here! You can now run `prek cache gc` to clean up unused repos, hook envs and tool versions from prek cache. - `language: bun` is now supported, making it possible to write and run hooks with [Bun](https://bun.sh/). ### Enhancements - Implement `prek cache gc` ([#1410](https://github.com/j178/prek/pull/1410)) - Bootstrap tracking configs from workspace cache ([#1417](https://github.com/j178/prek/pull/1417)) - Show total size `prek cache gc` removed ([#1418](https://github.com/j178/prek/pull/1418)) - Show accurate repo and hook details in `prek cache gc -v` ([#1420](https://github.com/j178/prek/pull/1420)) - `prek cache gc` remove specific unused tool versions ([#1422](https://github.com/j178/prek/pull/1422)) - Fix unused tool versions not removed in `prek cache gc` ([#1436](https://github.com/j178/prek/pull/1436)) - Add `language: bun` support ([#1411](https://github.com/j178/prek/pull/1411)) - Use `git ls-remote --tags` to list bun versions ([#1439](https://github.com/j178/prek/pull/1439)) - Accept `--stage` as an alias for `--hook-stage` in `prek run` ([#1398](https://github.com/j178/prek/pull/1398)) - Expand `~` tilde in `PREK_HOME` ([#1431](https://github.com/j178/prek/pull/1431)) - Support refs to trees ([#1449](https://github.com/j178/prek/pull/1449)) ### Bug fixes - Avoid file lock warning for in-process contention ([#1406](https://github.com/j178/prek/pull/1406)) - Resolve relative repo paths from config file directory ([#1443](https://github.com/j178/prek/pull/1443)) - fix: use `split()` instead of `resolve(None)` for builtin hook argument parsing ([#1415](https://github.com/j178/prek/pull/1415)) ### Documentation - Add `simple-icons` and `ast-grep` to the users of prek ([#1403](https://github.com/j178/prek/pull/1403)) - Improve JSON schema for `repo` field ([#1432](https://github.com/j178/prek/pull/1432)) - Improve JSON schema for builtin and meta hooks ([#1427](https://github.com/j178/prek/pull/1427)) - Add pronunciation entry to FAQ ([#1442](https://github.com/j178/prek/pull/1442)) - Add commitizen to the list of projects using prek ([#1413](https://github.com/j178/prek/pull/1413)) - Move docs to zensical ([#1421](https://github.com/j178/prek/pull/1421)) ### Other Changes - Refactor config layout ([#1407](https://github.com/j178/prek/pull/1407)) ### Contributors - @shaanmajid - @KevinGimbel - @jtamagnan - @jmeickle-theaiinstitute - @YazdanRa - @j178 - @mschoettle - @tisonkun ## 0.2.30 Released on 2026-01-18. ### Enhancements - Build binaries using minimal-size profile ([#1376](https://github.com/j178/prek/pull/1376)) - Check for duplicate keys in `check-json5` builtin hook ([#1387](https://github.com/j178/prek/pull/1387)) - Preserve quoting style in `auto-update` ([#1379](https://github.com/j178/prek/pull/1379)) - Show warning if file lock acquiring blocks for long time ([#1353](https://github.com/j178/prek/pull/1353)) - Singleflight Python health checks with cached interpreter info ([#1381](https://github.com/j178/prek/pull/1381)) ### Bug fixes - Do not resolve entry for docker_image ([#1386](https://github.com/j178/prek/pull/1386)) - Fix command lookup on Windows ([#1383](https://github.com/j178/prek/pull/1383)) ### Documentation - Document language support details ([#1380](https://github.com/j178/prek/pull/1380)) - Document that `check-json5` now rejects duplicate keys ([#1391](https://github.com/j178/prek/pull/1391)) ### Contributors - @j178 ## 0.2.29 Released on 2026-01-16. ### Highlights `files` / `exclude` now support globs (including glob lists), making config filters much easier to read and maintain than heavily-escaped regex. Before (regex): ```yaml files: "^(src/.*\\.rs$|crates/[^/]+/src/.*\\.rs$)" ``` After (glob list): ```yaml files: glob: - src/**/*.rs - crates/**/src/**/*.rs ``` ### Enhancements - Add `check-json5` as builtin hooks ([#1367](https://github.com/j178/prek/pull/1367)) - Add glob list support for file patterns (`files` and `exclude`) ([#1197](https://github.com/j178/prek/pull/1197)) ### Bug fixes - Fix missing commit hash from version info ([#1352](https://github.com/j178/prek/pull/1352)) - Remove git env vars from `uv pip install` subprocess ([#1355](https://github.com/j178/prek/pull/1355)) - Set `TERM=dumb` under PTY to prevent capability-probe hangs ([#1363](https://github.com/j178/prek/pull/1363)) ### Documentation - Add `home-assistant/core` to the users of prek ([#1350](https://github.com/j178/prek/pull/1350)) - Document builtin hooks ([#1370](https://github.com/j178/prek/pull/1370)) - Explain project configuration scope ([#1373](https://github.com/j178/prek/pull/1373)) ### Contributors - @Goldziher - @yihong0618 - @j178 - @shaanmajid - @ulgens ## 0.2.28 Released on 2026-01-13. ### Enhancements - Avoid running `git diff` for skipped hooks ([#1335](https://github.com/j178/prek/pull/1335)) - More accurate command line length limit calculation ([#1348](https://github.com/j178/prek/pull/1348)) - Raise platform command line length upper limit ([#1347](https://github.com/j178/prek/pull/1347)) - Use `/bin/sh` in generated git hook scripts ([#1333](https://github.com/j178/prek/pull/1333)) ### Bug fixes - Avoid rewriting if config is up-to-date ([#1346](https://github.com/j178/prek/pull/1346)) ### Documentation - Add `ty` to the users of prek ([#1342](https://github.com/j178/prek/pull/1342)) - Add `ruff` to the users of prek ([#1334](https://github.com/j178/prek/pull/1334)) - Complete configuration document ([#1338](https://github.com/j178/prek/pull/1338)) - Document UV environment variable inheritance in prek ([#1339](https://github.com/j178/prek/pull/1339)) ### Contributors - @copilot-swe-agent - @MatthewMckee4 - @yihong0618 - @j178 ## 0.2.27 Released on 2026-01-07. ### Highlights `python/cpython` is now [using](https://github.com/j178/prek/pull/1308) prek. That’s the highlight of this release! ### Enhancements - Add hook-level `env` option to set environment variables for hooks (#1279) ([#1285](https://github.com/j178/prek/pull/1285)) - Support apple's `container` for docker language ([#1306](https://github.com/j178/prek/pull/1306)) - Skip cookiecutter template directories like `{{cookiecutter.project_slug}}` during project discovery ([#1316](https://github.com/j178/prek/pull/1316)) - Use global `CONCURRENCY` for repo clone ([#1292](https://github.com/j178/prek/pull/1292)) - untar: disallow external symlinks ([#1314](https://github.com/j178/prek/pull/1314)) ### Bug fixes - Exit with success if no hooks match the hook stage ([#1317](https://github.com/j178/prek/pull/1317)) - Fix Go template string to detect rootless podman ([#1302](https://github.com/j178/prek/pull/1302)) - Panic on overly long filenames instead of silently dropping files ([#1287](https://github.com/j178/prek/pull/1287)) ### Other changes - Add `python/cpython` to users ([#1308](https://github.com/j178/prek/pull/1308)) - Add `MoonshotAI/kimi-cli` to users ([#1286](https://github.com/j178/prek/pull/1286)) - Drop powerpc64 wheels ([#1319](https://github.com/j178/prek/pull/1319)) ### Contributors - @ulgens - @loganaden - @danielparks - @branchv - @j178 - @yihong0618 - @mocknen - @copilot-swe-agent - @ZhuoZhuoCrayon ## 0.2.25 Released on 2025-12-27. ### Performance - Use `git cat-file -e` in check if a rev exists ([#1277](https://github.com/j178/prek/pull/1277)) ### Bug fixes - Fix `priority` not applied for remote hooks ([#1281](https://github.com/j178/prek/pull/1281)) - Report config file parsing error in `auto-update` ([#1274](https://github.com/j178/prek/pull/1274)) - Unset `GIT_DIR` for auto-update ([#1269](https://github.com/j178/prek/pull/1269)) ### Contributors - @j178 - @branchv ## 0.2.24 Released on 2025-12-23. ### Enhancements - Build and publish docker image to `ghcr.io/j178/prek` ([#1253](https://github.com/j178/prek/pull/1253)) - Support git urls for rust dependencies ([#1256](https://github.com/j178/prek/pull/1256)) ### Bug fixes - Ensure running `uv pip install` inside the remote repo path ([#1262](https://github.com/j178/prek/pull/1262)) - Fix `check-added-large-files` for traced files ([#1260](https://github.com/j178/prek/pull/1260)) - Respect `GIT_DIR` set by git ([#1258](https://github.com/j178/prek/pull/1258)) ### Documentation - Add docker integration docs ([#1254](https://github.com/j178/prek/pull/1254)) - Clarify `priority` scope across repos ([#1251](https://github.com/j178/prek/pull/1251)) - Improve documentation for configurations ([#1247](https://github.com/j178/prek/pull/1247)) - Render changelog in document site ([#1248](https://github.com/j178/prek/pull/1248)) ### Contributors - @j178 - @branchv ## 0.2.23 Released on 2025-12-20. ### Highlights 🚀 This release introduces priority-based parallel hook execution: prek can run multiple hooks in parallel when they share the same `priority`, which can be a huge speed-up for many configs. See configuration docs for [`priority`](https://prek.j178.dev/configuration/#priority). ### Enhancements - Allow uv reading user-level or system-level configuration files ([#1227](https://github.com/j178/prek/pull/1227)) - Implement `check-case-conflict` as builtin hook ([#888](https://github.com/j178/prek/pull/888)) - Implement `priority` based parallel execution ([#1232](https://github.com/j178/prek/pull/1232)) ### Bug fixes - Fix `check-executable-have-shebangs` "command line too long" error on Windows ([#1236](https://github.com/j178/prek/pull/1236)) ### Documentation - Add FastAPI to the list of projects using prek ([#1241](https://github.com/j178/prek/pull/1241)) - Document hook_types flag and default_install_hook_types behavior ([#1225](https://github.com/j178/prek/pull/1225)) - Improve documentation for `priority` ([#1245](https://github.com/j178/prek/pull/1245)) - Mention prek can be installed via`taiki-e/install-action@prek` ([#1234](https://github.com/j178/prek/pull/1234)) ### Contributors - @j178 - @copilot-swe-agent - @lmmx ## 0.2.22 Released on 2025-12-13. ### Highlights In this release, prek adds support for the `--cooldown-days` option in the `prek auto-update` command. This option allows users to skip releases that are newer than a specified number of days. It is useful to mitigate open source supply chain risks by avoiding very recent releases that may not have been widely adopted or vetted yet. Big thanks to @lmmx for driving this feature! ### Enhancements - Support`--cooldown-days` in `prek auto-update` ([#1172](https://github.com/j178/prek/pull/1172)) - Prefer tag creation timestamp in `--cooldown-days` ([#1221](https://github.com/j178/prek/pull/1221)) - Use `cargo install` for packages in workspace ([#1207](https://github.com/j178/prek/pull/1207)) ### Bug fixes - Set `CARGO_HOME` for `cargo metadata` ([#1209](https://github.com/j178/prek/pull/1209)) ### Contributors - @j178 - @lmmx ## 0.2.21 Released on 2025-12-09. ### Bug fixes - Fallback to use remote repo package root instead of erroring ([#1203](https://github.com/j178/prek/pull/1203)) - Prepend toolchain bin directory to PATH when calling cargo ([#1204](https://github.com/j178/prek/pull/1204)) - Use `cargo` from installed toolchain ([#1202](https://github.com/j178/prek/pull/1202)) ### Contributors - @j178 ## 0.2.20 Released on 2025-12-08. ### Highlights In this release: - Rust hooks are now fully supported with automatic toolchain management, including package discovery in virtual workspaces. Big thanks to @lmmx for driving this. - Added a `prek cache size` subcommand so you can quickly see how much cache space prek is using. Thanks @MatthewMckee4! - Nested workspaces are easier to reason about: set `orphan: true` on a project to isolate it from parents so its files are processed only once. Want to show your project runs on prek? Add our README badge to your docs or repo homepage: [![prek](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json)](https://github.com/j178/prek) ### Enhancements - Support Rust language ([#989](https://github.com/j178/prek/pull/989)) - Refactor Rust toolchain management ([#1198](https://github.com/j178/prek/pull/1198)) - Add support for finding packages in virtual workspaces ([#1180](https://github.com/j178/prek/pull/1180)) - Add `prek cache size` command ([#1183](https://github.com/j178/prek/pull/1183)) - Support orphan projects ([#1129](https://github.com/j178/prek/pull/1129)) - Fallback to `manual` stage for hooks specified directly in command line ([#1185](https://github.com/j178/prek/pull/1185)) - Make go module cache read-writeable (thus deletable) ([#1164](https://github.com/j178/prek/pull/1164)) - Provide more information when validating configs and manifests ([#1182](https://github.com/j178/prek/pull/1182)) - Improve error message for invalid number of arguments to hook-impl ([#1196](https://github.com/j178/prek/pull/1196)) ### Bug fixes - Disable git terminal prompts ([#1193](https://github.com/j178/prek/pull/1193)) - Prevent `post-checkout` deadlock when cloning repos ([#1192](https://github.com/j178/prek/pull/1192)) - Prevent color output when redirecting stdout to a file ([#1159](https://github.com/j178/prek/pull/1159)) ### Documentation - Add MacPorts to installation methods ([#1157](https://github.com/j178/prek/pull/1157)) - Add a FAQ page explaining `prek install --install--hooks` ([#1162](https://github.com/j178/prek/pull/1162)) ### Other changes - Add `prek: enabled` repo badge ([#1171](https://github.com/j178/prek/pull/1171)) - Add favicon for docs website ([#1187](https://github.com/j178/prek/pull/1187)) ### Contributors - @MatthewMckee4 - @lmmx - @j178 - @joshmarkovic - @frazar - @jmelahman - @drainpixie ## 0.2.19 Released on 2025-11-26. ### Performance - Simplify `fix_byte_order_marker` hook ([#1136](https://github.com/j178/prek/pull/1136)) - Simplify `trailing-whitespace` hook to improve performance ([#1135](https://github.com/j178/prek/pull/1135)) ### Bug fixes - Close stdin for hook subcommands ([#1155](https://github.com/j178/prek/pull/1155)) - Fix parsing Python interpreter info containing non-UTF8 chars ([#1141](https://github.com/j178/prek/pull/1141)) ### Contributors - @chilin0525 - @nblock - @j178 ## 0.2.18 Released on 2025-11-21. ### Highlights In this release, prek adds a new special repo type `repo: builtin` that lets you use built‑in hooks. It basically gives you another way to use the existing built‑in fast path for pre‑commit‑hooks, but without needing to point to an external repo. Since prek doesn’t have to clone anything or set up a virtual environment, `repo: builtin` hooks work even in air‑gapped environments. For more details, see: https://prek.j178.dev/builtin/ ### Enhancements - Add support `repo: builtin` ([#1118](https://github.com/j178/prek/pull/1118)) - Enable virtual terminal processing on Windows ([#1123](https://github.com/j178/prek/pull/1123)) ### Bug fixes - Do not recurse into submodules during workspace discovery ([#1121](https://github.com/j178/prek/pull/1121)) - Do not dim the hook output ([#1126](https://github.com/j178/prek/pull/1126)) - Further reduce max cli length for cmd.exe on Windows ([#1131](https://github.com/j178/prek/pull/1131)) - Revert "Disallow hook-level `minimum_prek_version` (#1101)" ([#1120](https://github.com/j178/prek/pull/1120)) ### Other changes - docs: refer airflow as Apache Airflow ([#1116](https://github.com/j178/prek/pull/1116)) ### Contributors - @j178 - @Lee-W ## 0.2.17 Released on 2025-11-18. ### Bug fixes - Revert back to use `serde_yaml` again ([#1112](https://github.com/j178/prek/pull/1112)) ### Contributors - @j178 ## 0.2.16 Released on 2025-11-18. ### Bug fixes - Disallow hook-level `minimum_prek_version` ([#1101](https://github.com/j178/prek/pull/1101)) - Do not require a project in `prek init-template-dir` ([#1109](https://github.com/j178/prek/pull/1109)) - Make sure `uv pip install` uses the Python from virtualenv ([#1108](https://github.com/j178/prek/pull/1108)) - Restore using `serde_yaml` in `check-yaml` hook ([#1106](https://github.com/j178/prek/pull/1106)) ### Contributors - @j178 ## 0.2.15 Released on 2025-11-17. ### Highlights prek is now available on crates.io! You can build prek from source via `cargo install prek` or `cargo binstall prek`, for more details see [Installation](https://prek.j178.dev/installation/#build-from-source). ### Enhancements - Clean up hook environments when install fails ([#1085](https://github.com/j178/prek/pull/1085)) - Prepare for publishing prek to crates.io ([#1088](https://github.com/j178/prek/pull/1088)) - Replace `serde-yaml` with `serde_saphyr` ([#1087](https://github.com/j178/prek/pull/1087)) - Warn unexpected keys in repo and hook level ([#1096](https://github.com/j178/prek/pull/1096)) ### Bug fixes - Fix `prek init-template-dir` fails in non-git repo ([#1093](https://github.com/j178/prek/pull/1093)) ### Contributors - @j178 ## 0.2.14 Released on 2025-11-14. ### Enhancements - Support `PREK_CONTAINER_RUNTIME=podman` to override container runtime ([#1033](https://github.com/j178/prek/pull/1033)) - Support rootless container runtime ([#1018](https://github.com/j178/prek/issues/1018)) - Support `language: unsupported` and `language: unsupported_script` introduced in pre-commit v4.4 ([#1073](https://github.com/j178/prek/pull/1073)) - Tweak to regex used for mountinfo ([#1037](https://github.com/j178/prek/pull/1037)) ### Bug fixes - Fix `--files` argument - files referencing other projects aren’t being filtered ([#1064](https://github.com/j178/prek/pull/1064)) - Unset `objectFormat` in `git init` ([#1048](https://github.com/j178/prek/pull/1048)) ### Documentation - Add scoop to installation ([#1067](https://github.com/j178/prek/pull/1067)) - Document workspace file visibility constraints ([#1071](https://github.com/j178/prek/pull/1071)) - Add `iceberg-python`, `msgspec` and `humanize` to "who is using prek" ([#1039](https://github.com/j178/prek/pull/1039), [#1042](https://github.com/j178/prek/pull/1042), [#1063](https://github.com/j178/prek/pull/1063)) ### Other changes - Add a hint to install when running inside a sub-project ([#1045](https://github.com/j178/prek/pull/1045)) - Add a hint to use `--refresh` when no configuration found ([#1046](https://github.com/j178/prek/pull/1046)) - Run uv pip install from the current directory ([#1069](https://github.com/j178/prek/pull/1069)) ### Contributors - @zzstoatzz - @st1971 - @yihong0618 - @j178 - @copilot-swe-agent - @idlsoft ## 0.2.13 Released on 2025-11-04. ### Enhancements - Add Ruby support (no download support yet) ([#993](https://github.com/j178/prek/pull/993)) - Implement `check-executables-have-shebangs` as builtin-hook ([#924](https://github.com/j178/prek/pull/924)) - Improve container id detection ([#1031](https://github.com/j178/prek/pull/1031)) ### Performance - Optimize hot paths: reduce allocations ([#997](https://github.com/j178/prek/pull/997)) - Refactor `identify` using smallvec ([#982](https://github.com/j178/prek/pull/982)) ### Bug fixes - Fix YAML with nested merge keys ([#1020](https://github.com/j178/prek/pull/1020)) - Treat every file as executable on Windows to keep compatibility with pre-commit ([#980](https://github.com/j178/prek/pull/980)) ### Documentation - Document that .gitignore is respected by default during workspace discovery ([#983](https://github.com/j178/prek/pull/983)) - Update project stability status ([#1005](https://github.com/j178/prek/pull/1005)) - Add FastMCP to "who is using prek" ([#1034](https://github.com/j178/prek/pull/1034)) - Add attrs to "who is using prek" ([#981](https://github.com/j178/prek/pull/981)) ### Contributors - @my1e5 - @j178 - @zzstoatzz - @lmmx - @feliblo - @yihong0618 - @st1971 - @is-alnilam ## 0.2.12 Released on 2025-10-27. ### Enhancements - Add a warning for unimplemented hooks ([#976](https://github.com/j178/prek/pull/976)) - Allow using system trusted store by `PREK_NATIVE_TLS` ([#959](https://github.com/j178/prek/pull/959)) ### Bug fixes - Do not check for `script` subprocess status ([#964](https://github.com/j178/prek/pull/964)) - Fix compatibility with older luarocks ([#967](https://github.com/j178/prek/pull/967)) - Fix local relative path in `try-repo` ([#975](https://github.com/j178/prek/pull/975)) ### Documentation - Update language support status ([#970](https://github.com/j178/prek/pull/970)) ### Contributors - @yihong0618 - @st1971 - @j178 ## 0.2.11 Released on 2025-10-24. ### Enhancements - Support `language: lua` hooks ([#954](https://github.com/j178/prek/pull/954)) - Support `language_version: system` ([#949](https://github.com/j178/prek/pull/949)) - Implement `no-commit-to-branch` as builtin hook ([#930](https://github.com/j178/prek/pull/930)) - Improve styling for stashing error message ([#953](https://github.com/j178/prek/pull/953)) - Support nix-shell style shebang ([#929](https://github.com/j178/prek/pull/929)) ### Documentation - Add a page about "Quick start" ([#934](https://github.com/j178/prek/pull/934)) - Add kreuzberg to "who is using prek" ([#936](https://github.com/j178/prek/pull/936)) - Clarify minimum mise version required to use `mise use prek` ([#931](https://github.com/j178/prek/pull/931)) ### Contributors - @fllesser - @j178 ## 0.2.10 Released on 2025-10-18. ### Enhancements - Add `--fail-fast` CLI flag to stop after first hook failure ([#908](https://github.com/j178/prek/pull/908)) - Add collision detection for hook env directories ([#914](https://github.com/j178/prek/pull/914)) - Error out if not projects found ([#913](https://github.com/j178/prek/pull/913)) - Implement `check-xml` as builtin hook ([#894](https://github.com/j178/prek/pull/894)) - Implement `check-merge-conflict` as builtin hook ([#885](https://github.com/j178/prek/pull/885)) - Use line-by-line reading in `check-merge-conflict` ([#910](https://github.com/j178/prek/pull/910)) ### Bug fixes - Fix pygrep hook env health check ([#921](https://github.com/j178/prek/pull/921)) - Group `pygrep` with `python` when installing pygrep hooks ([#920](https://github.com/j178/prek/pull/920)) - Ignore `.` prefixed directory when searching managed Python for pygrep ([#919](https://github.com/j178/prek/pull/919)) ### Documentation - Add contribution guide ([#912](https://github.com/j178/prek/pull/912)) ### Other changes ### Contributors - @AdityasWorks - @j178 - @kenwoodjw - @lmmx ## 0.2.9 Released on 2025-10-16. ### Enhancements - Lazily check hook env health ([#897](https://github.com/j178/prek/pull/897)) - Implement `check-symlinks` as builtin hook ([#895](https://github.com/j178/prek/pull/895)) - Implement `detect-private-key` as builtin hook ([#893](https://github.com/j178/prek/pull/893)) ### Bug fixes - Download files to scratch directory to avoid cross-filesystem rename ([#889](https://github.com/j178/prek/pull/889)) - Fix golang hook install local dependencies ([#902](https://github.com/j178/prek/pull/902)) - Ignore the user-set `UV_MANAGED_PYTHON` ([#900](https://github.com/j178/prek/pull/900)) ### Other changes - Add package metadata for cargo-binstall ([#882](https://github.com/j178/prek/pull/882)) ### Contributors - @j178 - @lmmx ## 0.2.8 Released on 2025-10-14. *This is a re-release of 0.2.6 that fixes an issue where publishing to npmjs.com failed.* ### Enhancements - Publish prek to npmjs.com ([#819](https://github.com/j178/prek/pull/819)) - Support YAML merge keys in `.pre-commit-config.yaml` ([#871](https://github.com/j178/prek/pull/871)) ### Bug fixes - Use relative path with `--cd` in the generated hook script ([#868](https://github.com/j178/prek/pull/868)) - Fix autoupdate `rev` rendering for "float-like" version numbers ([#867](https://github.com/j178/prek/pull/867)) ### Documentation - Add Nix and Conda installation details ([#874](https://github.com/j178/prek/pull/874)) ### Contributors - @mondeja - @j178 - @bbannier - @yihong0618 - @colindean ## 0.2.5 Released on 2025-10-10. ### Enhancements - Implement `prek try-repo` ([#797](https://github.com/j178/prek/pull/797)) - Add fallback mechanism for prek executable in git hooks ([#850](https://github.com/j178/prek/pull/850)) - Ignore config error if the directory is skipped ([#860](https://github.com/j178/prek/pull/860)) ### Bug fixes - Fix panic when parse config failed ([#859](https://github.com/j178/prek/pull/859)) ### Other changes - Add a Dockerfile ([#852](https://github.com/j178/prek/pull/852)) ### Contributors - @j178 - @luizvbo ## 0.2.4 Released on 2025-10-07. ### Enhancements - Add support for `.prekignore` to ignore directories from project discovery ([#826](https://github.com/j178/prek/pull/826)) - Make `prek auto-update --jobs` default to 0 (which uses max available parallelism) ([#833](https://github.com/j178/prek/pull/833)) - Improve install message when installing for a subproject ([#847](https://github.com/j178/prek/pull/847)) ### Bug fixes - Convert extension to lowercase before checking file tags ([#839](https://github.com/j178/prek/pull/839)) - Support pass multiple files like `prek run --files a b c d` ([#828](https://github.com/j178/prek/pull/828)) ### Documentation - Add requests-cache to "Who is using prek" ([#824](https://github.com/j178/prek/pull/824)) ### Contributors - @SigureMo - @j178 ## 0.2.3 Released on 2025-09-29. ### Enhancements - Add `--dry-run` to `prek auto-update` ([#806](https://github.com/j178/prek/pull/806)) - Add a global `--log-file` flag to specify the log file path ([#817](https://github.com/j178/prek/pull/817)) - Implement hook health check ([#798](https://github.com/j178/prek/pull/798)) - Show error message in quiet mode ([#807](https://github.com/j178/prek/pull/807)) ### Bug fixes - Write `fail` entry into output directly ([#811](https://github.com/j178/prek/pull/811)) ### Documentation - Update docs about uv in prek ([#810](https://github.com/j178/prek/pull/810)) ### Other changes - Add a security policy for reporting vulnerabilities ([#804](https://github.com/j178/prek/pull/804)) ### Contributors - @mondeja - @j178 ## 0.2.2 Released on 2025-09-26. ### Enhancements - Add `prek cache dir`, move `prek gc` and `prek clean` under `prek cache` ([#795](https://github.com/j178/prek/pull/795)) - Add a hint when hooks failed in CI ([#800](https://github.com/j178/prek/pull/800)) - Add support for specifying `PREK_UV_SOURCE` ([#766](https://github.com/j178/prek/pull/766)) - Run docker container with `--init` ([#791](https://github.com/j178/prek/pull/791)) - Support `--allow-multiple-documents` for `check-yaml` ([#790](https://github.com/j178/prek/pull/790)) ### Bug fixes - Fix interpreter identification ([#801](https://github.com/j178/prek/pull/801)) ### Documentation - Add PaperQA2 to "Who is using prek" ([#793](https://github.com/j178/prek/pull/793)) - Clarify built-in hooks activation conditions and behavior ([#781](https://github.com/j178/prek/pull/781)) - Deduplicate docs between README and MkDocs site ([#792](https://github.com/j178/prek/pull/792)) - Mention `j178/prek-action` in docs ([#753](https://github.com/j178/prek/pull/753)) ### Other Changes - Bump `pre-commit-hooks` in sample-config to v6.0.0 ([#761](https://github.com/j178/prek/pull/761)) - Improve arg parsing for builtin hooks ([#789](https://github.com/j178/prek/pull/789)) ### Contributors - @mondeja - @akx - @bxb100 - @j178 - @onerandomusername ## 0.2.1 ### Enhancements - auto-update: prefer tags that are most similar to the current version ([#719](https://github.com/j178/prek/pull/719)) ### Bug fixes - Fix `git --no-pager diff` command syntax upon failures ([#746](https://github.com/j178/prek/pull/746)) - Clean working tree of current workspace only ([#747](https://github.com/j178/prek/pull/747)) - Use concurrent read and write in `git check-attr` ([#731](https://github.com/j178/prek/pull/731)) ### Documentation - Fix typo in language-version to language_version ([#727](https://github.com/j178/prek/pull/727)) - Update benchmarks ([#728](https://github.com/j178/prek/pull/728)) ### Contributors - @j178 - @matthiask - @AdrianDC - @onerandomusername ## 0.2.0 This is a huge milestone release that introduces **Workspace Mode** — first‑class monorepo support. `prek` now allows you to manage multiple projects with their own `.pre-commit-config.yaml` within a single repository. It auto‑discovers nested projects, runs hooks in project scope, and provides flexible selectors to target specific projects and hooks. This makes `prek` a powerful tool for managing pre-commit hooks in complex repository structures. For more details, see [Workspace Mode](https://prek.j178.dev/workspace/). If you encounter any issues, please report them at [Issues](https://github.com/j178/prek/issues). **Note**: If you ran `prek install` in a repo before, you gonna need to run `prek install` again to replace the old git hook scripts for the workspace mode to work. Special thanks to @potiuk for all the help and feedback in designing and testing this feature! For detailed changes between 0.1.6 and 0.2.0, see [0.2.0-alpha.2](https://github.com/j178/prek/releases/v0.2.0-alpha.2), [0.2.0-alpha.3](https://github.com/j178/prek/releases/v0.2.0-alpha.3), [0.2.0-alpha.4](https://github.com/j178/prek/releases/v0.2.0-alpha.4), and [0.2.0-alpha.5](https://github.com/j178/prek/releases/v0.2.0-alpha.5). ### Enhancements - Fix parsing of tag describe for prerelease versions ([#714](https://github.com/j178/prek/pull/714)) - Truncate log file each time ([#717](https://github.com/j178/prek/pull/717)) ### Performance - Enable more aggressive optimizations for release ([#724](https://github.com/j178/prek/pull/724)) - Speed up check_toml ([#713](https://github.com/j178/prek/pull/713)) ### Bug fixes - Fix hook-impl don't run hooks when specified allow missing config ([#716](https://github.com/j178/prek/pull/716)) - fix: support py38 for pygrep ([#723](https://github.com/j178/prek/pull/723)) ### Other changes - Fix installation on fish and with missing tags ([#721](https://github.com/j178/prek/pull/721)) ### Contributors - @onerandomusername - @kushudai - @j178 ## 0.2.0a5 ### Enhancements - Add built in byte-order-marker fixer ([#700](https://github.com/j178/prek/pull/700)) - Use bigger buffer for fixing trailing whitespace ([#705](https://github.com/j178/prek/pull/705)) ### Bug fixes - Fix `trailing-whitespace` & `mixed-line-ending` write file path ([#708](https://github.com/j178/prek/pull/708)) - Fix file path handling for meta hooks in workspace mode ([#699](https://github.com/j178/prek/pull/699)) ### Documentation - Add docs about configuration ([#703](https://github.com/j178/prek/pull/703)) - Add docs about debugging ([#702](https://github.com/j178/prek/pull/702)) - Generate cli reference ([#707](https://github.com/j178/prek/pull/707)) ### Contributors - @kushudai - @j178 ## 0.2.0a4 ### Enhancements - Bring back `.pre-commit-config.yml` support ([#676](https://github.com/j178/prek/pull/676)) - Ignore config file from hidden directory ([#677](https://github.com/j178/prek/pull/677)) - Support selectors in `prek install/install-hooks/hook-impl` ([#683](https://github.com/j178/prek/pull/683)) ### Bug fixes - Do not set GOROOT for system install Go when running go hooks ([#694](https://github.com/j178/prek/pull/694)) - Fix `check_toml` and `check_yaml` in workspace mode ([#688](https://github.com/j178/prek/pull/688)) ### Documentation - Add docs about TODOs ([#679](https://github.com/j178/prek/pull/679)) - Add docs about builtin hooks ([#678](https://github.com/j178/prek/pull/678)) ### Other changes - docs(manifest): Correctly specify metadata for all packages ([#687](https://github.com/j178/prek/pull/687)) - refactor(cli): Clean up usage of clap ([#689](https://github.com/j178/prek/pull/689)) ### Contributors - @j178 - @epage - @aravindan888 ## 0.2.0a3 ### Enhancements - Add a warning to `hook-impl` when the script needs reinstall ([#647](https://github.com/j178/prek/pull/647)) ### Documentation - Add a notice to rerun `prek install` when upgrading to 0.2.0 ([#646](https://github.com/j178/prek/pull/646)) ### Contributors - @j178 ## 0.2.0-alpha.2 *This is a re-release of [0.2.0-alpha.1](https://github.com/j178/prek/releases/tag/v0.2.0-alpha.1), fixed an issue that prereleases are not published to PyPI.* This is a huge milestone release that introduces **Workspace Mode** — first‑class monorepo support. `prek` now allows you to manage multiple projects with their own `.pre-commit-config.yaml` within a single repository. It auto‑discovers nested projects, runs hooks in project scope, and provides flexible selectors to target specific projects and hooks. This makes `prek` a powerful tool for managing pre-commit hooks in complex repository structures. **Note**: If you ran `prek install` in a repo before, you gonna need to run `prek install` again to replace the old git hook scripts for the workspace mode to work. For more details, see [Workspace Mode](https://prek.j178.dev/workspace/). If you encounter any issues, please report them at [Issues](https://github.com/j178/prek/issues). Special thanks to @potiuk for all the help and feedback in designing and testing this feature! ### Enhancements - Support multiple `.pre-commit-config.yaml` in a workspace (monorepo mode) ([#583](https://github.com/j178/prek/pull/583)) - Implement project and hook selector ([#623](https://github.com/j178/prek/pull/623)) - Add `prek run --cd ` to change directory before running ([#581](https://github.com/j178/prek/pull/581)) - Support `prek list` in workspace mode ([#586](https://github.com/j178/prek/pull/586)) - Support `prek install|install-hooks|hook-impl|init-template-dir` in workspace mode ([#595](https://github.com/j178/prek/pull/595)) - Implement `auto-update` in workspace mode ([#605](https://github.com/j178/prek/pull/605)) - Implement selector completion in workspace mode ([#639](https://github.com/j178/prek/pull/639)) - Simplify `auto-update` implementation ([#608](https://github.com/j178/prek/pull/608)) - Add a `--dry-run` flag to `prek run` ([#622](https://github.com/j178/prek/pull/622)) - Cache workspace discovery result ([#636](https://github.com/j178/prek/pull/636)) - Fix local script hook entry path in workspace mode ([#603](https://github.com/j178/prek/pull/603)) - Fix `hook-impl` allow missing config ([#600](https://github.com/j178/prek/pull/600)) - Fix docker mount in workspace mode ([#638](https://github.com/j178/prek/pull/638)) - Show project line when project is not root ([#637](https://github.com/j178/prek/pull/637)) ### Documentation - Publish docs to `https://prek.j178.dev` ([#627](https://github.com/j178/prek/pull/627)) - Improve workspace docs about skips rule ([#615](https://github.com/j178/prek/pull/615)) - Add an full example and update docs ([#582](https://github.com/j178/prek/pull/582)) ### Other changes - Docs: `.pre-commit-config.yml` support has been removed ([#630](https://github.com/j178/prek/pull/630)) - Enable publishing prereleases ([#641](https://github.com/j178/prek/pull/641)) ### Contributors - [@luizvbo](https://github.com/luizvbo) - [@j178](https://github.com/j178) - [@hugovk](https://github.com/hugovk) ## 0.1.6 ### Enhancements - Improve hook install concurrency ([#611](https://github.com/j178/prek/pull/611)) - Parse JSON from slice ([#604](https://github.com/j178/prek/pull/604)) ### Bug fixes - Reuse hook env only for exactly same dependencies ([#609](https://github.com/j178/prek/pull/609)) - Workaround checkout file failure on Windows ([#616](https://github.com/j178/prek/pull/616)) ## 0.1.5 ### Enhancements - Implement `pre-push` hook type ([#598](https://github.com/j178/prek/pull/598)) - Implement `pre-commit-hooks:check_yaml` as builtin hook ([#557](https://github.com/j178/prek/pull/557)) - Implement `pre-commit-hooks:check-toml` as builtin hook ([#564](https://github.com/j178/prek/pull/564)) - Add validation for file type tags ([#565](https://github.com/j178/prek/pull/565)) - Ignore NotFound error in extracting metadata log ([#597](https://github.com/j178/prek/pull/597)) ### Documentation - Update project status ([#578](https://github.com/j178/prek/pull/578)) ### Other changes - Bump tracing-subscriber to 0.3.20 ([#567](https://github.com/j178/prek/pull/567)) - Remove color from trace log ([#580](https://github.com/j178/prek/pull/580)) ## 0.1.4 ### Enhancements - Improve docker image labels ([#551](https://github.com/j178/prek/pull/551)) ### Performance - Avoid unnecessary allocation in `run_by_batch` ([#549](https://github.com/j178/prek/pull/549)) - Cache current docker container mounts ([#552](https://github.com/j178/prek/pull/552)) ### Bug fixes - Fix `trailing-whitespace` cannot handle file contains invalid utf-8 data ([#544](https://github.com/j178/prek/pull/544)) - Fix trailing-whitespace eol trimming ([#546](https://github.com/j178/prek/pull/546)) - Fix trailing-whitespace markdown eol trimming ([#547](https://github.com/j178/prek/pull/547)) ### Documentation - Add authlib to `Who are using prek` ([#550](https://github.com/j178/prek/pull/550)) ## 0.1.3 ### Enhancements - Support PEP 723 scripts for Python hooks ([#529](https://github.com/j178/prek/pull/529)) ### Bug fixes - Fix Python hook stderr are not captured ([#530](https://github.com/j178/prek/pull/530)) ### Other changes - Add an error context when reading manifest failed ([#527](https://github.com/j178/prek/pull/527)) - Add a renovate rule to bump bundled uv version ([#528](https://github.com/j178/prek/pull/528)) - Disable semantic commits for renovate PRs ([#538](https://github.com/j178/prek/pull/538)) ## 0.1.2 ### Enhancements - Add check for missing hooks in new revision ([#521](https://github.com/j178/prek/pull/521)) ### Bug fixes - Fix `language: script` entry join issue ([#525](https://github.com/j178/prek/pull/525)) ### Other changes - Add OpenLineage to prek users ([#523](https://github.com/j178/prek/pull/523)) ## 0.1.1 ### Breaking changes - Drop support `.yml` config file ([#493](https://github.com/j178/prek/pull/493)) ### Enhancements - Add moving rev warning ([#488](https://github.com/j178/prek/pull/488)) - Implement `prek auto-update` ([#511](https://github.com/j178/prek/pull/511)) - Support local path as a `repo` url ([#513](https://github.com/j178/prek/pull/513)) ### Bug fixes - Fix recursion limit when checking deeply nested json ([#507](https://github.com/j178/prek/pull/507)) - Fix rename tempfile across device ([#508](https://github.com/j178/prek/pull/508)) - Fix build on s390x ([#518](https://github.com/j178/prek/pull/518)) ### Other changes - docs: install prek with mise ([#510](https://github.com/j178/prek/pull/510)) ## 0.0.29 ### Enhancements - Build wheels for more platforms ([#489](https://github.com/j178/prek/pull/489)) ### Bug fixes - Fix `git commit -a` does not pick up staged files correctly ([#487](https://github.com/j178/prek/pull/487)) ## 0.0.28 ### Bug fixes - Fix `inde.lock file exists` error when running `git commit -p` or `git commit -a` ([#482](https://github.com/j178/prek/pull/482)) - Various fixes to `init-templdate-dir` and directory related bug ([#484](https://github.com/j178/prek/pull/484)) ## 0.0.27 ### Enhancements - Clone repo temporarily into scratch directory ([#478](https://github.com/j178/prek/pull/478)) - Don’t show the progress bar if there’s no need for cloning or installing hooks ([#477](https://github.com/j178/prek/pull/477)) - Support `language_version: lts` for node ([#473](https://github.com/j178/prek/pull/473)) ### Bug fixes - Adjust `sample-config` file path before writing ([#474](https://github.com/j178/prek/pull/474)) - Resolve script shebang before running ([#475](https://github.com/j178/prek/pull/475)) ## 0.0.26 ### Enhancements - Disable `prek self update` for package managers ([#468](https://github.com/j178/prek/pull/468)) - Download uv from github releases directly ([#464](https://github.com/j178/prek/pull/464)) - Find `uv` alongside the `prek` binary ([#466](https://github.com/j178/prek/pull/466)) - Run hooks with pty if color enabled ([#471](https://github.com/j178/prek/pull/471)) - Warn unexpected keys in config ([#463](https://github.com/j178/prek/pull/463)) ### Bug fixes - Canonicalize prek executable path ([#467](https://github.com/j178/prek/pull/467)) ### Documentation - Add "Who are using prek" to README ([#458](https://github.com/j178/prek/pull/458)) ## 0.0.25 ### Enhancements - Add check for `minimum_prek_version` ([#437](https://github.com/j178/prefligit/pull/437)) - Make `--to-ref` default to HEAD if `--from-ref` is specified ([#426](https://github.com/j178/prefligit/pull/426)) - Support downloading uv from pypi and mirrors ([#449](https://github.com/j178/prefligit/pull/449)) - Write trace log to `$PREK_HOME/prek.log` ([#447](https://github.com/j178/prefligit/pull/447)) - Implement `mixed_line_ending` as builtin hook ([#444](https://github.com/j178/prefligit/pull/444)) - Support `--output-format=json` in `prek list` ([#446](https://github.com/j178/prefligit/pull/446)) - Add context message to install error ([#455](https://github.com/j178/prefligit/pull/455)) - Add warning for non-existent hook id ([#450](https://github.com/j178/prefligit/pull/450)) ### Performance - Refactor `fix_trailing_whitespace` ([#411](https://github.com/j178/prefligit/pull/411)) ### Bug fixes - Calculate more accurate max cli length ([#442](https://github.com/j178/prefligit/pull/442)) - Fix uv install on Windows ([#453](https://github.com/j178/prefligit/pull/453)) - Static link `liblzma` ([#445](https://github.com/j178/prefligit/pull/445)) ## 0.0.24 ### Enhancements - Add dynamic completion of hook ids ([#380](https://github.com/j178/prek/pull/380)) - Implement `prek list` to list available hooks ([#424](https://github.com/j178/prek/pull/424)) - Implement `pygrep` language support ([#383](https://github.com/j178/prek/pull/383)) - Support `prek run` multiple hooks ([#423](https://github.com/j178/prek/pull/423)) - Implement `check_json` as builtin hook ([#416](https://github.com/j178/prek/pull/416)) ### Performance - Avoid reading whole file into memory in `fix_end_of_file` and make it consistent with `pre-commit-hooks` ([#399](https://github.com/j178/prek/pull/399)) ### Bug fixes - Do not set `GOROOT` and `GOPATH` for system found go ([#415](https://github.com/j178/prek/pull/415)) ### Documentation - Use `brew install j178/tap/prek` for now ([#420](https://github.com/j178/prek/pull/420)) - chore: logo rebranded, Update README.md ([#408](https://github.com/j178/prek/pull/408)) ## 0.0.23 ### Breaking changes In this release, we've renamed the project to `prek` from `prefligit`. It's shorter so easier to type, and it avoids typosquatting with `preflight`. This means that the command-line name is now `prek`, and the PyPI package is now listed as [`prek`](https://pypi.org/project/prek/). And the Homebrew will be updated to `prek` as well. And previously, the cache directory was `~/.cache/prefligit`, now it is `~/.cache/prek`. You'd have to delete the old cache directory manually, or run `prefligit clean` to clean it up. Then uninstall the old `prefligit` and install the new `prek` from scratch. ### Enhancements - Relax uv version check range ([#396](https://github.com/j178/prefligit/pull/396)) ### Bug fixes - Fix `script` command path ([#398](https://github.com/j178/prefligit/pull/398)) - Fix meta hook `check_useless_excludes` ([#401](https://github.com/j178/prefligit/pull/401)) ### Other changes - Rename to `prek` from `prefligit` ([#402](https://github.com/j178/prefligit/pull/402)) ## 0.0.22 ### Enhancements - Add value hint to `prefligit run` flags ([#373](https://github.com/j178/prefligit/pull/373)) - Check minimum supported version for uv found from system ([#352](https://github.com/j178/prefligit/pull/352)) ### Bug fixes - Fix `check_added_large_files` parameter name ([#389](https://github.com/j178/prefligit/pull/389)) - Fix `npm install` on Windows ([#374](https://github.com/j178/prefligit/pull/374)) - Fix docker mount options ([#377](https://github.com/j178/prefligit/pull/377)) - Fix identify tags for `Pipfile.lock` ([#391](https://github.com/j178/prefligit/pull/391)) - Fix identifying symlinks ([#378](https://github.com/j178/prefligit/pull/378)) - Set `GOROOT` when installing golang hook ([#381](https://github.com/j178/prefligit/pull/381)) ### Other changes - Add devcontainer config ([#379](https://github.com/j178/prefligit/pull/379)) - Bump rust toolchain to 1.89 ([#386](https://github.com/j178/prefligit/pull/386)) ## 0.0.21 ### Enhancements - Add `--directory` to `prefligit run` ([#358](https://github.com/j178/prefligit/pull/358)) - Implement `tags_from_interpreter` ([#362](https://github.com/j178/prefligit/pull/362)) - Set GOBIN to `/bin`, set GOPATH to `$PREGLIGIT_HOME/cache/go` ([#369](https://github.com/j178/prefligit/pull/369)) ### Performance - Make Partitions iterator produce slice instead of Vec ([#361](https://github.com/j178/prefligit/pull/361)) - Use `rustc_hash` ([#359](https://github.com/j178/prefligit/pull/359)) ### Bug fixes - Add `node` to PATH when running `npm` ([#371](https://github.com/j178/prefligit/pull/371)) - Fix bug that default hook stage should be pre-commit ([#367](https://github.com/j178/prefligit/pull/367)) - Fix cache dir permission before clean ([#368](https://github.com/j178/prefligit/pull/368)) ### Other changes - Move `Project` into `workspace` module ([#364](https://github.com/j178/prefligit/pull/364)) ## 0.0.20 ### Enhancements - Support golang hooks and golang toolchain management ([#355](https://github.com/j178/prefligit/pull/355)) - Add `--last-commit` flag to `prefligit run` ([#351](https://github.com/j178/prefligit/pull/351)) ### Bug fixes - Fix bug that directories are ignored ([#350](https://github.com/j178/prefligit/pull/350)) - Use `git ls-remote` to fetch go releases ([#356](https://github.com/j178/prefligit/pull/356)) ### Documentation - Add migration section to README ([#354](https://github.com/j178/prefligit/pull/354)) ## 0.0.19 ### Enhancements - Improve node support ([#346](https://github.com/j178/prefligit/pull/346)) - Manage uv cache dir ([#345](https://github.com/j178/prefligit/pull/345)) ### Bug fixes - Add `--install-links` to `npm install` ([#347](https://github.com/j178/prefligit/pull/347)) - Fix large file check to use staged_get instead of intent_add ([#332](https://github.com/j178/prefligit/pull/332)) ## 0.0.18 ### Enhancements - Impl `FromStr` for language request ([#338](https://github.com/j178/prefligit/pull/338)) ### Performance - Use DFS to find connected components in hook dependencies ([#341](https://github.com/j178/prefligit/pull/341)) - Use more `Arc` over `Box` ([#333](https://github.com/j178/prefligit/pull/333)) ### Bug fixes - Fix node path match, add tests ([#339](https://github.com/j178/prefligit/pull/339)) - Skipped hook name should be taken into account for columns ([#335](https://github.com/j178/prefligit/pull/335)) ### Documentation - Add benchmarks ([#342](https://github.com/j178/prefligit/pull/342)) - Update docs ([#337](https://github.com/j178/prefligit/pull/337)) ## 0.0.17 ### Enhancements - Add `sample-config --file` to write sample config to file ([#313](https://github.com/j178/prefligit/pull/313)) - Cache computed `dependencies` on hook ([#319](https://github.com/j178/prefligit/pull/319)) - Cache the found path to uv ([#323](https://github.com/j178/prefligit/pull/323)) - Improve `sample-config` writing file ([#314](https://github.com/j178/prefligit/pull/314)) - Reimplement find matching env logic ([#327](https://github.com/j178/prefligit/pull/327)) ### Bug fixes - Fix issue that `entry` of `pygrep` is not shell commands ([#316](https://github.com/j178/prefligit/pull/316)) - Support `python311` as a valid language version ([#321](https://github.com/j178/prefligit/pull/321)) ### Other changes - Bump cargo-dist to 0.29.0 ([#322](https://github.com/j178/prefligit/pull/322)) - Update DIFF.md ([#318](https://github.com/j178/prefligit/pull/318)) ## 0.0.16 ### Enhancements - Improve error message for hook ([#308](https://github.com/j178/prefligit/pull/308)) - Improve error message for hook installation and run ([#310](https://github.com/j178/prefligit/pull/310)) - Improve hook invalid error message ([#307](https://github.com/j178/prefligit/pull/307)) - Parse `entry` when constructing hook ([#306](https://github.com/j178/prefligit/pull/306)) - Rename `autoupdate` to `auto-update`, `init-templatedir` to `init-template-dir` ([#302](https://github.com/j178/prefligit/pull/302)) ### Bug fixes - Fix `end-of-file-fixer` replaces `\r\n` with `\n` ([#311](https://github.com/j178/prefligit/pull/311)) ## 0.0.15 In this release, `language: node` hooks are fully supported now (finally)!. Give it a try and let us know if you run into any issues! ### Enhancements - Support `nodejs` language hook ([#298](https://github.com/j178/prefligit/pull/298)) - Show unimplemented message earlier ([#296](https://github.com/j178/prefligit/pull/296)) - Simplify npm installing dependencies ([#299](https://github.com/j178/prefligit/pull/299)) ### Documentation - Update readme ([#300](https://github.com/j178/prefligit/pull/300)) ## 0.0.14 ### Enhancements - Show unimplemented status instead of panic ([#290](https://github.com/j178/prefligit/pull/290)) - Try default uv managed python first, fallback to download ([#291](https://github.com/j178/prefligit/pull/291)) ### Other changes - Update Rust crate fancy-regex to 0.16.0 ([#286](https://github.com/j178/prefligit/pull/286)) - Update Rust crate indicatif to 0.18.0 ([#287](https://github.com/j178/prefligit/pull/287)) - Update Rust crate pprof to 0.15.0 ([#288](https://github.com/j178/prefligit/pull/288)) - Update Rust crate serde_json to v1.0.142 ([#285](https://github.com/j178/prefligit/pull/285)) - Update astral-sh/setup-uv action to v6 ([#289](https://github.com/j178/prefligit/pull/289)) ## 0.0.13 ### Enhancements - Add `PREFLIGIT_NO_FAST_PATH` to disable Rust fast path ([#272](https://github.com/j178/prefligit/pull/272)) - Improve subprocess error message ([#276](https://github.com/j178/prefligit/pull/276)) - Remove `LanguagePreference` and improve language check ([#277](https://github.com/j178/prefligit/pull/277)) - Support downloading requested Python version automatically ([#281](https://github.com/j178/prefligit/pull/281)) - Implement language specific version parsing ([#273](https://github.com/j178/prefligit/pull/273)) ### Bug fixes - Fix python version matching ([#275](https://github.com/j178/prefligit/pull/275)) - Show progress bar in verbose mode ([#278](https://github.com/j178/prefligit/pull/278)) ## 0.0.12 ### Bug fixes - Ignore `config not staged` error for config outside the repo ([#270](https://github.com/j178/prefligit/pull/270)) ### Other changes - Add test fixture files ([#266](https://github.com/j178/prefligit/pull/266)) - Use `sync_all` over `flush` ([#269](https://github.com/j178/prefligit/pull/269)) ## 0.0.11 ### Enhancements - Support reading `.pre-commit-config.yml` as well ([#213](https://github.com/j178/prefligit/pull/213)) - Refactor language version resolution and hook install dir ([#221](https://github.com/j178/prefligit/pull/221)) - Implement `prefligit install-hooks` command ([#258](https://github.com/j178/prefligit/pull/258)) - Implement `pre-commit-hooks:end-of-file-fixer` hook ([#255](https://github.com/j178/prefligit/pull/255)) - Implement `pre-commit-hooks:check_added_large_files` hook ([#219](https://github.com/j178/prefligit/pull/219)) - Implement `script` language hooks ([#252](https://github.com/j178/prefligit/pull/252)) - Implement node.js installer ([#152](https://github.com/j178/prefligit/pull/152)) - Use `-v` to show only verbose message, `-vv` show debug log, `-vvv` show trace log ([#211](https://github.com/j178/prefligit/pull/211)) - Write `.prefligit-repo.json` inside cloned repo ([#225](https://github.com/j178/prefligit/pull/225)) - Add language name to 'not yet implemented' messages ([#251](https://github.com/j178/prefligit/pull/251)) ### Bug fixes - Do not install if no additional dependencies for local python hook ([#195](https://github.com/j178/prefligit/pull/195)) - Ensure flushing log file ([#261](https://github.com/j178/prefligit/pull/261)) - Fix zip deflate ([#194](https://github.com/j178/prefligit/pull/194)) ### Other changes - Bump to Rust 1.88 and `cargo update` ([#254](https://github.com/j178/prefligit/pull/254)) - Upgrade to Rust 2024 edition ([#196](https://github.com/j178/prefligit/pull/196)) - Bump uv version ([#260](https://github.com/j178/prefligit/pull/260)) - Simplify archive extraction implementation ([#193](https://github.com/j178/prefligit/pull/193)) - Use `astral-sh/rs-async-zip` ([#259](https://github.com/j178/prefligit/pull/259)) - Use `ubuntu-latest` for release action ([#216](https://github.com/j178/prefligit/pull/216)) - Use async closure ([#200](https://github.com/j178/prefligit/pull/200)) ## 0.0.10 ### Breaking changes **Warning**: This release changed the store layout, it's recommended to delete the old store and install from scratch. To delete the old store, run: ```sh rm -rf ~/.cache/prefligit ``` ### Enhancements - Restructure store folders layout ([#181](https://github.com/j178/prefligit/pull/181)) - Fallback some env vars to to pre-commit ([#175](https://github.com/j178/prefligit/pull/175)) - Save patches to `$PREFLIGIT_HOME/patches` ([#182](https://github.com/j178/prefligit/pull/182)) ### Bug fixes - Fix removing git env vars ([#176](https://github.com/j178/prefligit/pull/176)) - Fix typo in Cargo.toml ([#160](https://github.com/j178/prefligit/pull/160)) ### Other changes - Do not publish to crates.io ([#191](https://github.com/j178/prefligit/pull/191)) - Bump cargo-dist to v0.28.0 ([#170](https://github.com/j178/prefligit/pull/170)) - Bump uv version to 0.6.0 ([#184](https://github.com/j178/prefligit/pull/184)) - Configure Renovate ([#168](https://github.com/j178/prefligit/pull/168)) - Format sample config output ([#172](https://github.com/j178/prefligit/pull/172)) - Make env vars a shareable crate ([#171](https://github.com/j178/prefligit/pull/171)) - Reduce String alloc ([#166](https://github.com/j178/prefligit/pull/166)) - Skip common git flags in command trace log ([#162](https://github.com/j178/prefligit/pull/162)) - Update Rust crate clap to v4.5.29 ([#173](https://github.com/j178/prefligit/pull/173)) - Update Rust crate which to v7.0.2 ([#163](https://github.com/j178/prefligit/pull/163)) - Update astral-sh/setup-uv action to v5 ([#164](https://github.com/j178/prefligit/pull/164)) - Upgrade Rust to 1.84 and upgrade dependencies ([#161](https://github.com/j178/prefligit/pull/161)) ## 0.0.9 Due to a mistake in the release process, this release is skipped. ## 0.0.8 ### Enhancements - Move home dir to `~/.cache/prefligit` ([#154](https://github.com/j178/prefligit/pull/154)) - Implement trailing-whitespace in Rust ([#137](https://github.com/j178/prefligit/pull/137)) - Limit hook install concurrency ([#145](https://github.com/j178/prefligit/pull/145)) - Simplify language default version implementation ([#150](https://github.com/j178/prefligit/pull/150)) - Support install uv from pypi ([#149](https://github.com/j178/prefligit/pull/149)) - Add executing command to error message ([#141](https://github.com/j178/prefligit/pull/141)) ### Bug fixes - Use hook `args` in fast path ([#139](https://github.com/j178/prefligit/pull/139)) ### Other changes - Remove hook install_key ([#153](https://github.com/j178/prefligit/pull/153)) - Remove pyvenv.cfg patch ([#156](https://github.com/j178/prefligit/pull/156)) - Try to use D drive on Windows CI ([#157](https://github.com/j178/prefligit/pull/157)) - Tweak trailing-whitespace-fixer ([#140](https://github.com/j178/prefligit/pull/140)) - Upgrade dist to v0.27.0 ([#158](https://github.com/j178/prefligit/pull/158)) - Uv install python into tools path ([#151](https://github.com/j178/prefligit/pull/151)) ## 0.0.7 ### Enhancements - Add progress bar for hook init and install ([#122](https://github.com/j178/prefligit/pull/122)) - Add color to command help ([#131](https://github.com/j178/prefligit/pull/131)) - Add commit info to version display ([#130](https://github.com/j178/prefligit/pull/130)) - Support meta hooks reading ([#134](https://github.com/j178/prefligit/pull/134)) - Implement meta hooks ([#135](https://github.com/j178/prefligit/pull/135)) ### Bug fixes - Fix same repo clone multiple times ([#125](https://github.com/j178/prefligit/pull/125)) - Fix logging level after renaming ([#119](https://github.com/j178/prefligit/pull/119)) - Fix version tag distance ([#132](https://github.com/j178/prefligit/pull/132)) ### Other changes - Disable uv cache on Windows ([#127](https://github.com/j178/prefligit/pull/127)) - Impl Eq and Hash for ConfigRemoteRepo ([#126](https://github.com/j178/prefligit/pull/126)) - Make `pass_env_vars` runs on Windows ([#133](https://github.com/j178/prefligit/pull/133)) - Run cargo update ([#129](https://github.com/j178/prefligit/pull/129)) - Update Readme ([#128](https://github.com/j178/prefligit/pull/128)) ## 0.0.6 ### Breaking changes In this release, we’ve renamed the project to `prefligit` (a deliberate misspelling of preflight) to prevent confusion with the existing pre-commit tool. For further information, refer to issue #73. - The command-line name is now `prefligit`. We suggest uninstalling any previous version of `pre-commit-rs` and installing `prefligit` from scratch. - The PyPI package is now listed as [`prefligit`](https://pypi.org/project/prefligit/). - The Cargo package is also now [`prefligit`](https://crates.io/crates/prefligit). - The Homebrew formula has been updated to `prefligit`. ### Enhancements - Support `docker_image` language ([#113](https://github.com/j178/pre-commit-rs/pull/113)) - Support `init-templatedir` subcommand ([#101](https://github.com/j178/pre-commit-rs/pull/101)) - Implement get filenames from merge conflicts ([#103](https://github.com/j178/pre-commit-rs/pull/103)) ### Bug fixes - Fix `prefligit install --hook-type` name ([#102](https://github.com/j178/pre-commit-rs/pull/102)) ### Other changes - Apply color option to log ([#100](https://github.com/j178/pre-commit-rs/pull/100)) - Improve tests ([#106](https://github.com/j178/pre-commit-rs/pull/106)) - Remove intermedia Language enum ([#107](https://github.com/j178/pre-commit-rs/pull/107)) - Run `cargo clippy` in the dev drive workspace ([#115](https://github.com/j178/pre-commit-rs/pull/115)) ## 0.0.5 ### Enhancements v0.0.4 release process was broken, so this release is a actually a re-release of v0.0.4. - Improve subprocess trace and error output ([#92](https://github.com/j178/pre-commit-rs/pull/92)) - Stash working tree before running hooks ([#96](https://github.com/j178/pre-commit-rs/pull/96)) - Add color to command trace ([#94](https://github.com/j178/pre-commit-rs/pull/94)) - Improve hook output display ([#79](https://github.com/j178/pre-commit-rs/pull/79)) - Improve uv installation ([#78](https://github.com/j178/pre-commit-rs/pull/78)) - Support docker language ([#67](https://github.com/j178/pre-commit-rs/pull/67)) ## 0.0.4 ### Enhancements - Improve subprocess trace and error output ([#92](https://github.com/j178/pre-commit-rs/pull/92)) - Stash working tree before running hooks ([#96](https://github.com/j178/pre-commit-rs/pull/96)) - Add color to command trace ([#94](https://github.com/j178/pre-commit-rs/pull/94)) - Improve hook output display ([#79](https://github.com/j178/pre-commit-rs/pull/79)) - Improve uv installation ([#78](https://github.com/j178/pre-commit-rs/pull/78)) - Support docker language ([#67](https://github.com/j178/pre-commit-rs/pull/67)) ## 0.0.3 ### Bug fixes - Check uv installed after acquired lock ([#72](https://github.com/j178/pre-commit-rs/pull/72)) ### Other changes - Add copyright of the original pre-commit to LICENSE ([#74](https://github.com/j178/pre-commit-rs/pull/74)) - Add profiler ([#71](https://github.com/j178/pre-commit-rs/pull/71)) - Publish to PyPI ([#70](https://github.com/j178/pre-commit-rs/pull/70)) - Publish to crates.io ([#75](https://github.com/j178/pre-commit-rs/pull/75)) - Rename pypi package to `pre-commit-rusty` ([#76](https://github.com/j178/pre-commit-rs/pull/76)) ## 0.0.2 ### Enhancements - Add `pre-commit self update` ([#68](https://github.com/j178/pre-commit-rs/pull/68)) - Auto install uv ([#66](https://github.com/j178/pre-commit-rs/pull/66)) - Generate shell completion ([#20](https://github.com/j178/pre-commit-rs/pull/20)) - Implement `pre-commit clean` ([#24](https://github.com/j178/pre-commit-rs/pull/24)) - Implement `pre-commit install` ([#28](https://github.com/j178/pre-commit-rs/pull/28)) - Implement `pre-commit sample-config` ([#37](https://github.com/j178/pre-commit-rs/pull/37)) - Implement `pre-commit uninstall` ([#36](https://github.com/j178/pre-commit-rs/pull/36)) - Implement `pre-commit validate-config` ([#25](https://github.com/j178/pre-commit-rs/pull/25)) - Implement `pre-commit validate-manifest` ([#26](https://github.com/j178/pre-commit-rs/pull/26)) - Implement basic `pre-commit hook-impl` ([#63](https://github.com/j178/pre-commit-rs/pull/63)) - Partition filenames and delegate to multiple subprocesses ([#7](https://github.com/j178/pre-commit-rs/pull/7)) - Refactor xargs ([#8](https://github.com/j178/pre-commit-rs/pull/8)) - Skip empty config argument ([#64](https://github.com/j178/pre-commit-rs/pull/64)) - Use `fancy-regex` ([#62](https://github.com/j178/pre-commit-rs/pull/62)) - feat: add fail language support ([#60](https://github.com/j178/pre-commit-rs/pull/60)) ### Bug Fixes - Fix stage operate_on_files ([#65](https://github.com/j178/pre-commit-rs/pull/65)) ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to prek Thanks for your interest in improving **prek**! This guide walks through the development environment, our snapshot-based testing workflow, and the helper tasks defined in `mise.toml` to keep everything smooth. ## 1. Set up the Rust development environment 1. **Install Rust with `rustup`** (recommended) Install `rustup` from if you do not already have it. Then install the toolchain pinned in `rust-toolchain.toml` (currently Rust 1.90): ```bash rustup show ``` Finally, add the common developer components: ```bash rustup component add rustfmt clippy ``` 2. **Install project helper tools** Install [`mise`](https://mise.jdx.dev/) to manage project-specific tools and tasks, then run `mise install` in the repository root to download the tool versions declared in `mise.toml` (for example `cargo-insta`, `cargo-nextest`, and the language toolchains used in integration tests). 3. (Optional) **Bootstrap git hooks** ```bash prek install ``` This installs a `pre-push` git hook that keeps formatting and linting checks aligned with CI before you push changes. ## 2. Writing tests with `insta` snapshot assertions prek uses [insta](https://insta.rs/) for snapshot testing. It's recommended (but not necessary) to use `cargo-insta` for a better snapshot review experience. If you are contributing new functionality, please include coverage via unit tests (in `src/…` using `#[cfg(test)]`) or integration tests (under `tests/`). In integration tests, you can use `cmd_snapshot!` macro to simplify creating snapshots for prek commands. For example: ```rust #[test] fn test_run() { let context = TestContext::new(); context.init_project(); cmd_snapshot!(context.filters(), context.run(), @""); } ``` ## 3. Running tests and updating snapshots You can invoke the test suite directly with Cargo or use the convenience tasks defined in `mise.toml`. ### Direct Cargo commands - To run and review a specific snapshot test: ```bash cargo test --package --test -- -- --exact cargo insta review ``` Where `` is the crate name (for example, `prek`), `` is the integration test file name (for example, `builtin_hooks`), and `` is the specific test function name. - Run snapshot-aware tests with the review UI: ```bash cargo insta test --review [test arguments] ``` This command runs the selected tests, shows snapshot diffs, and lets you approve or reject updates interactively. ### Using mise tasks `mise run ` picks up the arguments and environment declared in `mise.toml`. Helpful tasks include: - `mise run test-unit -- ` – run binary/unit tests matching `` with `cargo insta test --review --bin prek`. - `mise run test-all-unit` – run all unit tests with snapshot review enabled. - `mise run test-integration [filter]` – run one integration test (for example `mise run test-integration builtin_hooks detect_private_key_hook`). - `mise run test-all-integration` – execute the full integration test suite with review prompts. - `mise run test` – run `cargo test` across the workspace without the snapshot review flow. - `mise run lint` – run `cargo fmt` and `cargo clippy` (useful before opening a pull request). ## 4. Before you open a pull request - Ensure `mise run lint` passes without errors. - Include documentation updates if your change alters the user-facing behavior. - Keep commits focused and write descriptive messages—this helps reviewers follow along. Thanks again for contributing! ================================================ FILE: Cargo.toml ================================================ [workspace] members = ["crates/*"] resolver = "3" [workspace.package] version = "0.3.6" edition = "2024" rust-version = "1.92.0" repository = "https://github.com/j178/prek" homepage = "https://prek.j178.dev/" license = "MIT" [workspace.dependencies] prek-consts = { path = "crates/prek-consts", version = "0.3.6" } prek-identify = { path = "crates/prek-identify", version = "0.3.6" } prek-pty = { path = "crates/prek-pty", version = "0.3.6" } aho-corasick = { version = "1.1.4" } anstream = { version = "1.0.0" } anstyle-query = { version = "1.1.5" } anyhow = { version = "1.0.86" } async-compression = { version = "0.4.18", features = ["gzip", "xz", "tokio"] } async_zip = { version = "0.0.17", package = "astral_async_zip", features = [ "deflate", "tokio", ] } axoupdater = { version = "0.10.0", default-features = false, features = [ "github_releases", ] } bstr = { version = "1.11.0" } cargo_metadata = { version = "0.23.1" } clap = { version = "4.6.0", features = [ "derive", "env", "string", "wrap_help", ] } clap_complete = { version = "4.6.0", features = ["unstable-dynamic"] } ctrlc = { version = "3.4.5" } dunce = { version = "1.0.5" } etcetera = { version = "0.11.0" } fancy-regex = { version = "0.17.0" } fs-err = { version = "3.3.0", features = ["tokio"] } futures = { version = "0.3.31" } hex = { version = "0.4.3" } http = { version = "1.1.0" } ignore = { version = "0.4.23" } indicatif = { version = "0.18.0" } indoc = { version = "2.0.5" } itertools = { version = "0.14.0" } json5 = { version = "1.3.0" } lazy-regex = { version = "3.4.2" } levenshtein = { version = "1.0.5" } libc = { version = "0.2.182" } # Enable static linking for liblzma # This is required for the `xz` feature in `async-compression` liblzma = { version = "0.4.5", features = ["static"] } mea = { version = "0.6.3" } memchr = { version = "2.7.5" } owo-colors = { version = "4.1.0" } path-clean = { version = "1.0.1" } phf = { version = "0.13.1", default-features = false, features = ["macros"] } pprof = { version = "0.15.0" } quick-xml = { version = "0.39" } rand = { version = "0.10.0" } rayon = { version = "1.10.0" } reqwest = { version = "0.13.2", default-features = false, features = [ "http2", "stream", "json", "rustls", "system-proxy", "socks", ] } rustc-hash = { version = "2.1.1" } rustix = { version = "1.0.8", features = ["pty", "process", "fs", "termios"] } same-file = { version = "1.0.6" } semver = { version = "1.0.24", features = ["serde"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = { version = "1.0.132", features = [ "preserve_order", "unbounded_depth", ] } serde_stacker = { version = "0.1.12" } serde-saphyr = { version = "0.0.21", default-features = false } shlex = { version = "1.3.0" } globset = { version = "0.4.18" } strum = { version = "0.28.0", features = ["derive"] } target-lexicon = { version = "0.13.0" } tempfile = { version = "3.25.0" } thiserror = { version = "2.0.11" } tokio = { version = "1.47.1", features = [ "fs", "io-std", "process", "rt", "sync", "macros", "net", ] } tokio-tar = { version = "0.6.0", package = "astral-tokio-tar" } tokio-util = { version = "0.7.13" } toml = { version = "1.0.1", default-features = false, features = [ "fast_hash", "parse", "preserve_order", "serde", ] } toml_edit = { version = "0.25.1" } tracing = { version = "0.1.40" } tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } unicode-width = { version = "0.2.0", default-features = false } walkdir = { version = "2.5.0" } webpki-root-certs = { version = "1.0.6" } which = { version = "8.0.0" } # dev-dependencies assert_cmd = { version = "2.2.0" } assert_fs = { version = "1.1.2" } insta = { version = "1.40.0", features = ["filters"] } insta-cmd = { version = "0.6.0" } markdown = { version = "1.0.0" } predicates = { version = "3.1.2" } pretty_assertions = { version = "1.4.1" } regex = { version = "1.11.0" } schemars = { version = "1.1.0" } textwrap = { version = "0.16.0" } [workspace.lints.rust] dead_code = "allow" [workspace.lints.clippy] pedantic = { level = "warn", priority = -2 } # Allowed pedantic lints collapsible_else_if = "allow" collapsible_if = "allow" if_not_else = "allow" implicit_hasher = "allow" map_unwrap_or = "allow" match_same_arms = "allow" missing_errors_doc = "allow" missing_panics_doc = "allow" module_name_repetitions = "allow" must_use_candidate = "allow" similar_names = "allow" too_many_arguments = "allow" too_many_lines = "allow" used_underscore_binding = "allow" items_after_statements = "allow" iter-without-into-iter = "allow" # Disallowed restriction lints print_stdout = "warn" print_stderr = "warn" dbg_macro = "warn" empty_drop = "warn" empty_structs_with_brackets = "warn" exit = "warn" get_unwrap = "warn" rc_buffer = "warn" rc_mutex = "warn" rest_pat_in_fully_bound_structs = "warn" [workspace.metadata.typos.default] extend-ignore-re = [ '(?s)-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', ] [workspace.metadata.typos.default.extend-words] edn = "edn" styl = "styl" jod = "jod" [workspace.metadata.typos.files] extend-exclude = ["scripts/macports"] [workspace.metadata.cargo-shear] ignored = ["liblzma"] [profile.dev.package] # Insta suggests compiling these packages in opt mode for faster testing. # See https://docs.rs/insta/latest/insta/#optional-faster-runs. insta.opt-level = 3 similar.opt-level = 3 # Profile for fast test execution: Skip debug info generation, and # apply basic optimization, which speed up build and running tests. [profile.fast-build] inherits = "dev" debug = 0 strip = "debuginfo" # Profile for faster builds: Skip debug info generation, for faster # builds of smaller binaries. [profile.no-debug] inherits = "dev" debug = 0 strip = "debuginfo" [profile.profiling] inherits = "release" strip = false debug = "full" lto = false codegen-units = 16 [profile.minimal-size] inherits = "release" # Enable Full LTO for the best optimizations lto = "fat" # Reduce codegen units to 1 for better optimizations codegen-units = 1 strip = true panic = "abort" # The profile that 'cargo dist' will build with [profile.dist] inherits = "minimal-size" ================================================ FILE: Dockerfile ================================================ FROM --platform=$BUILDPLATFORM ubuntu AS build ENV HOME="/root" WORKDIR $HOME RUN apt update \ && apt install -y --no-install-recommends \ build-essential \ curl \ python3-venv \ && apt clean \ && rm -rf /var/lib/apt/lists/* # Setup zig as cross compiling linker RUN python3 -m venv $HOME/.venv RUN .venv/bin/pip install cargo-zigbuild ENV PATH="$HOME/.venv/bin:$PATH" # Install rust ARG TARGETPLATFORM RUN case "$TARGETPLATFORM" in \ "linux/arm64") echo "aarch64-unknown-linux-musl" > rust_target.txt ;; \ "linux/amd64") echo "x86_64-unknown-linux-musl" > rust_target.txt ;; \ *) exit 1 ;; \ esac # Update rustup whenever we bump the rust version COPY rust-toolchain.toml rust-toolchain.toml RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --target $(cat rust_target.txt) --profile minimal --default-toolchain none ENV PATH="$HOME/.cargo/bin:$PATH" # Install the toolchain then the musl target RUN rustup toolchain install RUN rustup target add $(cat rust_target.txt) # Build COPY ./Cargo.toml Cargo.toml COPY ./Cargo.lock Cargo.lock COPY crates crates RUN case "${TARGETPLATFORM}" in \ "linux/arm64") export JEMALLOC_SYS_WITH_LG_PAGE=16;; \ esac && \ cargo zigbuild --bin prek --profile dist --target $(cat rust_target.txt) RUN cp target/$(cat rust_target.txt)/dist/prek /prek # TODO: Optimize binary size, with a version that also works when cross compiling # RUN strip --strip-all /prek FROM scratch COPY --from=build /prek / WORKDIR /io ENTRYPOINT ["/prek"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 j178 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
prek

prek

[![prek](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json)](https://github.com/j178/prek) [![PyPI version](https://img.shields.io/pypi/v/prek.svg)](https://pypi.python.org/pypi/prek) [![codecov](https://codecov.io/github/j178/prek/graph/badge.svg?token=MP6TY24F43)](https://codecov.io/github/j178/prek) [![PyPI Downloads](https://img.shields.io/pypi/dm/prek?logo=python)](https://pepy.tech/projects/prek) [![Discord](https://img.shields.io/discord/1403581202102878289?logo=discord)](https://discord.gg/3NRJUqJz86)
[pre-commit](https://pre-commit.com/) is a framework to run hooks written in many languages, and it manages the language toolchain and dependencies for running the hooks. *prek* is a reimagined version of pre-commit, built in Rust. It is designed to be a faster, dependency-free and drop-in alternative for it, while also providing some additional long-requested features. > [!NOTE] > Although prek is pretty new, it’s already powering real‑world projects like [CPython](https://github.com/python/cpython), [Apache Airflow](https://github.com/apache/airflow), [FastAPI](https://github.com/fastapi/fastapi), and more projects are picking it up—see [Who is using prek?](#who-is-using-prek). If you’re looking for an alternative to `pre-commit`, please give it a try—we’d love your feedback! > > Please note that some languages are not yet supported for full drop‑in parity with `pre-commit`. See [Language Support](https://prek.j178.dev/languages/) for current status. ## Features - A single binary with no dependencies, does not require Python or any other runtime. - [Faster](https://prek.j178.dev/benchmark/) than `pre-commit` and more efficient in disk space usage. - Fully compatible with the original pre-commit configurations and hooks. - Built-in support for monorepos (i.e. [workspace mode](https://prek.j178.dev/workspace/)). - Integration with [`uv`](https://github.com/astral-sh/uv) for managing Python virtual environments and dependencies. - Improved toolchain installations for Python, Node.js, Bun, Go, Rust and Ruby, shared between hooks. - [Built-in](https://prek.j178.dev/builtin/) Rust-native implementation of some common hooks. ## Table of contents - [Installation](#installation) - [Quick start](#quick-start) - [Why prek?](#why-prek) - [Who is using prek?](#who-is-using-prek) - [Acknowledgements](#acknowledgements) ## Installation
Standalone installer prek provides a standalone installer script to download and install the tool, On Linux and macOS: ```bash curl --proto '=https' --tlsv1.2 -LsSf https://github.com/j178/prek/releases/download/v0.3.6/prek-installer.sh | sh ``` On Windows: ```powershell powershell -ExecutionPolicy ByPass -c "irm https://github.com/j178/prek/releases/download/v0.3.6/prek-installer.ps1 | iex" ```
PyPI prek is published as Python binary wheel to PyPI, you can install it using `pip`, `uv` (recommended), or `pipx`: ```bash # Using uv (recommended) uv tool install prek # Using uvx (install and run in one command) uvx prek # Adding prek to the project dev-dependencies uv add --dev prek # Using pip pip install prek # Using pipx pipx install prek ```
Homebrew ```bash brew install prek ```
mise To use prek with [mise](https://mise.jdx.dev) ([v2025.8.11](https://github.com/jdx/mise/releases/tag/v2025.8.11) or later): ```bash mise use prek ```
Cargo binstall Install pre-compiled binaries from GitHub using [cargo-binstall](https://github.com/cargo-bins/cargo-binstall): ```bash cargo binstall prek ```
Cargo Build from source using Cargo (Rust 1.89+ is required): ```bash cargo install --locked prek ```
npmjs prek is published as a [Node.js package](https://www.npmjs.com/package/@j178/prek) and can be installed with any npm-compatible package manager: ```bash # As a dev dependency npm add -D @j178/prek pnpm add -D @j178/prek bun add -D @j178/prek # Or install globally npm install -g @j178/prek pnpm add -g @j178/prek bun install -g @j178/prek # Or run directly without installing npx @j178/prek --version bunx @j178/prek --version ```
Nix prek is available via [Nixpkgs](https://search.nixos.org/packages?channel=unstable&show=prek&query=prek). ```shell # Choose what's appropriate for your use case. # One-off in a shell: nix-shell -p prek # NixOS or non-NixOS without flakes: nix-env -iA nixos.prek # Non-NixOS with flakes: nix profile install nixpkgs#prek ```
Conda prek is available as `prek` via [conda-forge](https://anaconda.org/conda-forge/prek). ```shell conda install conda-forge::prek ```
Scoop (Windows) prek is available via [Scoop](https://scoop.sh/#/apps?q=prek). ```powershell scoop install main/prek ```
Winget (Windows) prek is available via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/). ```powershell winget install --id j178.Prek ```
MacPorts prek is available via [MacPorts](https://ports.macports.org/port/prek/). ```bash sudo port install prek ```
GitHub Releases Pre-built binaries are available for download from the [GitHub releases](https://github.com/j178/prek/releases) page.
GitHub Actions prek can be used in GitHub Actions via the [j178/prek-action](https://github.com/j178/prek-action) repository. Example workflow: ```yaml name: Prek checks on: [push, pull_request] jobs: prek: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: j178/prek-action@v2 ``` This action installs prek and runs `prek run --all-files` on your repository. prek is also available via [`taiki-e/install-action`](https://github.com/taiki-e/install-action) for installing various tools.
If installed via the standalone installer, prek can update itself to the latest version: ```bash prek self update ``` ## Quick start - **I already use pre-commit:** follow the short migration checklist in the [quickstart guide](https://prek.j178.dev/quickstart/#already-using-pre-commit) to swap in `prek` safely. - **I'm new to pre-commit-style tools:** learn the basics—creating a config, running hooks, and installing Git shims—in the [beginner quickstart walkthrough](https://prek.j178.dev/quickstart/#new-to-pre-commit-style-workflows). ## Why prek? ### prek is faster - It is [multiple times faster](https://prek.j178.dev/benchmark/) than `pre-commit` and takes up half the disk space. - It redesigned how hook environments and toolchains are managed, they are all shared between hooks, which reduces the disk space usage and speeds up the installation process. - Repositories are cloned in parallel, and hooks are installed in parallel if their dependencies are disjoint. - Hooks can run in parallel by priority (hooks with the same [`priority`](https://prek.j178.dev/configuration/#priority) may run concurrently), reducing end-to-end runtime. - It uses [`uv`](https://github.com/astral-sh/uv) for creating Python virtualenvs and installing dependencies, which is known for its speed and efficiency. - It implements some common hooks in Rust, [built in prek](https://prek.j178.dev/builtin/), which are faster than their Python counterparts. - It supports `repo: builtin` for offline, zero-setup hooks, which is not available in `pre-commit`. ### prek provides a better user experience - No need to install Python or any other runtime, just download a single binary. - No hassle with your Python version or virtual environments, prek automatically installs the required Python version and creates a virtual environment for you. - Built-in support for [workspaces](https://prek.j178.dev/workspace/) (or monorepos), each subproject can have its own `.pre-commit-config.yaml` file. - [`prek run`](https://prek.j178.dev/cli/#prek-run) has some nifty improvements over `pre-commit run`, such as: - `prek run --directory ` runs hooks for files in the specified directory, no need to use `git ls-files -- | xargs pre-commit run --files` anymore. - `prek run --last-commit` runs hooks for files changed in the last commit. - `prek run [HOOK] [HOOK]` selects and runs multiple hooks. - [`prek list`](https://prek.j178.dev/cli/#prek-list) command lists all available hooks, their ids, and descriptions, providing a better overview of the configured hooks. - [`prek auto-update`](https://prek.j178.dev/cli/#prek-auto-update) supports `--cooldown-days` to mitigate open source supply chain attacks. - prek provides shell completions for `prek run ` command, making it easier to run specific hooks without remembering their ids. For more detailed improvements prek offers, take a look at [Difference from pre-commit](https://prek.j178.dev/diff/). ## Who is using prek? prek is pretty new, but it is already being used or recommend by some projects and organizations: - [apache/airflow](https://github.com/apache/airflow/issues/44995) - [python/cpython](https://github.com/python/cpython/issues/143148) - [pdm-project/pdm](https://github.com/pdm-project/pdm/pull/3593) - [fastapi/fastapi](https://github.com/fastapi/fastapi/pull/14572) - [fastapi/typer](https://github.com/fastapi/typer/pull/1453) - [fastapi/asyncer](https://github.com/fastapi/asyncer/pull/437) - [astral-sh/ruff](https://github.com/astral-sh/ruff/pull/22505) - [astral-sh/ty](https://github.com/astral-sh/ty/pull/2469) - [openclaw/openclaw](https://github.com/openclaw/openclaw/pull/1720) - [home-assistant/core](https://github.com/home-assistant/core/pull/160427) - [python-telegram-bot/python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot/pull/5142) - [DetachHead/basedpyright](https://github.com/DetachHead/basedpyright/pull/1413) - [OpenLineage/OpenLineage](https://github.com/OpenLineage/OpenLineage/pull/3965) - [authlib/authlib](https://github.com/authlib/authlib/pull/804) - [django/djangoproject.com](https://github.com/django/djangoproject.com/pull/2252) - [Future-House/paper-qa](https://github.com/Future-House/paper-qa/pull/1098) - [requests-cache/requests-cache](https://github.com/requests-cache/requests-cache/pull/1116) - [Goldziher/kreuzberg](https://github.com/Goldziher/kreuzberg/pull/142) - [python-attrs/attrs](https://github.com/python-attrs/attrs/commit/c95b177682e76a63478d29d040f9cb36a8d31915) - [jlowin/fastmcp](https://github.com/jlowin/fastmcp/pull/2309) - [apache/iceberg-python](https://github.com/apache/iceberg-python/pull/2533) - [apache/iggy](https://github.com/apache/iggy/pull/2383) - [apache/lucene](https://github.com/apache/lucene/pull/15629) - [jcrist/msgspec](https://github.com/jcrist/msgspec/pull/918) - [python-humanize/humanize](https://github.com/python-humanize/humanize/pull/276) - [MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli/pull/535) - [simple-icons/simple-icons](https://github.com/simple-icons/simple-icons/pull/14245) - [ast-grep/ast-grep](https://github.com/ast-grep/ast-grep.github.io/commit/e30818144b2967a7f9172c8cf2f4596bba219bf5) - [commitizen-tools/commitizen](https://github.com/commitizen-tools/commitizen) - [cocoindex-io/cocoindex](https://github.com/cocoindex-io/cocoindex/pull/1564) - [cachix/devenv](https://github.com/cachix/devenv/pull/2304) - [copper-project/copper-rs](https://github.com/copper-project/copper-rs/pull/783) - [bramstroker/homeassistant-powercalc](https://github.com/bramstroker/homeassistant-powercalc/pull/3978) ## Acknowledgements This project is heavily inspired by the original [pre-commit](https://pre-commit.com/) tool, and it wouldn't be possible without the hard work of the maintainers and contributors of that project. And a special thanks to the [Astral](https://github.com/astral-sh) team for their remarkable projects, particularly [uv](https://github.com/astral-sh/uv), from which I've learned a lot on how to write efficient and idiomatic Rust code. ================================================ FILE: clippy.toml ================================================ disallowed-methods = ["std::env::var", "std::env::var_os"] ================================================ FILE: crates/prek/Cargo.toml ================================================ [package] name = "prek" authors = ["j178 "] description = "Better `pre-commit`, re-engineered in Rust" readme = "../../README.md" version = { workspace = true } edition = { workspace = true } rust-version = { workspace = true } repository = { workspace = true } homepage = { workspace = true } license = { workspace = true } [features] default = ["docker"] # Adds self-update functionality. This feature is only enabled for prek built binarys # and should be left unselected when building prek for package managers. self-update = ["dep:axoupdater"] # Enable the profiler for benchmarking profiler = ["dep:pprof", "pprof/flamegraph"] # Enable docker related tests in integration tests docker = [] # Enable generation of JSON schema schemars = ["dep:schemars", "prek-identify/schemars"] [dependencies] prek-consts = { workspace = true } prek-identify = { workspace = true, features = ["serde"] } aho-corasick = { workspace = true } anstream = { workspace = true } anstyle-query = { workspace = true } anyhow = { workspace = true } async-compression = { workspace = true } async_zip = { workspace = true } axoupdater = { workspace = true, optional = true } bstr = { workspace = true } cargo_metadata = { workspace = true } clap = { workspace = true } clap_complete = { workspace = true } ctrlc = { workspace = true } dunce = { workspace = true } etcetera = { workspace = true } fancy-regex = { workspace = true } fs-err = { workspace = true } futures = { workspace = true } hex = { workspace = true } http = { workspace = true } ignore = { workspace = true } indicatif = { workspace = true } indoc = { workspace = true } itertools = { workspace = true } json5 = { workspace = true } lazy-regex = { workspace = true } levenshtein = { workspace = true } globset = { workspace = true } # Enable static linking for liblzma # This is required for the `xz` feature in `async-compression` liblzma = { workspace = true } mea = { workspace = true } memchr = { workspace = true } owo-colors = { workspace = true } path-clean = { workspace = true } quick-xml = { workspace = true } rand = { workspace = true } rayon = { workspace = true } reqwest = { workspace = true } rustc-hash = { workspace = true } same-file = { workspace = true } schemars = { workspace = true, optional = true } semver = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_stacker = { workspace = true } serde-saphyr = { workspace = true } shlex = { workspace = true } strum = { workspace = true } target-lexicon = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tokio-tar = { workspace = true } tokio-util = { workspace = true } toml = { workspace = true } toml_edit = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } unicode-width = { workspace = true } walkdir = { workspace = true } webpki-root-certs = { workspace = true } which = { workspace = true } [target.'cfg(unix)'.dependencies] prek-pty = { workspace = true } libc = { workspace = true } pprof = { workspace = true, optional = true } rustix = { workspace = true } [build-dependencies] fs-err = { workspace = true } [dev-dependencies] assert_cmd = { workspace = true } assert_fs = { workspace = true } etcetera = { workspace = true } insta = { workspace = true } insta-cmd = { workspace = true } markdown = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } regex = { workspace = true } tempfile = { workspace = true } textwrap = { workspace = true } [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/{ name }-{ target }{ archive-suffix }" pkg-fmt = "tgz" [package.metadata.binstall.overrides.x86_64-pc-windows-msvc] pkg-fmt = "zip" pkg-url = "{ repo }/releases/download/v{ version }/{ name }-{ target }.zip" [package.metadata.binstall.overrides.aarch64-pc-windows-msvc] pkg-fmt = "zip" pkg-url = "{ repo }/releases/download/v{ version }/{ name }-{ target }.zip" [package.metadata.cargo-shear] ignored = ["liblzma"] [lints] workspace = true ================================================ FILE: crates/prek/build.rs ================================================ /* MIT License Copyright (c) 2023 Astral Software Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ use std::path::{Path, PathBuf}; use std::process::Command; use fs_err as fs; fn main() { // The workspace root directory is not available without walking up the tree // https://github.com/rust-lang/cargo/issues/3946 #[allow(clippy::disallowed_methods)] let workspace_root = Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()) .parent() .expect("CARGO_MANIFEST_DIR should be nested in workspace") .parent() .expect("CARGO_MANIFEST_DIR should be doubly nested in workspace") .to_path_buf(); commit_info(&workspace_root); } fn commit_info(workspace_root: &Path) { // If not in a git repository, do not attempt to retrieve commit information let git_dir = workspace_root.join(".git"); if !git_dir.exists() { return; } if let Some(git_head_path) = git_head(&git_dir) { println!("cargo:rerun-if-changed={}", git_head_path.display()); let git_head_contents = fs::read_to_string(git_head_path); if let Ok(git_head_contents) = git_head_contents { // The contents are either a commit or a reference in the following formats // - "" when the head is detached // - "ref " when working on a branch // If a commit, checking if the HEAD file has changed is sufficient // If a ref, we need to add the head file for that ref to rebuild on commit let mut git_ref_parts = git_head_contents.split_whitespace(); git_ref_parts.next(); if let Some(git_ref) = git_ref_parts.next() { let git_ref_path = git_dir.join(git_ref); println!("cargo:rerun-if-changed={}", git_ref_path.display()); } } } let output = match Command::new("git") .arg("log") .arg("-1") .arg("--date=short") .arg("--abbrev=9") // describe:tags => Instead of only considering annotated tags, consider lightweight tags as well. .arg("--format='%H %h %cd %(describe:tags)'") .output() { Ok(output) if output.status.success() => output, _ => return, }; let stdout = String::from_utf8(output.stdout).unwrap(); let mut parts = stdout.split_whitespace(); let mut next = || parts.next().unwrap(); println!("cargo:rustc-env=PREK_COMMIT_HASH={}", next()); println!("cargo:rustc-env=PREK_COMMIT_SHORT_HASH={}", next()); println!("cargo:rustc-env=PREK_COMMIT_DATE={}", next()); // Describe can fail for some commits // https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-emdescribeoptionsem if let Some(describe) = parts.next() { // e.g. 'v0.2.0-alpha.5-1-g4e9faf2' let mut describe_parts = describe.rsplitn(3, '-'); describe_parts.next(); println!( "cargo:rustc-env=PREK_LAST_TAG_DISTANCE={}", describe_parts.next().unwrap_or("0") ); if let Some(last_tag) = describe_parts.next() { println!("cargo:rustc-env=PREK_LAST_TAG={last_tag}"); } } } fn git_head(git_dir: &Path) -> Option { // The typical case is a standard git repository. let git_head_path = git_dir.join("HEAD"); if git_head_path.exists() { return Some(git_head_path); } if !git_dir.is_file() { return None; } // If `.git/HEAD` doesn't exist and `.git` is actually a file, // then let's try to attempt to read it as a worktree. If it's // a worktree, then its contents will look like this, e.g.: // // gitdir: /home/andrew/astral/uv/main/.git/worktrees/pr2 // // And the HEAD file we want to watch will be at: // // /home/andrew/astral/uv/main/.git/worktrees/pr2/HEAD let contents = fs::read_to_string(git_dir).ok()?; let (label, worktree_path) = contents.split_once(':')?; if label != "gitdir" { return None; } let worktree_path = worktree_path.trim(); Some(PathBuf::from(worktree_path)) } ================================================ FILE: crates/prek/src/archive.rs ================================================ // MIT License // // Copyright (c) 2023 Astral Software Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. use std::ffi::OsString; use std::fmt::{Display, Formatter}; use std::path::{Component, Path, PathBuf}; use async_compression::tokio::bufread::{GzipDecoder, XzDecoder}; use async_zip::base::read::stream::ZipFileReader; use rustc_hash::FxHashSet; use tokio::io::{AsyncRead, BufReader}; use tokio_tar::ArchiveBuilder; use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; use tracing::warn; #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] AsyncZip(#[from] async_zip::error::ZipError), #[error(transparent)] Io(#[from] std::io::Error), #[error("Unsupported archive type: {0}")] UnsupportedArchive(PathBuf), #[error( "The top-level of the archive must only contain a list directory, but it contains: {0:?}" )] NonSingularArchive(Vec), #[error("The top-level of the archive must only contain a list directory, but it's empty")] EmptyArchive, } const DEFAULT_BUF_SIZE: usize = 128 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ArchiveExtension { Zip, TarGz, TarBz2, TarXz, TarZst, TarLzma, Tar, } impl ArchiveExtension { /// Extract the [`ArchiveExtension`] from a path. pub fn from_path(path: impl AsRef) -> Result { /// Returns true if the path is a tar file (e.g., `.tar.gz`). fn is_tar(path: &Path) -> bool { path.file_stem().is_some_and(|stem| { Path::new(stem) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("tar")) }) } let Some(extension) = path.as_ref().extension().and_then(|ext| ext.to_str()) else { return Err(Error::UnsupportedArchive(path.as_ref().to_path_buf())); }; match extension { "zip" => Ok(Self::Zip), "whl" => Ok(Self::Zip), // Wheel files are zip files "tar" => Ok(Self::Tar), "tgz" => Ok(Self::TarGz), "tbz" => Ok(Self::TarBz2), "txz" => Ok(Self::TarXz), "tlz" => Ok(Self::TarLzma), "gz" if is_tar(path.as_ref()) => Ok(Self::TarGz), "bz2" if is_tar(path.as_ref()) => Ok(Self::TarBz2), "xz" if is_tar(path.as_ref()) => Ok(Self::TarXz), "lz" | "lzma" if is_tar(path.as_ref()) => Ok(Self::TarLzma), "zst" if is_tar(path.as_ref()) => Ok(Self::TarZst), _ => Err(Error::UnsupportedArchive(path.as_ref().to_path_buf())), } } } impl Display for ArchiveExtension { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::Zip => f.write_str("zip"), Self::TarGz => f.write_str("tar.gz"), Self::TarBz2 => f.write_str("tar.bz2"), Self::TarXz => f.write_str("tar.xz"), Self::TarZst => f.write_str("tar.zst"), Self::TarLzma => f.write_str("tar.lzma"), Self::Tar => f.write_str("tar"), } } } /// Extract the top-level directory from an unpacked archive. /// /// This function returns the path to that top-level directory. pub fn strip_component(source: impl AsRef) -> Result { let top_level = fs_err::read_dir(source.as_ref())?.collect::>>()?; match top_level.as_slice() { [root] => Ok(root.path()), [] => Err(Error::EmptyArchive), _ => Err(Error::NonSingularArchive( top_level .into_iter() .map(|entry| entry.file_name()) .collect(), )), } } /// Unpack a `.zip` archive into the target directory, without requiring `Seek`. /// /// This is useful for unzipping files as they're being downloaded. If the archive /// is already fully on disk, consider using `unzip_archive`, which can use multiple /// threads to work faster in that case. pub async fn unzip(reader: R, target: impl AsRef) -> Result<(), Error> { /// Ensure the file path is safe to use as a [`Path`]. /// /// See: pub(crate) fn enclosed_name(file_name: &str) -> Option { if file_name.contains('\0') { return None; } let path = PathBuf::from(file_name); let mut depth = 0usize; for component in path.components() { match component { Component::Prefix(_) | Component::RootDir => return None, Component::ParentDir => depth = depth.checked_sub(1)?, Component::Normal(_) => depth += 1, Component::CurDir => (), } } Some(path) } let target = target.as_ref(); let mut reader = futures::io::BufReader::with_capacity(DEFAULT_BUF_SIZE, reader.compat()); let mut zip = ZipFileReader::new(&mut reader); let mut directories = FxHashSet::default(); let mut offset = 0; while let Some(mut entry) = zip.next_with_entry().await? { // Construct the (expected) path to the file on-disk. let path = entry.reader().entry().filename().as_str()?; // Sanitize the file name to prevent directory traversal attacks. let Some(path) = enclosed_name(path) else { warn!("Skipping unsafe file name: {path}"); // Close current file prior to proceeding, as per: // https://docs.rs/async_zip/0.0.16/async_zip/base/read/stream/ (.., zip) = entry.skip().await?; // Store the current offset. offset = zip.offset(); continue; }; let path = target.join(path); let is_dir = entry.reader().entry().dir()?; // Either create the directory or write the file to disk. if is_dir { if directories.insert(path.clone()) { fs_err::tokio::create_dir_all(path).await?; } } else { if let Some(parent) = path.parent() { if directories.insert(parent.to_path_buf()) { fs_err::tokio::create_dir_all(parent).await?; } } // We don't know the file permissions here, because we haven't seen the central directory yet. let file = fs_err::tokio::File::create(&path).await?; let size = entry.reader().entry().uncompressed_size(); let mut writer = if let Ok(size) = usize::try_from(size) { tokio::io::BufWriter::with_capacity(std::cmp::min(size, 1024 * 1024), file) } else { tokio::io::BufWriter::new(file) }; let mut reader = entry.reader_mut().compat(); tokio::io::copy(&mut reader, &mut writer).await?; } // Close current file prior to proceeding, as per: // https://docs.rs/async_zip/0.0.16/async_zip/base/read/stream/ (.., zip) = entry.skip().await?; // Store the current offset. offset = zip.offset(); } // On Unix, we need to set file permissions, which are stored in the central directory, at the // end of the archive. The `ZipFileReader` reads until it sees a central directory signature, // which indicates the first entry in the central directory. So we continue reading from there. #[cfg(unix)] { use async_zip::base::read::cd::CentralDirectoryReader; use async_zip::base::read::cd::Entry; use std::fs::Permissions; use std::os::unix::fs::PermissionsExt; let mut directory = CentralDirectoryReader::new(&mut reader, offset); while let Entry::CentralDirectoryEntry(entry) = directory.next().await? { if entry.dir()? { continue; } let Some(mode) = entry.unix_permissions() else { continue; }; // Construct the (expected) path to the file on-disk. let path = entry.filename().as_str()?; let Some(path) = enclosed_name(path) else { continue; }; let path = target.join(path); fs_err::tokio::set_permissions(&path, Permissions::from_mode(mode)).await?; } } #[cfg(not(unix))] { let _ = offset; } Ok(()) } /// Unpack a `.tar.gz` archive into the target directory, without requiring `Seek`. /// /// This is useful for unpacking files as they're being downloaded. pub async fn untar_gz( reader: R, target: impl AsRef, ) -> Result<(), Error> { let reader = BufReader::with_capacity(DEFAULT_BUF_SIZE, reader); let reader = GzipDecoder::new(reader); let mut archive = ArchiveBuilder::new(reader) .set_preserve_mtime(true) .set_preserve_permissions(true) .set_allow_external_symlinks(false) .build(); archive.unpack(target.as_ref()).await?; Ok(()) } /// Unpack a `.tar.xz` archive into the target directory, without requiring `Seek`. /// /// This is useful for unpacking files as they're being downloaded. pub async fn untar_xz( reader: R, target: impl AsRef, ) -> Result<(), Error> { let reader = BufReader::with_capacity(DEFAULT_BUF_SIZE, reader); let reader = XzDecoder::new(reader); let mut archive = ArchiveBuilder::new(reader) .set_preserve_mtime(true) .set_preserve_permissions(true) .set_allow_external_symlinks(false) .build(); archive.unpack(target.as_ref()).await?; Ok(()) } /// Unpack a `.tar` archive into the target directory, without requiring `Seek`. /// /// This is useful for unpacking files as they're being downloaded. pub async fn untar(reader: R, target: impl AsRef) -> Result<(), Error> { let reader = BufReader::with_capacity(DEFAULT_BUF_SIZE, reader); let mut archive = ArchiveBuilder::new(reader) .set_preserve_mtime(true) .set_preserve_permissions(true) .set_allow_external_symlinks(false) .build(); archive.unpack(target.as_ref()).await?; Ok(()) } /// Unpack a `.zip`, `.tar.gz`, `.tar.bz2`, `.tar.zst`, or `.tar.xz` archive into the target directory, /// without requiring `Seek`. pub async fn unpack( reader: R, ext: ArchiveExtension, target: impl AsRef, ) -> Result<(), Error> { match ext { ArchiveExtension::Zip => unzip(reader, target).await, ArchiveExtension::Tar => untar(reader, target).await, ArchiveExtension::TarGz => untar_gz(reader, target).await, ArchiveExtension::TarXz => untar_xz(reader, target).await, _ => Err(Error::UnsupportedArchive(target.as_ref().to_path_buf())), } } ================================================ FILE: crates/prek/src/cleanup.rs ================================================ use std::sync::Mutex; static CLEANUP_HOOKS: Mutex>> = Mutex::new(Vec::new()); /// Run all cleanup functions. pub fn cleanup() { let mut cleanup = CLEANUP_HOOKS.lock().unwrap(); for f in cleanup.drain(..) { f(); } } /// Add a cleanup function to be run when the program is interrupted. pub fn add_cleanup(f: F) { let mut cleanup = CLEANUP_HOOKS.lock().unwrap(); cleanup.push(Box::new(f)); } ================================================ FILE: crates/prek/src/cli/auto_update.rs ================================================ use std::fmt::Write; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use futures::StreamExt; use itertools::Itertools; use lazy_regex::regex; use owo_colors::OwoColorize; use prek_consts::PRE_COMMIT_HOOKS_YAML; use rustc_hash::FxHashMap; use rustc_hash::FxHashSet; use semver::Version; use toml_edit::DocumentMut; use tracing::{debug, trace}; use crate::cli::ExitStatus; use crate::cli::reporter::AutoUpdateReporter; use crate::cli::run::Selectors; use crate::config::{RemoteRepo, Repo}; use crate::fs::{CWD, Simplified}; use crate::printer::Printer; use crate::run::CONCURRENCY; use crate::store::Store; use crate::workspace::{Project, Workspace}; use crate::yaml::serialize_yaml_scalar; use crate::{config, git}; #[derive(Default, Clone)] struct Revision { rev: String, frozen: Option, } pub(crate) async fn auto_update( store: &Store, config: Option, filter_repos: Vec, bleeding_edge: bool, freeze: bool, jobs: usize, dry_run: bool, cooldown_days: u8, printer: Printer, ) -> Result { struct RepoInfo<'a> { project: &'a Project, remote_size: usize, remote_index: usize, } let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?; // TODO: support selectors? let selectors = Selectors::default(); let workspace = Workspace::discover(store, workspace_root, config, Some(&selectors), true)?; // Collect repos and deduplicate by RemoteRepo #[allow(clippy::mutable_key_type)] let mut repo_updates: FxHashMap<&RemoteRepo, Vec> = FxHashMap::default(); for project in workspace.projects() { let remote_size = project .config() .repos .iter() .filter(|r| matches!(r, Repo::Remote(_))) .count(); let mut remote_index = 0; for repo in &project.config().repos { if let Repo::Remote(remote_repo) = repo { let updates = repo_updates.entry(remote_repo).or_default(); updates.push(RepoInfo { project, remote_size, remote_index, }); remote_index += 1; } } } let jobs = if jobs == 0 { *CONCURRENCY } else { jobs }; let jobs = jobs .min(if filter_repos.is_empty() { repo_updates.len() } else { filter_repos.len() }) .max(1); let reporter = AutoUpdateReporter::new(printer); let mut tasks = futures::stream::iter(repo_updates.iter().filter(|(remote_repo, _)| { // Filter by user specified repositories if filter_repos.is_empty() { true } else { filter_repos.iter().any(|r| r == remote_repo.repo.as_str()) } })) .map(async |(remote_repo, _)| { let progress = reporter.on_update_start(&remote_repo.to_string()); let result = update_repo(remote_repo, bleeding_edge, freeze, cooldown_days).await; reporter.on_update_complete(progress); (*remote_repo, result) }) .buffer_unordered(jobs) .collect::>() .await; // Sort tasks by repository URL for consistent output order tasks.sort_by(|(a, _), (b, _)| a.repo.cmp(&b.repo)); reporter.on_complete(); // Group results by project config file #[allow(clippy::mutable_key_type)] let mut project_updates: FxHashMap<&Project, Vec>> = FxHashMap::default(); let mut failure = false; for (remote_repo, result) in tasks { match result { Ok(new_rev) => { let is_changed = remote_repo.rev != new_rev.rev; if is_changed { writeln!( printer.stdout(), "[{}] updating {} -> {}", remote_repo.repo.as_str().cyan(), remote_repo.rev, new_rev.rev )?; } else { writeln!( printer.stdout(), "[{}] already up to date", remote_repo.repo.as_str().yellow() )?; } // Apply this update to all projects that reference this repo if is_changed && let Some(projects) = repo_updates.get(&remote_repo) { for RepoInfo { project, remote_size, remote_index, } in projects { let revisions = project_updates .entry(project) .or_insert_with(|| vec![None; *remote_size]); revisions[*remote_index] = Some(new_rev.clone()); } } } Err(e) => { failure = true; writeln!( printer.stderr(), "[{}] update failed: {e}", remote_repo.repo.as_str().red() )?; } } } if !dry_run { // Update each project config file for (project, revisions) in project_updates { let has_changes = revisions.iter().any(Option::is_some); if has_changes { write_new_config(project.config_file(), &revisions).await?; } } } if failure { return Ok(ExitStatus::Failure); } Ok(ExitStatus::Success) } async fn update_repo( repo: &RemoteRepo, bleeding_edge: bool, freeze: bool, cooldown_days: u8, ) -> Result { let tmp_dir = tempfile::tempdir()?; let repo_path = tmp_dir.path(); trace!( "Cloning repository `{}` to `{}`", repo.repo, repo_path.display() ); setup_and_fetch_repo(repo.repo.as_str(), repo_path).await?; let rev = resolve_revision(repo_path, &repo.rev, bleeding_edge, cooldown_days).await?; let Some(rev) = rev else { debug!("No suitable revision found for repo `{}`", repo.repo); return Ok(Revision { rev: repo.rev.clone(), frozen: None, }); }; let (rev, frozen) = if freeze && let Some(exact) = freeze_revision(repo_path, &rev).await? { debug!("Freezing revision `{rev}` to `{exact}`"); (exact, Some(rev)) } else { (rev, None) }; checkout_and_validate_manifest(repo_path, &rev, repo).await?; Ok(Revision { rev, frozen }) } async fn setup_and_fetch_repo(repo_url: &str, repo_path: &Path) -> Result<()> { git::init_repo(repo_url, repo_path).await?; git::git_cmd("git config")? .arg("config") .arg("extensions.partialClone") .arg("true") .current_dir(repo_path) .remove_git_envs() .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .await?; git::git_cmd("git fetch")? .arg("fetch") .arg("origin") .arg("HEAD") .arg("--quiet") .arg("--filter=blob:none") .arg("--tags") .current_dir(repo_path) .remove_git_envs() .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .await?; Ok(()) } async fn resolve_bleeding_edge(repo_path: &Path) -> Result> { let output = git::git_cmd("git describe")? .arg("describe") .arg("FETCH_HEAD") // Instead of using only the annotated tags, use any tag found in refs/tags namespace. // This option enables matching a lightweight (non-annotated) tag. .arg("--tags") // Only output exact matches (a tag directly references the supplied commit). // This is a synonym for --candidates=0. .arg("--exact-match") .check(false) .current_dir(repo_path) .remove_git_envs() .output() .await?; let rev = if output.status.success() { String::from_utf8_lossy(&output.stdout).trim().to_string() } else { debug!("No matching tag for `FETCH_HEAD`, using rev-parse instead"); // "fatal: no tag exactly matches xxx" let output = git::git_cmd("git rev-parse")? .arg("rev-parse") .arg("FETCH_HEAD") .check(true) .current_dir(repo_path) .remove_git_envs() .output() .await?; String::from_utf8_lossy(&output.stdout).trim().to_string() }; debug!("Resolved `FETCH_HEAD` to `{rev}`"); Ok(Some(rev)) } /// Returns all tags and their Unix timestamps (newest first). /// /// Within groups of tags sharing the same timestamp, semver-parseable tags /// are sorted highest version first; non-semver tags sort after them. async fn get_tag_timestamps(repo: &Path) -> Result> { let output = git::git_cmd("git for-each-ref")? .arg("for-each-ref") .arg("--sort=-creatordate") // `creatordate` is the date the tag was created (annotated tags) or the commit date (lightweight tags) // `lstrip=2` removes the "refs/tags/" prefix .arg("--format=%(refname:lstrip=2) %(creatordate:unix)") .arg("refs/tags") .check(true) .current_dir(repo) .remove_git_envs() .output() .await?; let mut tags: Vec<(String, u64)> = String::from_utf8_lossy(&output.stdout) .lines() .filter_map(|line| { let mut parts = line.split_whitespace(); let tag = parts.next()?.trim_ascii(); let ts_str = parts.next()?.trim_ascii(); let ts: u64 = ts_str.parse().ok()?; Some((tag.to_string(), ts)) }) .collect(); // Deterministic sort: primary key is timestamp (newest first). // Within equal timestamps, prefer higher semver versions; non-semver tags // sort after semver ones. As a final tie-breaker, compare the tag refname // so ordering is stable across platforms/filesystems. tags.sort_by(|(tag_a, ts_a), (tag_b, ts_b)| { ts_b.cmp(ts_a).then_with(|| { let ver_a = Version::parse(tag_a.strip_prefix('v').unwrap_or(tag_a)); let ver_b = Version::parse(tag_b.strip_prefix('v').unwrap_or(tag_b)); match (ver_a, ver_b) { (Ok(a), Ok(b)) => b.cmp(&a).then_with(|| tag_a.cmp(tag_b)), (Ok(_), Err(_)) => std::cmp::Ordering::Less, (Err(_), Ok(_)) => std::cmp::Ordering::Greater, (Err(_), Err(_)) => tag_a.cmp(tag_b), } }) }); Ok(tags) } async fn resolve_revision( repo_path: &Path, current_rev: &str, bleeding_edge: bool, cooldown_days: u8, ) -> Result> { if bleeding_edge { return resolve_bleeding_edge(repo_path).await; } let tags_with_ts = get_tag_timestamps(repo_path).await?; let cutoff_secs = u64::from(cooldown_days) * 86400; let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); let cutoff = now.saturating_sub(cutoff_secs); // tags_with_ts is sorted newest -> oldest; find the first bucket where ts <= cutoff. let left = match tags_with_ts.binary_search_by(|(_, ts)| ts.cmp(&cutoff).reverse()) { Ok(i) | Err(i) => i, }; let Some((target_tag, target_ts)) = tags_with_ts.get(left) else { trace!("No tags meet cooldown cutoff {cutoff_secs}s"); return Ok(None); }; debug!("Using tag `{target_tag}` cutoff timestamp {target_ts}"); let best = get_best_candidate_tag(repo_path, target_tag, current_rev) .await .unwrap_or_else(|_| target_tag.clone()); debug!("Using best candidate tag `{best}` for revision `{target_tag}`"); Ok(Some(best)) } async fn freeze_revision(repo_path: &Path, rev: &str) -> Result> { let exact = git::git_cmd("git rev-parse")? .arg("rev-parse") .arg(format!("{rev}^{{}}")) .current_dir(repo_path) .remove_git_envs() .output() .await? .stdout; let exact = str::from_utf8(&exact)?.trim(); if rev == exact { Ok(None) } else { Ok(Some(exact.to_string())) } } async fn checkout_and_validate_manifest( repo_path: &Path, rev: &str, repo: &RemoteRepo, ) -> Result<()> { // Workaround for Windows: https://github.com/pre-commit/pre-commit/issues/2865, // https://github.com/j178/prek/issues/614 if cfg!(windows) { git::git_cmd("git show")? .arg("show") .arg(format!("{rev}:{PRE_COMMIT_HOOKS_YAML}")) .current_dir(repo_path) .remove_git_envs() .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .await?; } git::git_cmd("git checkout")? .arg("checkout") .arg("--quiet") .arg(rev) .arg("--") .arg(PRE_COMMIT_HOOKS_YAML) .current_dir(repo_path) .remove_git_envs() .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .await?; let manifest = config::read_manifest(&repo_path.join(PRE_COMMIT_HOOKS_YAML))?; let new_hook_ids = manifest .hooks .into_iter() .map(|h| h.id) .collect::>(); let hooks_missing = repo .hooks .iter() .filter(|h| !new_hook_ids.contains(&h.id)) .map(|h| h.id.clone()) .collect::>(); if !hooks_missing.is_empty() { anyhow::bail!( "Cannot update to rev `{}`, hook{} {} missing: {}", rev, if hooks_missing.len() > 1 { "s" } else { "" }, if hooks_missing.len() > 1 { "are" } else { "is" }, hooks_missing.join(", ") ); } Ok(()) } /// Multiple tags can exist on an SHA. Sometimes a moving tag is attached /// to a version tag. Try to pick the tag that looks like a version and most similar /// to the current revision. async fn get_best_candidate_tag(repo: &Path, rev: &str, current_rev: &str) -> Result { let stdout = git::git_cmd("git tag")? .arg("tag") .arg("--points-at") .arg(format!("{rev}^{{}}")) .check(true) .current_dir(repo) .remove_git_envs() .output() .await? .stdout; String::from_utf8_lossy(&stdout) .lines() .filter(|line| line.contains('.')) .sorted_by_key(|tag| { // Prefer tags that are more similar to the current revision levenshtein::levenshtein(tag, current_rev) }) .next() .map(ToString::to_string) .with_context(|| format!("No tags found for revision {rev}")) } async fn write_new_config(path: &Path, revisions: &[Option]) -> Result<()> { let content = fs_err::tokio::read_to_string(path).await?; let new_content = match path.extension() { Some(ext) if ext.eq_ignore_ascii_case("toml") => { render_updated_toml_config(path, &content, revisions)? } _ => render_updated_yaml_config(path, &content, revisions)?, }; fs_err::tokio::write(path, new_content) .await .with_context(|| { format!( "Failed to write updated config file `{}`", path.user_display() ) })?; Ok(()) } fn render_updated_toml_config( path: &Path, content: &str, revisions: &[Option], ) -> Result { let mut doc = content.parse::()?; let Some(repos) = doc .get_mut("repos") .and_then(|item| item.as_array_of_tables_mut()) else { anyhow::bail!("Missing `[[repos]]` array in `{}`", path.user_display()); }; let mut remote_repos = Vec::new(); for table in repos.iter_mut() { let repo_value = table .get("repo") .and_then(|item| item.as_value()) .and_then(|value| value.as_str()) .unwrap_or_default(); if matches!(repo_value, "local" | "meta" | "builtin") { continue; } if !table.contains_key("rev") { anyhow::bail!( "Found remote repo without `rev` in `{}`", path.user_display() ); } remote_repos.push(table); } if remote_repos.len() != revisions.len() { anyhow::bail!( "Found {} remote repos in `{}` but expected {}, file content may have changed", remote_repos.len(), path.user_display(), revisions.len() ); } for (table, revision) in remote_repos.into_iter().zip_eq(revisions) { let Some(revision) = revision else { continue; }; let Some(value) = table.get_mut("rev").and_then(|item| item.as_value_mut()) else { continue; }; let suffix = value .decor() .suffix() .and_then(|s| s.as_str()) .filter(|s| !s.trim_start().starts_with("# frozen:")) .map(str::to_string); *value = toml_edit::Value::from(revision.rev.clone()); if let Some(frozen) = &revision.frozen { value.decor_mut().set_suffix(format!(" # frozen: {frozen}")); } else if let Some(suffix) = suffix { value.decor_mut().set_suffix(suffix); } } Ok(doc.to_string()) } fn render_updated_yaml_config( path: &Path, content: &str, revisions: &[Option], ) -> Result { let mut lines = content .split_inclusive('\n') .map(ToString::to_string) .collect::>(); let rev_regex = regex!(r#"^(\s+)rev:(\s*)(['"]?)([^\s#]+)(.*)(\r?\n)$"#); let rev_lines = lines .iter() .enumerate() .filter_map(|(line_no, line)| { if rev_regex.is_match(line) { Some(line_no) } else { None } }) .collect::>(); if rev_lines.len() != revisions.len() { anyhow::bail!( "Found {} `rev:` lines in `{}` but expected {}, file content may have changed", rev_lines.len(), path.user_display(), revisions.len() ); } for (line_no, revision) in rev_lines.iter().zip_eq(revisions) { let Some(revision) = revision else { // This repo was not updated, skip continue; }; let caps = rev_regex .captures(&lines[*line_no]) .context("Failed to capture rev line")?; let new_rev = serialize_yaml_scalar(&revision.rev, &caps[3])?; let comment = if let Some(frozen) = &revision.frozen { format!(" # frozen: {frozen}") } else if caps[5].trim_start().starts_with("# frozen:") { String::new() } else { caps[5].to_string() }; lines[*line_no] = format!( "{}rev:{}{}{}{}", &caps[1], &caps[2], new_rev, comment, &caps[6] ); } Ok(lines.join("")) } #[cfg(test)] mod tests { use super::*; use crate::process::Cmd; use std::time::{SystemTime, UNIX_EPOCH}; async fn setup_test_repo() -> tempfile::TempDir { let tmp = tempfile::tempdir().unwrap(); let repo = tmp.path(); // Initialize git repo git::git_cmd("git init") .unwrap() .arg("init") .current_dir(repo) .remove_git_envs() .output() .await .unwrap(); // Configure git user git::git_cmd("git config") .unwrap() .args(["config", "user.email", "test@test.com"]) .current_dir(repo) .remove_git_envs() .output() .await .unwrap(); git::git_cmd("git config") .unwrap() .args(["config", "user.name", "Test"]) .current_dir(repo) .remove_git_envs() .output() .await .unwrap(); // First commit (required before creating a branch) git::git_cmd("git commit") .unwrap() .args([ "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial", ]) .current_dir(repo) .remove_git_envs() .output() .await .unwrap(); // Create a trunk branch (avoid dangling commits) git::git_cmd("git checkout") .unwrap() .args(["branch", "-M", "trunk"]) .current_dir(repo) .remove_git_envs() .output() .await .unwrap(); tmp } fn git_cmd(dir: impl AsRef, summary: &str) -> Cmd { let mut cmd = git::git_cmd(summary).unwrap(); cmd.current_dir(dir) .args(["-c", "commit.gpgsign=false"]) .args(["-c", "tag.gpgsign=false"]); cmd } async fn create_commit(repo: &Path, message: &str) { git_cmd(repo, "git commit") .args(["commit", "--allow-empty", "-m", message]) .remove_git_envs() .output() .await .unwrap(); } async fn create_backdated_commit(repo: &Path, message: &str, days_ago: u64) { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() - (days_ago * 86400); let date_str = format!("{timestamp} +0000"); git_cmd(repo, "git commit") .args(["commit", "--allow-empty", "-m", message]) .env("GIT_AUTHOR_DATE", &date_str) .env("GIT_COMMITTER_DATE", &date_str) .remove_git_envs() .output() .await .unwrap(); } async fn create_lightweight_tag(repo: &Path, tag: &str) { git_cmd(repo, "git tag") .arg("tag") .arg(tag) .remove_git_envs() .output() .await .unwrap(); } async fn create_annotated_tag(repo: &Path, tag: &str, days_ago: u64) { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() - (days_ago * 86400); let date_str = format!("{timestamp} +0000"); git_cmd(repo, "git tag") .arg("tag") .arg(tag) .arg("-m") .arg(tag) .env("GIT_AUTHOR_DATE", &date_str) .env("GIT_COMMITTER_DATE", &date_str) .remove_git_envs() .output() .await .unwrap(); } fn get_backdated_timestamp(days_ago: u64) -> u64 { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); now - (days_ago * 86400) } #[tokio::test] async fn test_get_tag_timestamps() { let tmp = setup_test_repo().await; let repo = tmp.path(); create_backdated_commit(repo, "old", 5).await; create_lightweight_tag(repo, "v0.1.0").await; create_backdated_commit(repo, "new", 2).await; create_lightweight_tag(repo, "v0.2.0").await; create_annotated_tag(repo, "alias-v0.2.0", 0).await; let timestamps = get_tag_timestamps(repo).await.unwrap(); assert_eq!(timestamps.len(), 3); assert_eq!(timestamps[0].0, "alias-v0.2.0"); assert_eq!(timestamps[1].0, "v0.2.0"); assert_eq!(timestamps[2].0, "v0.1.0"); } #[tokio::test] async fn test_resolve_bleeding_edge_prefers_exact_tag() { let tmp = setup_test_repo().await; let repo = tmp.path(); create_commit(repo, "tagged").await; create_lightweight_tag(repo, "v1.2.3").await; git::git_cmd("git fetch") .unwrap() .args(["fetch", ".", "HEAD"]) .current_dir(repo) .remove_git_envs() .output() .await .unwrap(); let rev = resolve_bleeding_edge(repo).await.unwrap(); assert_eq!(rev, Some("v1.2.3".to_string())); } #[tokio::test] async fn test_resolve_bleeding_edge_falls_back_to_rev_parse() { let tmp = setup_test_repo().await; let repo = tmp.path(); create_commit(repo, "untagged").await; git::git_cmd("git fetch") .unwrap() .args(["fetch", ".", "HEAD"]) .current_dir(repo) .remove_git_envs() .output() .await .unwrap(); let rev = resolve_bleeding_edge(repo).await.unwrap(); let head = git::git_cmd("git rev-parse") .unwrap() .args(["rev-parse", "HEAD"]) .current_dir(repo) .remove_git_envs() .output() .await .unwrap() .stdout; let head = String::from_utf8_lossy(&head).trim().to_string(); assert_eq!(rev, Some(head)); } #[tokio::test] async fn test_resolve_revision_uses_cooldown_bucket() { let tmp = setup_test_repo().await; let repo = tmp.path(); create_backdated_commit(repo, "candidate", 5).await; create_lightweight_tag(repo, "v2.0.0-rc1").await; create_lightweight_tag(repo, "totally-different").await; create_backdated_commit(repo, "latest", 1).await; create_lightweight_tag(repo, "v2.0.0").await; let rev = resolve_revision(repo, "v2.0.0", false, 3).await.unwrap(); assert_eq!(rev, Some("v2.0.0-rc1".to_string())); } #[tokio::test] async fn test_resolve_revision_returns_none_when_all_tags_too_new() { let tmp = setup_test_repo().await; let repo = tmp.path(); create_backdated_commit(repo, "recent-1", 2).await; create_lightweight_tag(repo, "v1.0.0").await; create_backdated_commit(repo, "recent-2", 1).await; create_lightweight_tag(repo, "v1.1.0").await; let rev = resolve_revision(repo, "v1.1.0", false, 5).await.unwrap(); assert_eq!(rev, None); } #[tokio::test] async fn test_resolve_revision_picks_oldest_eligible_bucket() { let tmp = setup_test_repo().await; let repo = tmp.path(); create_backdated_commit(repo, "oldest", 10).await; create_lightweight_tag(repo, "v1.0.0").await; create_backdated_commit(repo, "mid", 4).await; create_lightweight_tag(repo, "v1.1.0").await; create_backdated_commit(repo, "newest", 1).await; create_lightweight_tag(repo, "v1.2.0").await; let rev = resolve_revision(repo, "v1.2.0", false, 5).await.unwrap(); assert_eq!(rev, Some("v1.0.0".to_string())); } #[tokio::test] async fn test_resolve_revision_prefers_version_like_tags() { let tmp = setup_test_repo().await; let repo = tmp.path(); create_backdated_commit(repo, "eligible", 2).await; create_lightweight_tag(repo, "moving-tag").await; create_lightweight_tag(repo, "v1.0.0").await; // Even though the current rev matches the moving tag exactly, the dotted tag // should be preferred. let rev = resolve_revision(repo, "moving-tag", false, 1) .await .unwrap(); assert_eq!(rev, Some("v1.0.0".to_string())); } #[tokio::test] async fn test_resolve_revision_picks_closest_version_string() { let tmp = setup_test_repo().await; let repo = tmp.path(); create_backdated_commit(repo, "eligible", 3).await; create_lightweight_tag(repo, "v1.2.0").await; create_lightweight_tag(repo, "foo-1.2.0").await; create_lightweight_tag(repo, "v2.0.0").await; let rev = resolve_revision(repo, "v1.2.3", false, 1).await.unwrap(); assert_eq!(rev, Some("v1.2.0".to_string())); } #[tokio::test] async fn test_get_tag_timestamps_stable_order_for_equal_timestamps() { let tmp = setup_test_repo().await; let repo = tmp.path(); // Create multiple tags on the same commit (same timestamp) create_backdated_commit(repo, "release", 5).await; create_lightweight_tag(repo, "v1.0.0").await; create_lightweight_tag(repo, "v1.0.3").await; create_lightweight_tag(repo, "v1.0.5").await; create_lightweight_tag(repo, "v1.0.2").await; let timestamps = get_tag_timestamps(repo).await.unwrap(); // All timestamps are equal (tags on same commit). // Within equal timestamps, semver tags should sort highest version first. let tags: Vec<&str> = timestamps.iter().map(|(t, _)| t.as_str()).collect(); assert_eq!(tags, vec!["v1.0.5", "v1.0.3", "v1.0.2", "v1.0.0"]); } #[tokio::test] async fn test_get_tag_timestamps_deterministic_order_for_equal_timestamp_non_semver() { let tmp = setup_test_repo().await; let repo = tmp.path(); // Lightweight tags on the same commit share a timestamp. create_backdated_commit(repo, "release", 5).await; create_lightweight_tag(repo, "beta").await; create_lightweight_tag(repo, "alpha").await; create_lightweight_tag(repo, "gamma").await; let timestamps = get_tag_timestamps(repo).await.unwrap(); let tags: Vec<&str> = timestamps.iter().map(|(t, _)| t.as_str()).collect(); assert_eq!(tags, vec!["alpha", "beta", "gamma"]); } } ================================================ FILE: crates/prek/src/cli/cache_clean.rs ================================================ use std::fmt::Write; use std::fs::FileType; use std::io; use std::path::Path; use anyhow::Result; use owo_colors::OwoColorize; use tracing::error; use crate::cli::ExitStatus; use crate::cli::cache_size::human_readable_bytes; use crate::cli::reporter::CleaningReporter; use crate::printer::Printer; use crate::store::{CacheBucket, Store}; pub(crate) fn cache_clean(store: &Store, printer: Printer) -> Result { if !store.path().exists() { writeln!(printer.stdout(), "{}", "Nothing to clean".bold())?; return Ok(ExitStatus::Success); } let num_paths = walkdir::WalkDir::new(store.path()).into_iter().count(); let reporter = CleaningReporter::new(printer, num_paths); if let Err(e) = fix_permissions(store.cache_path(CacheBucket::Go)) && e.kind() != io::ErrorKind::NotFound { error!("Failed to fix permissions: {}", e); } let removal = remove_dir_all(store.path(), Some(&reporter))?; match (removal.num_files, removal.num_dirs) { (0, 0) => { write!(printer.stderr(), "No cache entries found")?; } (0, 1) => { write!(printer.stderr(), "Removed 1 directory")?; } (0, num_dirs_removed) => { write!(printer.stderr(), "Removed {num_dirs_removed} directories")?; } (1, _) => { write!(printer.stderr(), "Removed 1 file")?; } (num_files_removed, _) => { write!(printer.stderr(), "Removed {num_files_removed} files")?; } } // If any, write a summary of the total byte count removed. if removal.total_bytes > 0 { let (bytes, unit) = human_readable_bytes(removal.total_bytes); let bytes = format!("{bytes:.1}{unit}"); write!(printer.stderr(), " ({})", bytes.cyan().bold())?; } writeln!(printer.stderr())?; Ok(ExitStatus::Success) } #[derive(Debug, Default)] pub struct RemovalStats { pub num_files: u64, pub num_dirs: u64, pub total_bytes: u64, } /// Recursively remove a directory and all its contents. fn remove_dir_all(path: &Path, reporter: Option<&CleaningReporter>) -> io::Result { match fs_err::symlink_metadata(path) { Ok(metadata) => { if !metadata.is_dir() { return Err(io::Error::new( io::ErrorKind::NotADirectory, format!( "Expected a directory at {}, but found a file", path.display() ), )); } } Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(RemovalStats::default()), Err(err) => return Err(err), } let mut stats = RemovalStats::default(); for entry in walkdir::WalkDir::new(path).contents_first(true) { let entry = entry?; if entry.file_type().is_symlink() { stats.num_files += 1; if let Ok(metadata) = entry.metadata() { stats.total_bytes += metadata.len(); } remove_symlink(entry.path(), entry.file_type())?; } else if entry.file_type().is_dir() { stats.num_dirs += 1; fs_err::remove_dir_all(entry.path())?; } else { stats.num_files += 1; if let Ok(metadata) = entry.metadata() { stats.total_bytes += metadata.len(); } fs_err::remove_file(entry.path())?; } reporter.map(CleaningReporter::on_clean); } reporter.map(CleaningReporter::on_complete); Ok(stats) } fn remove_symlink(path: &Path, file_type: FileType) -> io::Result<()> { #[cfg(windows)] { use std::os::windows::fs::FileTypeExt; if file_type.is_symlink_dir() { fs_err::remove_dir(path) } else { fs_err::remove_file(path) } } #[cfg(not(windows))] { let _ = file_type; fs_err::remove_file(path) } } /// Add write permission to GOMODCACHE directory recursively. /// Go sets the permissions to read-only by default. #[cfg(not(windows))] pub fn fix_permissions>(path: P) -> io::Result<()> { use std::fs; use std::os::unix::fs::PermissionsExt; let path = path.as_ref(); let metadata = fs::metadata(path)?; let mut permissions = metadata.permissions(); let current_mode = permissions.mode(); // Add write permissions for owner, group, and others let new_mode = current_mode | 0o222; permissions.set_mode(new_mode); fs::set_permissions(path, permissions)?; // If it's a directory, recursively process its contents if metadata.is_dir() { let entries = fs::read_dir(path)?; for entry in entries { let entry = entry?; fix_permissions(entry.path())?; } } Ok(()) } #[cfg(windows)] #[allow(clippy::unnecessary_wraps)] pub fn fix_permissions>(_path: P) -> io::Result<()> { // On Windows, permissions are handled differently and this function does nothing. Ok(()) } #[cfg(test)] mod tests { use super::remove_dir_all; use assert_fs::fixture::TempDir; #[test] fn rm_rf_counts_and_removes_tree() -> anyhow::Result<()> { let temp = TempDir::new()?; let cache_root = temp.path().join("cache"); fs_err::create_dir_all(cache_root.join("nested/deep"))?; fs_err::write(cache_root.join("root.txt"), b"hello")?; fs_err::write(cache_root.join("nested/data.txt"), b"abc")?; fs_err::write(cache_root.join("nested/deep/end.bin"), b"zz")?; let stats = remove_dir_all(&cache_root, None)?; assert_eq!(stats.num_files, 3); assert_eq!(stats.num_dirs, 3); assert_eq!(stats.total_bytes, 10); assert!(!cache_root.exists()); Ok(()) } #[test] fn rm_rf_empty_directory() -> anyhow::Result<()> { let temp = TempDir::new()?; let cache_root = temp.path().join("cache"); fs_err::create_dir_all(&cache_root)?; let stats = remove_dir_all(&cache_root, None)?; assert_eq!(stats.num_files, 0); assert_eq!(stats.num_dirs, 1); assert_eq!(stats.total_bytes, 0); assert!(!cache_root.exists()); Ok(()) } #[test] fn rm_rf_rejects_non_directory() -> anyhow::Result<()> { let temp = TempDir::new()?; let file_path = temp.path().join("not-a-dir.txt"); fs_err::write(&file_path, b"important data")?; let err = remove_dir_all(&file_path, None).unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::NotADirectory); assert!(file_path.exists(), "file must not be deleted"); Ok(()) } #[test] fn rm_rf_non_exist_directory() -> anyhow::Result<()> { let temp = TempDir::new()?; let dir_path = temp.path().join("non-existent"); let stats = remove_dir_all(&dir_path, None)?; assert_eq!(stats.num_files, 0); assert_eq!(stats.num_dirs, 0); assert_eq!(stats.total_bytes, 0); Ok(()) } #[cfg(unix)] #[test] fn rm_rf_counts_symlink_entries() -> anyhow::Result<()> { use std::os::unix::fs::symlink; let temp = TempDir::new()?; let cache_root = temp.path().join("cache"); fs_err::create_dir_all(&cache_root)?; let link_path = cache_root.join("link-to-missing"); symlink("missing-target", &link_path)?; let expected_len = fs_err::symlink_metadata(&link_path)?.len(); let stats = remove_dir_all(&cache_root, None)?; assert_eq!(stats.num_files, 1); assert_eq!(stats.num_dirs, 1); assert_eq!(stats.total_bytes, expected_len); assert!(!cache_root.exists()); Ok(()) } } ================================================ FILE: crates/prek/src/cli/cache_gc.rs ================================================ use std::fmt::Write; use std::fmt::{Display, Formatter}; use std::ops::AddAssign; use std::path::Path; use anyhow::Result; use owo_colors::OwoColorize; use rustc_hash::FxHashMap; use rustc_hash::FxHashSet; use strum::IntoEnumIterator; use tracing::{debug, trace, warn}; use crate::cli::ExitStatus; use crate::cli::cache_size::{dir_size_bytes, human_readable_bytes}; use crate::config::{self, Error as ConfigError, Repo as ConfigRepo, load_config}; use crate::hook::{HOOK_MARKER, HookEnvKey, HookSpec, InstallInfo, Repo as HookRepo}; use crate::printer::Printer; use crate::store::{CacheBucket, REPO_MARKER, Store, ToolBucket}; #[derive(Debug, Copy, Clone, Eq, PartialEq)] enum RemovalKind { Repos, HookEnvs, Tools, CacheEntries, } impl RemovalKind { fn display(self, count: usize) -> &'static str { if count > 1 { match self { RemovalKind::Repos => "repos", RemovalKind::HookEnvs => "hook envs", RemovalKind::Tools => "tools", RemovalKind::CacheEntries => "cache entries", } } else { match self { RemovalKind::Repos => "repo", RemovalKind::HookEnvs => "hook env", RemovalKind::Tools => "tool", RemovalKind::CacheEntries => "cache entry", } } } } #[derive(Debug, Clone)] struct RemovalItem { label: String, abs_path: String, lines: Vec, } impl RemovalItem { fn new(label: String, abs_path: String) -> Self { Self { label, abs_path, lines: Vec::new(), } } } #[derive(Debug, Clone)] struct Removal { kind: RemovalKind, count: usize, bytes: u64, items: Vec, } impl Removal { fn new(kind: RemovalKind) -> Self { Self { kind, count: 0, bytes: 0, items: Vec::new(), } } } impl Display for Removal { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, "{} {}", self.count.cyan().bold(), self.kind.display(self.count) ) } } impl AddAssign for Removal { fn add_assign(&mut self, rhs: Self) { debug_assert_eq!(self.kind, rhs.kind); self.count += rhs.count; self.bytes = self.bytes.saturating_add(rhs.bytes); self.items.extend(rhs.items); } } #[derive(Debug, Default)] struct RemovalSummary { parts: Vec, count: usize, bytes: u64, } impl RemovalSummary { fn is_empty(&self) -> bool { self.parts.is_empty() } fn joined(&self) -> String { self.parts.join(", ") } fn total_bytes(&self) -> u64 { self.bytes } } impl AddAssign<&Removal> for RemovalSummary { fn add_assign(&mut self, rhs: &Removal) { if rhs.count > 0 { self.parts.push(rhs.to_string()); } self.count += rhs.count; self.bytes = self.bytes.saturating_add(rhs.bytes); } } pub(crate) async fn cache_gc( store: &Store, dry_run: bool, verbose: bool, printer: Printer, ) -> Result { let _lock = store.lock_async().await?; let tracked_configs = store.tracked_configs()?; if tracked_configs.is_empty() { writeln!(printer.stdout(), "{}", "Nothing to clean".bold())?; return Ok(ExitStatus::Success); } let mut kept_configs: FxHashSet<&Path> = FxHashSet::default(); let mut used_repo_keys: FxHashSet = FxHashSet::default(); let mut used_hook_env_dirs: FxHashSet = FxHashSet::default(); let mut used_tools: FxHashSet = FxHashSet::default(); let mut used_tool_versions: FxHashMap> = FxHashMap::default(); let mut used_cache: FxHashSet = FxHashSet::default(); let mut used_env_keys: Vec = Vec::new(); // Always keep Prek's own cache. used_cache.insert(CacheBucket::Prek); let installed = store.installed_hooks().await; for config_path in &tracked_configs { let config = match load_config(config_path) { Ok(config) => { trace!(path = %config_path.display(), "Found tracked config"); config } Err(err) => match err { ConfigError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => { debug!(path = %config_path.display(), "Tracked config does not exist, dropping"); continue; } err => { warn!(path = %config_path.display(), %err, "Failed to parse config, skipping for GC"); kept_configs.insert(config_path); continue; } }, }; kept_configs.insert(config_path); used_env_keys.extend(hook_env_keys_from_config(store, &config)); // Mark repos referenced by this config (if present in store). // We do this via config parsing (no clone), so GC won't keep repos for missing configs. for repo in &config.repos { if let ConfigRepo::Remote(remote) = repo { let key = Store::repo_key(remote); used_repo_keys.insert(key); } } } // Mark tools/caches from hook languages. for key in &used_env_keys { used_tools.extend(key.language.tool_buckets()); used_cache.extend(key.language.cache_buckets()); } // Mark hook environments by matching already-installed env metadata. // While doing this, try to derive the specific tool *version* directories in use from // `InstallInfo.toolchain` (which is persisted in `.prek-hook.json`). for info in &installed { if used_env_keys.iter().any(|k| k.matches_install_info(info)) { if let Some(dir) = info .env_path .file_name() .and_then(|s| s.to_str()) .map(str::to_string) { used_hook_env_dirs.insert(dir); } mark_tool_versions_from_install_info(store, info, &mut used_tool_versions); } } // Update tracking file to drop configs that no longer exist. if !dry_run && kept_configs.len() != tracked_configs.len() { let kept_configs = kept_configs.into_iter().map(Path::to_path_buf).collect(); store.update_tracked_configs(&kept_configs)?; } // Sweep repos/ let removed_repos = sweep_dir_by_name( RemovalKind::Repos, &store.repos_dir(), &used_repo_keys, dry_run, verbose, )?; // Sweep hooks/ let removed_hooks = sweep_dir_by_name( RemovalKind::HookEnvs, &store.hooks_dir(), &used_hook_env_dirs, dry_run, verbose, )?; // Sweep tools/ let tools_root = store.tools_dir(); let used_tool_names: FxHashSet = used_tools.iter().map(ToString::to_string).collect(); let removed_tool_buckets = sweep_dir_by_name( RemovalKind::Tools, &tools_root, &used_tool_names, dry_run, verbose, )?; // Sweep tools// let removed_tool_versions = sweep_tool_versions(store, &used_tool_versions, dry_run, verbose)?; let mut removed_tools = removed_tool_buckets; removed_tools += removed_tool_versions; // Sweep cache/ let cache_root = store.cache_dir(); let used_cache_names: FxHashSet = used_cache.iter().map(ToString::to_string).collect(); let removed_cache = sweep_dir_by_name( RemovalKind::CacheEntries, &cache_root, &used_cache_names, dry_run, verbose, )?; // Seep scratch/, as it is only temporary data. if !dry_run { let _ = fs_err::remove_dir_all(store.scratch_path()); } // NOTE: Do not clear `patches/` here. It can contain user-important temporary patches. // A future enhancement could implement a safer cleanup strategy (e.g. GC patches older // than a configurable age, or only remove patches known to be orphaned). // let _ = fs_err::remove_dir_all(store.patches_dir())?; let mut removed = RemovalSummary::default(); removed += &removed_repos; removed += &removed_hooks; removed += &removed_tools; removed += &removed_cache; let removed_total_bytes = removed.total_bytes(); let (removed_bytes, removed_unit) = human_readable_bytes(removed_total_bytes); let verb = if dry_run { "Would remove" } else { "Removed" }; if removed.is_empty() { writeln!(printer.stdout(), "{}", "Nothing to clean".bold())?; } else { writeln!( printer.stdout(), "{verb} {} ({})", removed.joined(), format!("{removed_bytes:.1}{removed_unit}").cyan().bold(), )?; if verbose { print_removed_details(printer, verb, &removed_repos)?; print_removed_details(printer, verb, &removed_hooks)?; print_removed_details(printer, verb, &removed_tools)?; print_removed_details(printer, verb, &removed_cache)?; } } Ok(ExitStatus::Success) } fn print_removed_details(printer: Printer, verb: &str, removal: &Removal) -> Result<()> { if removal.count == 0 { return Ok(()); } writeln!( printer.stdout(), "\n{}:", format!("{verb} {removal}").bold() )?; let mut items = removal.items.clone(); items.sort_unstable_by(|a, b| a.label.cmp(&b.label)); for item in items { writeln!(printer.stdout(), "{} {}", "-".dimmed(), item.label.bold())?; writeln!( printer.stdout(), " {}: {}", "path".bold().dimmed(), item.abs_path )?; for line in item.lines { writeln!(printer.stdout(), " {line}")?; } } Ok(()) } fn hook_env_keys_from_config(store: &Store, config: &config::Config) -> Vec { let mut keys = Vec::new(); for repo_config in &config.repos { match repo_config { ConfigRepo::Remote(repo_config) => { let repo_path = store.repo_path(repo_config); if !repo_path.is_dir() { continue; } let repo = match HookRepo::remote( repo_config.repo.clone(), repo_config.rev.clone(), repo_path, ) { Ok(repo) => repo, Err(err) => { warn!(repo = %repo_config.repo, %err, "Failed to load repo manifest, skipping"); continue; } }; let remote_dep = repo_config.to_string(); for hook_config in &repo_config.hooks { let Some(manifest_hook) = repo.get_hook(&hook_config.id) else { continue; }; let mut hook_spec = manifest_hook.clone(); hook_spec.apply_remote_hook_overrides(hook_config); match HookEnvKey::from_hook_spec(config, hook_spec, Some(&remote_dep)) { Ok(Some(key)) => keys.push(key), Ok(None) => {} Err(err) => { warn!(hook = %hook_config.id, repo = %remote_dep, %err, "Failed to compute hook env key, skipping"); } } } } ConfigRepo::Local(repo_config) => { for hook in &repo_config.hooks { let hook_spec = HookSpec::from(hook.clone()); match HookEnvKey::from_hook_spec(config, hook_spec, None) { Ok(Some(key)) => keys.push(key), Ok(None) => {} Err(err) => { warn!(hook = %hook.id, %err, "Failed to compute hook env key, skipping"); } } } } _ => {} // Meta repos and builtin repos do not have hook envs. } } keys } fn mark_tool_versions_from_install_info( store: &Store, info: &InstallInfo, used_tool_versions: &mut FxHashMap>, ) { // NOTE: `InstallInfo.toolchain` is typically the executable path (e.g. // tools/go/1.24.0/bin/go). We keep the first path component under the tool bucket. // If we can't recognize it, we do nothing (and GC will keep all versions). for bucket in info.language.tool_buckets() { let bucket_root = store.tools_path(*bucket); if let Some(version) = tool_version_dir_name(&bucket_root, &info.toolchain) { used_tool_versions .entry(*bucket) .or_default() .insert(version); } } } fn tool_version_dir_name(bucket_root: &Path, toolchain: &Path) -> Option { let rel = toolchain.strip_prefix(bucket_root).ok()?; let version = rel.components().next()?.as_os_str().to_str()?; if version.is_empty() { return None; } Some(version.to_string()) } fn sweep_tool_versions( store: &Store, used_tool_versions: &FxHashMap>, dry_run: bool, verbose: bool, ) -> Result { let mut total = Removal::new(RemovalKind::Tools); for bucket in ToolBucket::iter() { let bucket_root = store.tools_path(bucket); let keep_versions = used_tool_versions.get(&bucket); let removed = sweep_tool_bucket_versions(bucket, &bucket_root, keep_versions, dry_run, verbose)?; total += removed; } Ok(total) } fn sweep_tool_bucket_versions( bucket: ToolBucket, bucket_root: &Path, keep_versions: Option<&FxHashSet>, dry_run: bool, collect_names: bool, ) -> Result { let mut removal = Removal::new(RemovalKind::Tools); let entries = match fs_err::read_dir(bucket_root) { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { return Ok(Removal::new(RemovalKind::Tools)); } Err(err) => return Err(err.into()), }; for entry in entries { let entry = match entry { Ok(entry) => entry, Err(err) => { warn!(%err, root = %bucket_root.display(), "Failed to read tool bucket entry"); continue; } }; let path = entry.path(); // Don't remove files (uv, and rustup are files inside tools/). if !path.is_dir() { continue; } let Some(version_name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; // Skip hidden/system dirs. if version_name.starts_with('.') { continue; } if keep_versions.is_some_and(|keep| keep.contains(version_name)) { continue; } let entry_bytes = dir_size_bytes(&path); let item = if collect_names { Some(RemovalItem::new( format!("{bucket}/{version_name}"), path.to_string_lossy().to_string(), )) } else { None }; if dry_run { removal.count += 1; removal.bytes = removal.bytes.saturating_add(entry_bytes); if let Some(item) = item { removal.items.push(item); } continue; } if let Err(err) = fs_err::remove_dir_all(&path) { warn!(%err, path = %path.display(), "Failed to remove unused tool version"); } else { removal.count += 1; removal.bytes = removal.bytes.saturating_add(entry_bytes); if let Some(item) = item { removal.items.push(item); } } } Ok(removal) } fn sweep_dir_by_name( kind: RemovalKind, root: &Path, keep_names: &FxHashSet, dry_run: bool, collect_names: bool, ) -> Result { let mut removal = Removal::new(kind); let entries = match fs_err::read_dir(root) { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Removal::new(kind)), Err(err) => return Err(err.into()), }; for entry in entries { let entry = match entry { Ok(entry) => entry, Err(err) => { warn!(%err, root = %root.display(), "Failed to read store entry"); continue; } }; let path = entry.path(); if !path.is_dir() { continue; } let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; // Skip hidden/system dirs. if name.starts_with('.') { continue; } if keep_names.contains(name) { continue; } let entry_bytes = dir_size_bytes(&path); let item = if collect_names { let repo_marker = (kind == RemovalKind::Repos) .then(|| read_repo_marker(&path)) .flatten(); let hook_marker = (kind == RemovalKind::HookEnvs) .then(|| read_hook_marker(&path)) .flatten(); let mut item = RemovalItem::new(name.to_string(), path.to_string_lossy().to_string()); if let Some(label) = label_for_entry(kind, repo_marker.as_ref(), hook_marker.as_ref()) { item.label = label; } item.lines = detail_lines_for_entry(kind, repo_marker.as_ref(), hook_marker.as_ref()); Some(item) } else { None }; if dry_run { removal.count += 1; removal.bytes = removal.bytes.saturating_add(entry_bytes); if collect_names && let Some(item) = item { removal.items.push(item); } continue; } // Best-effort cleanup. if let Err(err) = fs_err::remove_dir_all(&path) { warn!(%err, path = %path.display(), "Failed to remove unused cache entry"); } else { removal.count += 1; removal.bytes = removal.bytes.saturating_add(entry_bytes); if collect_names { if let Some(item) = item { removal.items.push(item); } } } } Ok(removal) } fn label_for_entry( kind: RemovalKind, repo_marker: Option<&RepoMarker>, hook_marker: Option<&InstallInfo>, ) -> Option { match kind { RemovalKind::Repos => repo_marker.map(|repo| format!("{}@{}", repo.repo, repo.rev)), RemovalKind::HookEnvs => hook_marker.map(|info| { // Keep this short; more info goes in detail lines. format!("{} env", info.language.as_ref()) }), _ => None, } } fn detail_lines_for_entry( kind: RemovalKind, _repo_marker: Option<&RepoMarker>, hook_marker: Option<&InstallInfo>, ) -> Vec { const MAX_VALUE_CHARS: usize = 140; match kind { RemovalKind::Repos => vec![], RemovalKind::HookEnvs => { let Some(info) = hook_marker else { return Vec::new(); }; let mut lines = Vec::new(); lines.push(format!( "{}: {} ({})", "language".dimmed().bold(), info.language.as_ref(), info.language_version )); let (repo_dep, deps) = split_repo_dependency(&info.dependencies); if let Some(repo_dep) = repo_dep { lines.push(format!( "{}: {}", "repo".dimmed().bold(), truncate_end(&repo_dep, MAX_VALUE_CHARS) )); } if !deps.is_empty() { let deps_str = format_dependency_list(&deps, 6, MAX_VALUE_CHARS); lines.push(format!("{}: {deps_str}", "deps".dimmed().bold())); } lines } _ => Vec::new(), } } #[derive(Debug, serde::Deserialize)] struct RepoMarker { repo: String, rev: String, } fn read_repo_marker(root: &Path) -> Option { // NOTE: `Store::clone_repo` serializes `RemoteRepo`, but with some fields skipped during // serialization (e.g. `hooks`). That means deserializing back into `RemoteRepo` can fail. // For GC display, we only need `repo` + `rev`. let content = fs_err::read_to_string(root.join(REPO_MARKER)).ok()?; serde_json::from_str(&content).ok() } fn read_hook_marker(root: &Path) -> Option { let content = fs_err::read_to_string(root.join(HOOK_MARKER)).ok()?; serde_json::from_str(&content).ok() } fn truncate_end(s: &str, max_chars: usize) -> String { if s.chars().count() <= max_chars { return s.to_string(); } let mut out = s .chars() .take(max_chars.saturating_sub(1)) .collect::(); out.push('…'); out } fn split_repo_dependency(deps: &FxHashSet) -> (Option, Vec) { // Best-effort: the remote repo dependency is typically `repo@rev`. // Prefer URL-like values to avoid accidentally treating PEP508 deps as repo identifiers. let mut repo_dep: Option = None; let mut rest = Vec::new(); for dep in deps { if repo_dep.is_none() && dep.contains('@') && (dep.contains("://") || dep.starts_with('/') || dep.starts_with("..") || dep.starts_with('.')) { repo_dep = Some(dep.clone()); } else { rest.push(dep.clone()); } } rest.sort_unstable(); (repo_dep, rest) } fn format_dependency_list(deps: &[String], max_items: usize, max_chars: usize) -> String { if deps.is_empty() { return String::new(); } let shown: Vec<&str> = deps.iter().take(max_items).map(String::as_str).collect(); let extra = deps.len().saturating_sub(shown.len()); let mut rendered = shown.join(", "); if extra > 0 { let _ = write!(&mut rendered, ", … (+{extra} more)"); } truncate_end(&rendered, max_chars) } #[cfg(test)] mod tests { use super::*; #[test] fn truncate_end_returns_input_when_short_enough() { assert_eq!(truncate_end("abc", 3), "abc"); assert_eq!(truncate_end("abc", 10), "abc"); } #[test] fn truncate_end_truncates_and_appends_ellipsis() { assert_eq!(truncate_end("abcd", 3), "ab…"); assert_eq!(truncate_end("abcdef", 5), "abcd…"); } #[test] fn truncate_end_counts_chars_not_bytes() { // 3 unicode scalar values. assert_eq!(truncate_end("ééé", 3), "ééé"); assert_eq!(truncate_end("ééé", 2), "é…"); } #[test] fn split_repo_dependency_prefers_url_like_repo_at_rev() { let mut deps = FxHashSet::default(); deps.insert("requests==2.32.0".to_string()); deps.insert("black==24.1.0".to_string()); deps.insert("https://github.com/pre-commit/pre-commit-hooks@v1.0.0".to_string()); let (repo_dep, rest) = split_repo_dependency(&deps); assert_eq!( repo_dep.as_deref(), Some("https://github.com/pre-commit/pre-commit-hooks@v1.0.0") ); assert_eq!(rest, vec!["black==24.1.0", "requests==2.32.0"]); } #[test] fn split_repo_dependency_returns_none_when_no_repo_like_dep() { let mut deps = FxHashSet::default(); deps.insert("requests==2.32.0".to_string()); deps.insert("black==24.1.0".to_string()); let (repo_dep, rest) = split_repo_dependency(&deps); assert!(repo_dep.is_none()); assert_eq!(rest, vec!["black==24.1.0", "requests==2.32.0"]); } #[test] fn format_dependency_list_includes_more_suffix() { let deps = vec!["a".to_string(), "b".to_string(), "c".to_string()]; assert_eq!(format_dependency_list(&deps, 2, 200), "a, b, … (+1 more)"); } #[test] fn format_dependency_list_truncates_rendered_string() { let deps = vec!["abcdef".to_string()]; assert_eq!(format_dependency_list(&deps, 6, 5), "abcd…"); } } ================================================ FILE: crates/prek/src/cli/cache_size.rs ================================================ use std::fmt::Write; use std::path::Path; use anyhow::Result; use crate::cli::ExitStatus; use crate::printer::Printer; use crate::store::Store; /// Display the total size of the cache. pub(crate) fn cache_size( store: &Store, human_readable: bool, printer: Printer, ) -> Result { // Walk the entire cache root let total_bytes = dir_size_bytes(store.path()); if human_readable { let (bytes, unit) = human_readable_bytes(total_bytes); writeln!(printer.stdout_important(), "{bytes:.1}{unit}")?; } else { writeln!(printer.stdout_important(), "{total_bytes}")?; } Ok(ExitStatus::Success) } /// Formats a number of bytes into a human readable SI-prefixed size (binary units). /// /// Returns a tuple of `(quantity, units)`. #[allow( clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_precision_loss, clippy::cast_sign_loss )] pub(crate) fn human_readable_bytes(bytes: u64) -> (f32, &'static str) { const UNITS: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; let bytes_f32 = bytes as f32; let i = ((bytes_f32.log2() / 10.0) as usize).min(UNITS.len() - 1); (bytes_f32 / 1024_f32.powi(i as i32), UNITS[i]) } pub(crate) fn dir_size_bytes(path: &Path) -> u64 { if !path.exists() { return 0; } walkdir::WalkDir::new(path) .follow_links(false) .into_iter() .filter_map(Result::ok) .filter_map(|entry| match entry.metadata() { Ok(metadata) if metadata.is_file() => Some(metadata.len()), _ => None, }) .sum() } #[cfg(test)] mod tests { use super::{dir_size_bytes, human_readable_bytes}; use assert_fs::fixture::TempDir; #[test] fn human_readable_bytes_handles_zero() { let (value, unit) = human_readable_bytes(0); assert!(value.abs() < f32::EPSILON); assert_eq!(unit, "B"); } #[test] fn dir_stats_missing_directory() -> anyhow::Result<()> { let temp = TempDir::new()?; let missing = temp.path().join("missing"); assert_eq!(dir_size_bytes(&missing), 0); Ok(()) } #[test] fn dir_stats_empty_directory() -> anyhow::Result<()> { let temp = TempDir::new()?; assert_eq!(dir_size_bytes(temp.path()), 0); Ok(()) } #[test] fn dir_stats_nested_files() -> anyhow::Result<()> { let temp = TempDir::new()?; let nested = temp.path().join("nested/deep"); fs_err::create_dir_all(&nested)?; fs_err::write(temp.path().join("root.txt"), b"hello")?; fs_err::write(temp.path().join("nested/data.txt"), b"abc")?; fs_err::write(temp.path().join("nested/deep/end.bin"), b"zz")?; assert_eq!(dir_size_bytes(temp.path()), 10); Ok(()) } } ================================================ FILE: crates/prek/src/cli/completion.rs ================================================ use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsStr; use std::path::Path; use clap::builder::StyledStr; use clap_complete::CompletionCandidate; use crate::config; use crate::fs::CWD; use crate::store::Store; use crate::workspace::{Project, Workspace}; /// Provide completion candidates for `include` and `skip` selectors. pub(crate) fn selector_completer(current: &OsStr) -> Vec { let Some(current_str) = current.to_str() else { return vec![]; }; let Ok(store) = Store::from_settings() else { return vec![]; }; let Ok(workspace) = Workspace::find_root(None, &CWD) .and_then(|root| Workspace::discover(&store, root, None, None, false)) else { return vec![]; }; let mut candidates: Vec = vec![]; // Support optional `path:hook_prefix` form while typing. let (path_part, hook_prefix_opt) = match current_str.split_once(':') { Some((p, rest)) => (p, Some(rest)), None => (current_str, None), }; if path_part.contains('/') { // Provide subdirectory matches relative to cwd for the path prefix let path_obj = Path::new(path_part); let (base_dir, shown_prefix, filter_prefix) = if path_part.ends_with('/') { (CWD.join(path_obj), path_part.to_string(), String::new()) } else { let parent = path_obj.parent().unwrap_or(Path::new("")); let file = path_obj.file_name().and_then(OsStr::to_str).unwrap_or(""); let shown_prefix = if parent.as_os_str().is_empty() { String::new() } else { format!("{}/", parent.display()) }; (CWD.join(parent), shown_prefix, file.to_string()) }; let mut had_children = false; if hook_prefix_opt.is_none() { let mut child_dirs = list_subdirs(&base_dir, &shown_prefix, &filter_prefix, &workspace); let mut child_colons = list_direct_project_colons(&base_dir, &shown_prefix, &filter_prefix, &workspace); had_children = !(child_dirs.is_empty() && child_colons.is_empty()); candidates.append(&mut child_dirs); candidates.append(&mut child_colons); } // If the path refers to a project directory in the workspace and a colon is present, // suggest `path:hook_id`. For pure path input (no colon), don't suggest hooks. let project_dir_abs = if path_part.ends_with('/') { CWD.join(path_part.trim_end_matches('/')) } else { CWD.join(path_obj) }; if hook_prefix_opt.is_some() { if let Some(proj) = workspace .projects() .iter() .find(|p| p.path() == project_dir_abs) { let hook_pairs = all_hooks(proj); let path_prefix_display = if path_part.ends_with('/') { path_part.trim_end_matches('/') } else { path_part }; for (hid, name) in hook_pairs { if let Some(hpref) = hook_prefix_opt { if !hid.starts_with(hpref) && !hid.contains(hpref) { continue; } } let value = format!("{path_prefix_display}:{hid}"); candidates .push(CompletionCandidate::new(value).help(name.map(StyledStr::from))); } } } else if path_part.ends_with('/') { // No colon and trailing slash: if this base dir is a leaf project (no child projects), // suggest the directory itself (with trailing '/'). let is_project = workspace .projects() .iter() .any(|p| p.path() == project_dir_abs); if is_project && !had_children { candidates.push(CompletionCandidate::new(path_part.to_string())); } } return candidates; } // No slash: match subdirectories under cwd and hook ids across workspace candidates.extend(list_subdirs(&CWD, "", current_str, &workspace)); // Also suggest immediate child project roots as `name:` candidates.extend(list_direct_project_colons( &CWD, "", current_str, &workspace, )); // If the input ends with `:`, suggest hooks for that project if let Some(hook_prefix) = hook_prefix_opt { if !path_part.is_empty() { let project_dir_abs = CWD.join(Path::new(path_part)); if let Some(proj) = workspace .projects() .iter() .find(|p| p.path() == project_dir_abs) { for (hid, name) in all_hooks(proj) { if !hook_prefix.is_empty() && !hid.starts_with(hook_prefix) && !hid.contains(hook_prefix) { continue; } let value = format!("{path_part}:{hid}"); candidates .push(CompletionCandidate::new(value).help(name.map(StyledStr::from))); } } } } // Aggregate unique hooks and filter by id let mut uniq: BTreeMap> = BTreeMap::new(); for proj in workspace.projects() { for (id, name) in all_hooks(proj) { if id.contains(current_str) || id.starts_with(current_str) { uniq.entry(id).or_insert(name); } } } candidates.extend( uniq.into_iter() .map(|(id, name)| CompletionCandidate::new(id).help(name.map(StyledStr::from))), ); candidates } fn all_hooks(proj: &Project) -> Vec<(String, Option)> { let mut out = Vec::new(); for repo in &proj.config().repos { match repo { config::Repo::Remote(cfg) => { for h in &cfg.hooks { out.push((h.id.clone(), h.name.as_ref().map(ToString::to_string))); } } config::Repo::Local(cfg) => { for h in &cfg.hooks { out.push((h.id.clone(), Some(h.name.clone()))); } } config::Repo::Meta(cfg) => { for h in &cfg.hooks { out.push((h.id.clone(), Some(h.name.clone()))); } } config::Repo::Builtin(cfg) => { for h in &cfg.hooks { out.push((h.id.clone(), Some(h.name.clone()))); } } } } out } // List subdirectories under base that contain projects (immediate or nested), // derived solely from workspace discovery; always end with '/' fn list_subdirs( base: &Path, shown_prefix: &str, filter_prefix: &str, workspace: &Workspace, ) -> Vec { let mut out = Vec::new(); let mut first_components: BTreeSet = BTreeSet::new(); for proj in workspace.projects() { let p = proj.path(); if let Ok(rel) = p.strip_prefix(base) { if rel.as_os_str().is_empty() { // Project is exactly at base; doesn't yield a child directory continue; } if let Some(first) = rel.components().next() { let name = first.as_os_str().to_string_lossy().to_string(); first_components.insert(name); } } } for name in first_components { if filter_prefix.is_empty() || name.starts_with(filter_prefix) || name.contains(filter_prefix) { let mut value = String::new(); value.push_str(shown_prefix); value.push_str(&name); if !value.ends_with('/') { value.push('/'); } out.push(CompletionCandidate::new(value)); } } out } // List immediate child directories under `base` that are themselves project roots, // suggesting them as `name:` (or `shown_prefix + name + :`) fn list_direct_project_colons( base: &Path, shown_prefix: &str, filter_prefix: &str, workspace: &Workspace, ) -> Vec { // Build a set of absolute project paths for quick lookup let proj_paths: BTreeSet<_> = workspace .projects() .iter() .map(|p| p.path().to_path_buf()) .collect(); // Compute immediate child names that lead to at least one project (same logic as list_subdirs) // then keep only those where `base/child` is itself a project root. let mut names: BTreeSet = BTreeSet::new(); for proj in workspace.projects() { let p = proj.path(); if let Ok(rel) = p.strip_prefix(base) { if rel.as_os_str().is_empty() { continue; } if let Some(first) = rel.components().next() { let name = first.as_os_str().to_string_lossy().to_string(); // Only keep if this immediate child is a project root let child_abs = base.join(&name); if proj_paths.contains(&child_abs) { names.insert(name); } } } } let mut out = Vec::new(); for name in names { if filter_prefix.is_empty() || name.starts_with(filter_prefix) || name.contains(filter_prefix) { let mut value = String::new(); value.push_str(shown_prefix); value.push_str(&name); value.push(':'); out.push(CompletionCandidate::new(value)); } } out } ================================================ FILE: crates/prek/src/cli/hook_impl.rs ================================================ use std::ffi::OsString; use std::fmt::Write; use std::ops::RangeInclusive; use std::path::PathBuf; use std::process::Stdio; use anstream::eprintln; use anyhow::Result; use itertools::Itertools; use owo_colors::OwoColorize; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use prek_consts::env_vars::EnvVars; use crate::cli::{self, ExitStatus, RunArgs}; use crate::config::HookType; use crate::fs::CWD; use crate::git::GIT_ROOT; use crate::languages::resolve_command; use crate::printer::Printer; use crate::process::Cmd; use crate::store::Store; use crate::workspace; use crate::workspace::Project; use crate::{git, warn_user}; pub(crate) async fn hook_impl( store: &Store, config: Option, includes: Vec, skips: Vec, hook_type: HookType, hook_dir: PathBuf, skip_on_missing_config: bool, script_version: Option, args: Vec, printer: Printer, ) -> Result { let stdin = read_hook_stdin(hook_type).await?; let legacy_code = run_legacy(hook_type, &hook_dir, &args, &stdin).await?; if script_version != Some(cli::install::CUR_SCRIPT_VERSION) { warn_user!( "The installed Git shim `{hook_type}` is outdated (version: {:?}, expected: {}). Please reinstall the Git shims with `prek install`.", script_version.unwrap_or(1), cli::install::CUR_SCRIPT_VERSION ); } let allow_missing_config = skip_on_missing_config || EnvVars::is_set(EnvVars::PREK_ALLOW_NO_CONFIG); let warn_for_no_config = || { eprintln!( "- To temporarily silence this, run `{}`", format!("{}=1 git ...", EnvVars::PREK_ALLOW_NO_CONFIG).cyan() ); eprintln!( "- To permanently silence this, install hooks with the `{}` flag", "--allow-missing-config".cyan() ); eprintln!("- To uninstall hooks, run `{}`", "prek uninstall".cyan()); }; // Check if there is config file if let Some(ref config) = config { if !config.try_exists()? { return if allow_missing_config { Ok(legacy_code.into()) } else { eprintln!( "{}: config file not found: `{}`", "error".red().bold(), config.display().cyan() ); warn_for_no_config(); Ok(ExitStatus::Failure) }; } writeln!(printer.stdout(), "Using config file: {}", config.display())?; } else { // Try to discover a project from current directory (after `--cd`) match Project::discover(config.as_deref(), &CWD) { Err(e @ workspace::Error::MissingConfigFile) => { return if allow_missing_config { Ok(legacy_code.into()) } else { eprintln!("{}: {e}", "error".red().bold()); warn_for_no_config(); Ok(ExitStatus::Failure) }; } Ok(project) => { if project.path() != GIT_ROOT.as_ref()? { writeln!( printer.stdout(), "Running in workspace: `{}`", project.path().display().cyan() )?; } } Err(e) => return Err(e.into()), } } if !hook_type.num_args().contains(&args.len()) { anyhow::bail!( "hook `{}` expects {} but received {}{}", hook_type.to_string().cyan(), format_expected_args(hook_type.num_args()), format_received_args(args.len()), format_argument_dump(&args) ); } let Some(run_args) = to_run_args(hook_type, &args, &stdin).await else { return Ok(legacy_code.into()); }; let status = cli::run( store, config, includes, skips, Some(hook_type.into()), run_args.from_ref, run_args.to_ref, run_args.all_files, vec![], vec![], false, false, run_args.fail_fast, false, false, run_args.extra, false, printer, ) .await?; Ok(if !matches!(status, ExitStatus::Success) { status } else { legacy_code.into() }) } async fn read_hook_stdin(hook_type: HookType) -> Result> { if !matches!(hook_type, HookType::PrePush) { return Ok(vec![]); } let mut stdin = tokio::io::stdin(); let mut buffer = vec![]; stdin.read_to_end(&mut buffer).await?; Ok(buffer) } async fn run_legacy( hook_type: HookType, hook_dir: &std::path::Path, args: &[OsString], stdin: &[u8], ) -> Result { if EnvVars::is_set(EnvVars::PREK_RUNNING_LEGACY) { anyhow::bail!( "prek's Git shim is installed in migration mode\n\ run `prek install -f --hook-type {hook_type}` to reinstall the shim" ); } let legacy_hook = hook_dir.join(format!("{hook_type}.legacy")); let metadata = match fs_err::tokio::metadata(&legacy_hook).await { Ok(metadata) => metadata, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // No legacy hook, so skip running it. return Ok(0); } Err(e) => return Err(e.into()), }; let executable; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; executable = metadata.permissions().mode() & 0o111 != 0; } #[cfg(not(unix))] { executable = true; _ = metadata; } if !executable { return Ok(0); } let entry = resolve_command(vec![legacy_hook.to_string_lossy().into_owned()], None); let mut cmd = Cmd::new(&entry[0], format!("legacy hook `{}`", hook_type.as_ref())); cmd.check(false).args(&entry[1..]).args(args); cmd.env(EnvVars::PREK_RUNNING_LEGACY, "1"); let status = if stdin.is_empty() { cmd.status().await? } else { cmd.stdin(Stdio::piped()); let mut child = cmd.spawn()?; if let Some(mut child_stdin) = child.stdin.take() { child_stdin.write_all(stdin).await?; } child.wait().await? }; Ok(status .code() .and_then(|code| u8::try_from(code).ok()) .unwrap_or(1)) } async fn to_run_args(hook_type: HookType, args: &[OsString], stdin: &[u8]) -> Option { let mut run_args = RunArgs::default(); match hook_type { HookType::PrePush => { // https://git-scm.com/docs/githooks#_pre_push run_args.extra.remote_name = Some(args[0].to_string_lossy().into_owned()); run_args.extra.remote_url = Some(args[1].to_string_lossy().into_owned()); if let Some(push_info) = parse_pre_push_info(&args[0].to_string_lossy(), stdin).await { run_args.from_ref = push_info.from_ref; run_args.to_ref = push_info.to_ref; run_args.all_files = push_info.all_files; run_args.extra.remote_branch = push_info.remote_branch; run_args.extra.local_branch = push_info.local_branch; } else { // Nothing to push return None; } } HookType::CommitMsg => { run_args.extra.commit_msg_filename = Some(args[0].to_string_lossy().into_owned()); } HookType::PrepareCommitMsg => { run_args.extra.commit_msg_filename = Some(args[0].to_string_lossy().into_owned()); if args.len() > 1 { run_args.extra.prepare_commit_message_source = Some(args[1].to_string_lossy().into_owned()); } if args.len() > 2 { run_args.extra.commit_object_name = Some(args[2].to_string_lossy().into_owned()); } } HookType::PostCheckout => { run_args.from_ref = Some(args[0].to_string_lossy().into_owned()); run_args.to_ref = Some(args[1].to_string_lossy().into_owned()); run_args.extra.checkout_type = Some(args[2].to_string_lossy().into_owned()); } HookType::PostMerge => run_args.extra.is_squash_merge = args[0] == "1", HookType::PostRewrite => { run_args.extra.rewrite_command = Some(args[0].to_string_lossy().into_owned()); } HookType::PreRebase => { run_args.extra.pre_rebase_upstream = Some(args[0].to_string_lossy().into_owned()); if args.len() > 1 { run_args.extra.pre_rebase_branch = Some(args[1].to_string_lossy().into_owned()); } } HookType::PostCommit | HookType::PreMergeCommit | HookType::PreCommit => {} } Some(run_args) } #[derive(Debug)] struct PushInfo { from_ref: Option, to_ref: Option, all_files: bool, remote_branch: Option, local_branch: Option, } async fn parse_pre_push_info(remote_name: &str, stdin: &[u8]) -> Option { let buffer = String::from_utf8_lossy(stdin); for line in buffer.lines() { let parts: Vec<&str> = line.rsplitn(4, ' ').collect(); if parts.len() != 4 { continue; } let local_branch = parts[3]; let local_sha = parts[2]; let remote_branch = parts[1]; let remote_sha = parts[0]; // Skip if local_sha is all zeros if local_sha.bytes().all(|b| b == b'0') { continue; } // If remote_sha exists and is not all zeros if !remote_sha.bytes().all(|b| b == b'0') && git::rev_exists(remote_sha).await.unwrap_or(false) { return Some(PushInfo { from_ref: Some(remote_sha.to_string()), to_ref: Some(local_sha.to_string()), all_files: false, remote_branch: Some(remote_branch.to_string()), local_branch: Some(local_branch.to_string()), }); } // Find ancestors that don't exist in remote let ancestors = git::get_ancestors_not_in_remote(local_sha, remote_name) .await .unwrap_or_default(); if ancestors.is_empty() { continue; } let first_ancestor = &ancestors[0]; let roots = git::get_root_commits(local_sha).await.unwrap_or_default(); if roots.contains(first_ancestor) { // Pushing the whole tree including root commit return Some(PushInfo { from_ref: None, to_ref: Some(local_sha.to_string()), all_files: true, remote_branch: Some(remote_branch.to_string()), local_branch: Some(local_branch.to_string()), }); } // Find the source (first_ancestor^) if let Ok(Some(source)) = git::get_parent_commit(first_ancestor).await { return Some(PushInfo { from_ref: Some(source), to_ref: Some(local_sha.to_string()), all_files: false, remote_branch: Some(remote_branch.to_string()), local_branch: Some(local_branch.to_string()), }); } } // Nothing to push None } fn format_expected_args(range: RangeInclusive) -> String { let (start, end) = (*range.start(), *range.end()); match (start, end) { (0, 0) => "no arguments".to_string(), (1, 1) => "exactly 1 argument".to_string(), (s, e) if s == e => format!("exactly {s} arguments"), (0, e) => format!("up to {e} arguments"), (s, usize::MAX) => format!("at least {s} arguments"), (s, e) => format!("between {s} and {e} arguments"), } } fn format_received_args(received: usize) -> String { match received { 0 => "no arguments".to_string(), 1 => "1 argument".to_string(), n => format!("{n} arguments"), } } fn format_argument_dump(args: &[OsString]) -> String { if args.is_empty() { String::new() } else { format!(": `{}`", args.iter().map(|s| s.to_string_lossy()).join(" ")) } } ================================================ FILE: crates/prek/src/cli/identify.rs ================================================ use std::fmt::Write; use std::path::PathBuf; use itertools::Itertools; use owo_colors::OwoColorize; use prek_identify::tags_from_path; use serde::Serialize; use crate::cli::{ExitStatus, IdentifyOutputFormat}; use crate::printer::Printer; #[derive(Serialize)] struct IdentifyEntry { path: String, tags: Vec, } pub(crate) fn identify( paths: &[PathBuf], output_format: IdentifyOutputFormat, printer: Printer, ) -> anyhow::Result { let mut status = ExitStatus::Success; let mut outputs = Vec::new(); for path in paths { match tags_from_path(path) { Ok(tags) => match output_format { IdentifyOutputFormat::Text => { writeln!( printer.stdout_important(), "{}: {}", path.display().bold(), tags.iter().join(", ") )?; } IdentifyOutputFormat::Json => { outputs.push(IdentifyEntry { path: path.display().to_string(), tags: tags.iter().map(ToString::to_string).collect(), }); } }, Err(err) => { status = ExitStatus::Failure; writeln!( printer.stderr(), "{}: {}: {}", "error".red().bold(), path.display(), err )?; } } } if matches!(output_format, IdentifyOutputFormat::Json) { let json_output = serde_json::to_string_pretty(&outputs)?; writeln!(printer.stdout_important(), "{json_output}")?; } Ok(status) } ================================================ FILE: crates/prek/src/cli/install.rs ================================================ use std::fmt::Write as _; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, Result}; use bstr::ByteSlice; use clap::ValueEnum; use owo_colors::OwoColorize; use prek_consts::CONFIG_FILENAMES; use same_file::is_same_file; use crate::cli::reporter::{HookInitReporter, HookInstallReporter}; use crate::cli::run; use crate::cli::run::{SelectorSource, Selectors}; use crate::cli::{ExitStatus, HookType}; use crate::config::load_config; use crate::fs::{CWD, Simplified}; use crate::git::{GIT_ROOT, git_cmd}; use crate::printer::Printer; use crate::store::Store; use crate::workspace::{Error as WorkspaceError, Project, Workspace}; use crate::{git, warn_user}; #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn install( store: &Store, config: Option, includes: Vec, skips: Vec, hook_types: Vec, prepare_hooks: bool, overwrite: bool, allow_missing_config: bool, refresh: bool, quiet: u8, verbose: u8, no_progress: bool, printer: Printer, git_dir: Option<&Path>, ) -> Result { if git_dir.is_none() && git::has_hooks_path_set().await? { anyhow::bail!( "Cowardly refusing to install hooks with `core.hooksPath` set.\nhint: Run these commands to remove core.hooksPath:\nhint: {}\nhint: {}", "git config --unset-all --local core.hooksPath".cyan(), "git config --unset-all --global core.hooksPath".cyan() ); } let hook_mode = git::get_shared_repository_file_mode(0o755) .await .unwrap_or(0o755); let project = match Project::discover(config.as_deref(), &CWD) { Ok(project) => Some(project), Err(err) => { if let WorkspaceError::Config(err) = &err { err.warn_parse_error(); } None } }; let hook_types = get_hook_types(hook_types, project.as_ref(), config.as_deref()); let hooks_path = if let Some(dir) = git_dir { dir.join("hooks") } else { git::get_git_common_dir().await?.join("hooks") }; fs_err::create_dir_all(&hooks_path)?; let selectors = if let Some(project) = &project { Some(Selectors::load(&includes, &skips, project.path())?) } else if !includes.is_empty() || !skips.is_empty() { anyhow::bail!("Cannot use `--include` or `--skip` outside of a git repository"); } else { None }; for hook_type in hook_types { install_hook_script( project.as_ref(), config.clone(), selectors.as_ref(), hook_type, &hooks_path, overwrite, allow_missing_config, hook_mode, quiet, verbose, no_progress, printer, )?; } if prepare_hooks { self::prepare_hooks(store, config, includes, skips, refresh, printer).await?; } Ok(ExitStatus::Success) } pub(crate) async fn prepare_hooks( store: &Store, config: Option, includes: Vec, skips: Vec, refresh: bool, printer: Printer, ) -> Result { let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?; let selectors = Selectors::load(&includes, &skips, &workspace_root)?; let mut workspace = Workspace::discover(store, workspace_root, config, Some(&selectors), refresh)?; let reporter = HookInitReporter::new(printer); let _lock = store.lock_async().await?; let hooks = workspace .init_hooks(store, Some(&reporter)) .await .context("Failed to init hooks")?; let filtered_hooks: Vec<_> = hooks .into_iter() .filter(|h| selectors.matches_hook(h)) .map(Arc::new) .collect(); let reporter = HookInstallReporter::new(printer); run::install_hooks(filtered_hooks, store, &reporter).await?; Ok(ExitStatus::Success) } fn get_hook_types( mut hook_types: Vec, project: Option<&Project>, config: Option<&Path>, ) -> Vec { if !hook_types.is_empty() { return hook_types; } hook_types = if let Some(project) = project { project .config() .default_install_hook_types .clone() .unwrap_or_default() } else { let fallbacks = CONFIG_FILENAMES .iter() .map(Path::new) .filter(|p| p.exists()); if let Some(path) = config.into_iter().chain(fallbacks).next() { match load_config(path) { Ok(cfg) => cfg.default_install_hook_types.clone().unwrap_or_default(), Err(err) => { err.warn_parse_error(); vec![] } } } else { vec![] } }; if hook_types.is_empty() { hook_types = vec![HookType::PreCommit]; } hook_types } #[allow(clippy::fn_params_excessive_bools)] fn install_hook_script( project: Option<&Project>, config: Option, selectors: Option<&Selectors>, hook_type: HookType, hooks_path: &Path, overwrite: bool, skip_on_missing_config: bool, hook_mode: u32, quiet: u8, verbose: u8, no_progress: bool, printer: Printer, ) -> Result<()> { let hook_path = hooks_path.join(hook_type.as_ref()); let legacy_path = hook_path.with_added_extension("legacy"); if hook_path.try_exists()? { if overwrite { writeln!( printer.stdout(), "Overwriting existing hook at `{}`", hook_path.user_display().cyan() )?; } else { if !is_our_script(&hook_path)? { fs_err::rename(&hook_path, &legacy_path)?; writeln!( printer.stdout(), "Hook already exists at `{}`, moved it to `{}`", hook_path.user_display().cyan(), legacy_path.user_display().yellow() )?; } } } if legacy_path.try_exists()? { if overwrite { // Remove existing legacy script too if we're overwriting. fs_err::remove_file(&legacy_path)?; } else { writeln!( printer.stdout(), "Migration mode: prek will also run legacy hook `{}`. Use `--overwrite` to remove legacy hooks.", legacy_path.user_display().yellow() )?; } } let mut args = vec![]; // Add include/skip selectors. if let Some(selectors) = selectors { for include in selectors.includes() { args.push(include.as_normalized_flag()); } // Find any skip selectors from environment variables. if let Some(env_var) = selectors.skips().iter().find_map(|skip| { if let SelectorSource::EnvVar(var) = skip.source() { Some(var) } else { None } }) { warn_user!( "Skip selectors from environment variables `{}` are ignored during installing hooks.", env_var.cyan() ); } for skip in selectors.skips() { if matches!(skip.source(), SelectorSource::CliFlag(_)) { args.push(skip.as_normalized_flag()); } } } args.push(format!("--hook-type={hook_type}")); let mut hint = format!("prek installed at `{}`", hook_path.user_display().cyan()); // Prefer explicit config path if given (non-workspace mode). // Otherwise, use the config path from the discovered project (workspace mode). // If neither is available, don't pass a config path (let prek find it). In this case, // we're different with `pre-commit` which always sets `--config=.pre-commit-config.yaml`. if let Some(config) = config { args.push(format!(r#"--config="{}""#, config.display())); write!(hint, " with specified config `{}`", config.display().cyan())?; } else if let Some(project) = project { let git_root = GIT_ROOT.as_ref()?; let project_path = project.path(); let relative_path = project_path.strip_prefix(git_root).unwrap_or(project_path); if !relative_path.as_os_str().is_empty() { args.push(format!(r#"--cd="{}""#, relative_path.display())); } // Show workspace path if it's not the root project. if project_path != git_root { writeln!(hint, " for workspace `{}`", project_path.display().cyan())?; write!( hint, "\n{} this hook installed for `{}` only; run `prek install` from `{}` to install for the entire repo.", "hint:".bold().yellow(), project_path.display().cyan(), git_root.display().cyan() )?; } } if skip_on_missing_config { args.push("--skip-on-missing-config".to_string()); } let prek = std::env::current_exe()?; let prek = prek.simplified_display().to_string(); let mut prek_global_args = render_global_args(quiet, verbose, no_progress); if !prek_global_args.is_empty() { prek_global_args.push(' '); } let hook_script = HOOK_TMPL .replace("[CUR_SCRIPT_VERSION]", &CUR_SCRIPT_VERSION.to_string()) .replace("[PREK_PATH]", &format!(r#""{prek}""#)) .replace("[PREK_GLOBAL_ARGS]", &prek_global_args) .replace("[PREK_ARGS]", &args.join(" ")); fs_err::OpenOptions::new() .write(true) .create(true) .truncate(true) .open(&hook_path)? .write_all(hook_script.as_bytes())?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = hook_path.metadata()?.permissions(); perms.set_mode(hook_mode); fs_err::set_permissions(&hook_path, perms)?; } // Unused on non-Unix platforms #[cfg(not(unix))] let _ = hook_mode; writeln!(printer.stdout(), "{hint}")?; Ok(()) } fn render_global_args(quiet: u8, verbose: u8, no_progress: bool) -> String { let mut args = Vec::with_capacity(3); if quiet > 0 { args.push(format!("-{}", "q".repeat(quiet.into()))); } if verbose > 0 { args.push(format!("-{}", "v".repeat(verbose.into()))); } if no_progress { args.push("--no-progress".to_string()); } if args.is_empty() { String::new() } else { args.join(" ") } } /// The version of the hook script. Increment this when the script changes in a way that /// requires re-installation. pub(crate) static CUR_SCRIPT_VERSION: usize = 4; static HOOK_TMPL: &str = r#"#!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK=[PREK_PATH] # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" [PREK_GLOBAL_ARGS]hook-impl --hook-dir "$HERE" --script-version [CUR_SCRIPT_VERSION] [PREK_ARGS] -- "$@" "#; static PRIOR_HASHES: &[&str] = &[]; // Use a different hash for each change to the script. // Use a different hash from `pre-commit` since our script is different. static CURRENT_HASH: &str = "182c10f181da4464a3eec51b83331688"; /// Checks if the script contains any of the hashes that `prek` has used in the past. fn is_our_script(hook_path: &Path) -> std::io::Result { let content = fs_err::read_to_string(hook_path)?; Ok(std::iter::once(CURRENT_HASH) .chain(PRIOR_HASHES.iter().copied()) .any(|hash| content.contains(hash))) } pub(crate) async fn uninstall( config: Option, hook_types: Vec, all: bool, printer: Printer, ) -> Result { let project = Project::discover(config.as_deref(), &CWD).ok(); let hooks_path = git::get_git_common_dir().await?.join("hooks"); let types: Vec = if all { HookType::value_variants().to_vec() } else { get_hook_types(hook_types, project.as_ref(), config.as_deref()) }; for hook_type in types { let hook_path = hooks_path.join(hook_type.as_ref()); let legacy_path = hook_path.with_added_extension("legacy"); if is_our_script(&legacy_path).unwrap_or(false) { fs_err::remove_file(&legacy_path)?; writeln!( printer.stderr(), "Found legacy hook at `{}`, removing it.", legacy_path.user_display().cyan() )?; } match is_our_script(&hook_path) { Ok(true) => {} Ok(false) => { if !all { writeln!( printer.stderr(), "`{}` is not managed by prek, skipping.", hook_path.user_display().cyan() )?; } continue; } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { if !all { writeln!( printer.stderr(), "`{}` does not exist, skipping.", hook_path.user_display().cyan() )?; } continue; } Err(err) => return Err(err.into()), } fs_err::remove_file(&hook_path)?; writeln!( printer.stdout(), "Uninstalled `{}`", hook_type.as_ref().cyan() )?; if legacy_path.try_exists()? { fs_err::rename(&legacy_path, &hook_path)?; writeln!( printer.stdout(), "Restored `{}` to `{}`", legacy_path.user_display().cyan(), hook_path.user_display().cyan() )?; } } Ok(ExitStatus::Success) } pub(crate) async fn init_template_dir( store: &Store, directory: PathBuf, config: Option, hook_types: Vec, requires_config: bool, refresh: bool, quiet: u8, verbose: u8, no_progress: bool, printer: Printer, ) -> Result { install( store, config, vec![], vec![], hook_types, false, true, !requires_config, refresh, quiet, verbose, no_progress, printer, Some(&directory), ) .await?; let output = git_cmd("git config")? .arg("config") .arg("init.templateDir") .check(false) .output() .await?; let template_dir = String::from_utf8_lossy(output.stdout.trim()).to_string(); if template_dir.is_empty() || !is_same_file(&directory, &template_dir)? { warn_user!( "git config `init.templateDir` not set to the target directory, try `{}`", format!( "git config --global init.templateDir '{}'", directory.display() ) .cyan() ); } Ok(ExitStatus::Success) } ================================================ FILE: crates/prek/src/cli/list.rs ================================================ use std::fmt::Write; use std::path::PathBuf; use anyhow::Context; use owo_colors::OwoColorize; use serde::Serialize; use crate::cli::reporter::HookInitReporter; use crate::cli::run::Selectors; use crate::cli::{ExitStatus, ListOutputFormat}; use crate::config::{Language, Stage}; use crate::fs::CWD; use crate::printer::Printer; use crate::store::Store; use crate::workspace::Workspace; #[derive(Serialize)] struct SerializableHook { id: String, full_id: String, name: String, alias: String, language: Language, description: Option, stages: Vec, } pub(crate) async fn list( store: &Store, config: Option, includes: Vec, skips: Vec, hook_stage: Option, language: Option, output_format: ListOutputFormat, refresh: bool, verbose: bool, printer: Printer, ) -> anyhow::Result { let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?; let selectors = Selectors::load(&includes, &skips, &workspace_root)?; let mut workspace = Workspace::discover(store, workspace_root, config, Some(&selectors), refresh)?; let reporter = HookInitReporter::new(printer); let lock = store.lock_async().await?; let hooks = workspace .init_hooks(store, Some(&reporter)) .await .context("Failed to init hooks")?; drop(lock); let filtered_hooks: Vec<_> = hooks .into_iter() .filter(|h| selectors.matches_hook(h)) .filter(|h| hook_stage.is_none_or(|hook_stage| h.stages.contains(hook_stage))) .filter(|h| language.is_none_or(|lang| h.language == lang)) .collect(); selectors.report_unused(); match output_format { ListOutputFormat::Text => { if verbose { // TODO: show repo path and environment path (if installed) for hook in &filtered_hooks { writeln!(printer.stdout(), "{}", hook.full_id().bold())?; writeln!(printer.stdout(), " {} {}", "ID:".bold().cyan(), hook.id)?; if !hook.alias.is_empty() && hook.alias != hook.id { writeln!( printer.stdout(), " {} {}", "Alias:".bold().cyan(), hook.alias )?; } writeln!( printer.stdout(), " {} {}", "Name:".bold().cyan(), hook.name )?; if let Some(description) = &hook.description { writeln!( printer.stdout(), " {} {}", "Description:".bold().cyan(), description )?; } writeln!( printer.stdout(), " {} {}", "Language:".bold().cyan(), hook.language.as_ref() )?; writeln!( printer.stdout(), " {} {}", "Stages:".bold().cyan(), hook.stages )?; writeln!(printer.stdout())?; } } else { for hook in &filtered_hooks { writeln!(printer.stdout(), "{}", hook.full_id())?; } } } ListOutputFormat::Json => { let serializable_hooks: Vec<_> = filtered_hooks .into_iter() .map(|h| { let id = h.id.clone(); let full_id = h.full_id(); let stages = h.stages.to_vec(); SerializableHook { id, full_id, name: h.name, alias: h.alias, language: h.language, description: h.description, stages, } }) .collect(); let json_output = serde_json::to_string_pretty(&serializable_hooks)?; writeln!(printer.stdout(), "{json_output}")?; } } Ok(ExitStatus::Success) } ================================================ FILE: crates/prek/src/cli/list_builtins.rs ================================================ use std::fmt::Write; use owo_colors::OwoColorize; use serde::Serialize; use strum::IntoEnumIterator; use crate::cli::{ExitStatus, ListOutputFormat}; use crate::config::BuiltinHook; use crate::hooks::BuiltinHooks; use crate::printer::Printer; #[derive(Serialize)] struct SerializableBuiltinHook { id: String, name: String, description: Option, } /// List all builtin hooks. pub(crate) fn list_builtins( output_format: ListOutputFormat, verbose: bool, printer: Printer, ) -> anyhow::Result { let hooks = BuiltinHooks::iter().map(|variant| { let id = variant.as_ref(); BuiltinHook::from_id(id).expect("All BuiltinHooks variants should be valid") }); match output_format { ListOutputFormat::Text => { if verbose { for hook in hooks { writeln!(printer.stdout_important(), "{}", hook.id.bold())?; if let Some(description) = &hook.options.description { writeln!(printer.stdout_important(), " {description}")?; } writeln!(printer.stdout_important())?; } } else { for hook in hooks { writeln!(printer.stdout_important(), "{}", hook.id)?; } } } ListOutputFormat::Json => { let serializable: Vec<_> = hooks .map(|h| SerializableBuiltinHook { id: h.id, name: h.name, description: h.options.description, }) .collect(); let json_output = serde_json::to_string_pretty(&serializable)?; writeln!(printer.stdout_important(), "{json_output}")?; } } Ok(ExitStatus::Success) } ================================================ FILE: crates/prek/src/cli/mod.rs ================================================ use std::ffi::OsString; use std::path::PathBuf; use std::process::ExitCode; use clap::builder::styling::{AnsiColor, Effects}; use clap::builder::{ArgPredicate, Styles}; use clap::{ArgAction, Args, Parser, Subcommand, ValueHint}; use clap_complete::engine::ArgValueCompleter; use prek_consts::env_vars::EnvVars; use serde::{Deserialize, Serialize}; use crate::config::{HookType, Language, Stage}; mod auto_update; mod cache_clean; mod cache_gc; mod cache_size; mod completion; mod hook_impl; mod identify; mod install; mod list; mod list_builtins; pub mod reporter; pub mod run; mod sample_config; #[cfg(feature = "self-update")] mod self_update; mod try_repo; mod validate; mod yaml_to_toml; pub(crate) use auto_update::auto_update; pub(crate) use cache_clean::cache_clean; pub(crate) use cache_gc::cache_gc; pub(crate) use cache_size::cache_size; use completion::selector_completer; pub(crate) use hook_impl::hook_impl; pub(crate) use identify::identify; pub(crate) use install::{init_template_dir, install, prepare_hooks, uninstall}; pub(crate) use list::list; pub(crate) use list_builtins::list_builtins; pub(crate) use run::run; pub(crate) use sample_config::sample_config; #[cfg(feature = "self-update")] pub(crate) use self_update::self_update; pub(crate) use try_repo::try_repo; pub(crate) use validate::{validate_configs, validate_manifest}; pub(crate) use yaml_to_toml::yaml_to_toml; #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(crate) enum ExitStatus { /// The command succeeded. Success, /// The command failed due to an error in the user input. Failure, /// The command failed with an unexpected error. Error, /// The command was interrupted. Interrupted, /// The command's exit status is propagated from an external command. External(u8), } impl From for ExitCode { fn from(status: ExitStatus) -> Self { match status { ExitStatus::Success => Self::from(0), ExitStatus::Failure => Self::from(1), ExitStatus::Error => Self::from(2), ExitStatus::Interrupted => Self::from(130), ExitStatus::External(code) => Self::from(code), } } } impl From for ExitStatus { fn from(code: u8) -> Self { match code { 0 => Self::Success, other => Self::External(other), } } } #[derive(Debug, Copy, Clone, clap::ValueEnum)] pub enum ColorChoice { /// Enables colored output only when the output is going to a terminal or TTY with support. Auto, /// Enables colored output regardless of the detected environment. Always, /// Disables colored output. Never, } impl From for anstream::ColorChoice { fn from(value: ColorChoice) -> Self { match value { ColorChoice::Auto => Self::Auto, ColorChoice::Always => Self::Always, ColorChoice::Never => Self::Never, } } } const STYLES: Styles = Styles::styled() .header(AnsiColor::Green.on_default().effects(Effects::BOLD)) .usage(AnsiColor::Green.on_default().effects(Effects::BOLD)) .literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) .placeholder(AnsiColor::Cyan.on_default()); #[derive(Parser)] #[command( name = "prek", long_version = crate::version::version(), about = "Better pre-commit, re-engineered in Rust" )] #[command( propagate_version = true, disable_help_flag = true, disable_help_subcommand = true, disable_version_flag = true )] #[command(styles=STYLES)] pub(crate) struct Cli { #[command(subcommand)] pub(crate) command: Option, // run as the default subcommand #[command(flatten)] pub(crate) run_args: RunArgs, #[command(flatten)] pub(crate) globals: GlobalArgs, } #[derive(Debug, Args)] #[command(next_help_heading = "Global options", next_display_order = 1000)] #[allow(clippy::struct_excessive_bools)] pub(crate) struct GlobalArgs { /// Path to alternate config file. #[arg(global = true, short, long)] pub(crate) config: Option, /// Change to directory before running. #[arg( global = true, short = 'C', long, value_name = "DIR", value_hint = ValueHint::DirPath, )] pub(crate) cd: Option, /// Whether to use color in output. #[arg( global = true, long, value_enum, env = EnvVars::PREK_COLOR, default_value_t = ColorChoice::Auto, )] pub(crate) color: ColorChoice, /// Refresh all cached data. #[arg(global = true, long)] pub(crate) refresh: bool, /// Display the concise help for this command. #[arg(global = true, short, long, action = ArgAction::HelpShort)] help: (), /// Hide all progress outputs. /// /// For example, spinners or progress bars. #[arg(global = true, long)] pub no_progress: bool, /// Use quiet output. /// /// Repeating this option, e.g., `-qq`, will enable a silent mode in which /// prek will write no output to stdout. #[arg(global = true, short, long, env = EnvVars::PREK_QUIET, conflicts_with = "verbose", action = ArgAction::Count)] pub quiet: u8, /// Use verbose output. #[arg(global = true, short, long, action = ArgAction::Count)] pub(crate) verbose: u8, /// Write trace logs to the specified file. /// If not specified, trace logs will be written to `$PREK_HOME/prek.log`. #[arg(global = true, long, value_name = "LOG_FILE", value_hint = ValueHint::FilePath)] pub(crate) log_file: Option, /// Do not write trace logs to a log file. #[arg(global = true, long, overrides_with = "log_file", hide = true)] pub(crate) no_log_file: bool, /// Display the prek version. #[arg(global = true, short = 'V', long, action = ArgAction::Version)] version: (), /// Show the resolved settings for the current command. /// /// This option is used for debugging and development purposes. #[arg(global = true, long, hide = true)] pub show_settings: bool, } #[derive(Debug, Subcommand)] pub(crate) enum Command { /// Install prek Git shims under the `.git/hooks/` directory. /// /// The Git shims installed by this command are determined by `--hook-type` /// or `default_install_hook_types` in the config file, falling back to /// `pre-commit` when neither is set. /// /// A hook's `stages` field does not affect which Git shims this /// command installs. Install(InstallArgs), /// Prepare environments for all hooks used in the config file. /// /// This command does not install Git shims. To install the Git shims /// along with the hook environments in one command, use `prek install --prepare-hooks`. #[command(alias = "install-hooks")] PrepareHooks(PrepareHooksArgs), /// Run hooks. Run(Box), /// List hooks configured in the current workspace. List(ListArgs), /// Uninstall prek Git shims. Uninstall(UninstallArgs), /// Validate configuration files (prek.toml or .pre-commit-config.yaml). ValidateConfig(ValidateConfigArgs), /// Validate `.pre-commit-hooks.yaml` files. ValidateManifest(ValidateManifestArgs), /// Produce a sample configuration file (prek.toml or .pre-commit-config.yaml). SampleConfig(SampleConfigArgs), /// Auto-update the `rev` field of repositories in the config file to the latest version. #[command(alias = "autoupdate")] AutoUpdate(AutoUpdateArgs), /// Manage the prek cache. Cache(CacheNamespace), /// Clean unused cached repos. #[command(hide = true)] GC(CacheGcArgs), /// Remove all prek cached data. #[command(hide = true)] Clean, /// Install Git shims in a directory intended for use with `git config init.templateDir`. #[command(alias = "init-templatedir", hide = true)] InitTemplateDir(InitTemplateDirArgs), /// Try the pre-commit hooks in the current repo. TryRepo(Box), /// The implementation of the prek Git shim that is installed in the `.git/hooks/` directory. #[command(hide = true)] HookImpl(HookImplArgs), /// Utility commands. Util(UtilNamespace), /// `prek` self management. #[command(name = "self")] Self_(SelfNamespace), } #[derive(Debug, Args)] pub(crate) struct InstallArgs { /// Include the specified hooks or projects. /// /// Supports flexible selector syntax: /// /// - `hook-id`: Run all hooks with the specified ID across all projects /// /// - `project-path/`: Run all hooks from the specified project /// /// - `project-path:hook-id`: Run only the specified hook from the specified project /// /// Can be specified multiple times to select multiple hooks/projects. #[arg( value_name = "HOOK|PROJECT", value_hint = ValueHint::Other, add = ArgValueCompleter::new(selector_completer) )] pub(crate) includes: Vec, /// Skip the specified hooks or projects. /// /// Supports flexible selector syntax: /// /// - `hook-id`: Skip all hooks with the specified ID across all projects /// /// - `project-path/`: Skip all hooks from the specified project /// /// - `project-path:hook-id`: Skip only the specified hook from the specified project /// /// Can be specified multiple times. Also accepts `PREK_SKIP` or `SKIP` environment variables (comma-delimited). #[arg(long = "skip", value_name = "HOOK|PROJECT", add = ArgValueCompleter::new(selector_completer))] pub(crate) skips: Vec, /// Overwrite existing Git shims. #[arg(short = 'f', long)] pub(crate) overwrite: bool, /// Also prepare environments for all hooks used in the config file. #[arg(long, alias = "install-hooks")] pub(crate) prepare_hooks: bool, /// Which Git shim(s) to install. /// /// Specifies which Git hook type(s) you want to install shims for. /// Can be specified multiple times to install shims for multiple hook types. /// /// If not specified, uses `default_install_hook_types` from the config file, /// or defaults to `pre-commit` if that is also not set. /// /// Note: This is different from a hook's `stages` parameter in the config file, /// which declares which stages a hook *can* run in. #[arg(short = 't', long = "hook-type", value_name = "HOOK_TYPE", value_enum)] pub(crate) hook_types: Vec, /// Allow a missing configuration file. #[arg(long)] pub(crate) allow_missing_config: bool, /// Install Git shims into the `hooks` subdirectory of the given git directory (`/hooks/`). /// /// When this flag is used, `prek install` bypasses the safety check that normally /// refuses to install shims while `core.hooksPath` is set. Git itself will still /// ignore `.git/hooks` while `core.hooksPath` is configured, so ensure your Git /// configuration points to the directory where the shim is installed if you want /// it to be executed. #[arg(long, value_name = "GIT_DIR", value_hint = ValueHint::DirPath)] pub(crate) git_dir: Option, } #[derive(Debug, Args)] pub(crate) struct PrepareHooksArgs { /// Include the specified hooks or projects. /// /// Supports flexible selector syntax: /// /// - `hook-id`: Run all hooks with the specified ID across all projects /// /// - `project-path/`: Run all hooks from the specified project /// /// - `project-path:hook-id`: Run only the specified hook from the specified project /// /// Can be specified multiple times to select multiple hooks/projects. #[arg( value_name = "HOOK|PROJECT", value_hint = ValueHint::Other, add = ArgValueCompleter::new(selector_completer) )] pub(crate) includes: Vec, /// Skip the specified hooks or projects. /// /// Supports flexible selector syntax: /// /// - `hook-id`: Skip all hooks with the specified ID across all projects /// /// - `project-path/`: Skip all hooks from the specified project /// /// - `project-path:hook-id`: Skip only the specified hook from the specified project /// /// Can be specified multiple times. Also accepts `PREK_SKIP` or `SKIP` environment variables (comma-delimited). #[arg(long = "skip", value_name = "HOOK|PROJECT", add = ArgValueCompleter::new(selector_completer))] pub(crate) skips: Vec, } #[derive(Debug, Args)] pub(crate) struct UninstallArgs { /// Uninstall all prek-managed Git shims. /// /// Scans the hooks directory and removes every hook managed by prek, /// regardless of hook type. #[arg(long, conflicts_with = "hook_types")] pub(crate) all: bool, /// Which Git shim(s) to uninstall. /// /// Specifies which Git hook type(s) you want to uninstall shims for. /// Can be specified multiple times to uninstall shims for multiple hook types. /// /// If not specified, uses `default_install_hook_types` from the config file, /// or defaults to `pre-commit` if that is also not set. /// Use `--all` to remove all prek-managed hooks. #[arg(short = 't', long = "hook-type", value_name = "HOOK_TYPE", value_enum)] pub(crate) hook_types: Vec, } #[derive(Debug, Clone, Default, Args)] pub(crate) struct RunExtraArgs { #[arg(long, hide = true)] pub(crate) remote_branch: Option, #[arg(long, hide = true)] pub(crate) local_branch: Option, #[arg(long, hide = true, required_if_eq("stage", "pre-rebase"))] pub(crate) pre_rebase_upstream: Option, #[arg(long, hide = true)] pub(crate) pre_rebase_branch: Option, #[arg(long, hide = true, required_if_eq_any = [("stage", "prepare-commit-msg"), ("stage", "commit-msg")])] pub(crate) commit_msg_filename: Option, #[arg(long, hide = true)] pub(crate) prepare_commit_message_source: Option, #[arg(long, hide = true)] pub(crate) commit_object_name: Option, #[arg(long, hide = true)] pub(crate) remote_name: Option, #[arg(long, hide = true)] pub(crate) remote_url: Option, #[arg(long, hide = true)] pub(crate) checkout_type: Option, #[arg(long, hide = true)] pub(crate) is_squash_merge: bool, #[arg(long, hide = true)] pub(crate) rewrite_command: Option, } #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Default, Args)] pub(crate) struct RunArgs { /// Include the specified hooks or projects. /// /// Supports flexible selector syntax: /// /// - `hook-id`: Run all hooks with the specified ID across all projects /// /// - `project-path/`: Run all hooks from the specified project /// /// - `project-path:hook-id`: Run only the specified hook from the specified project /// /// Can be specified multiple times to select multiple hooks/projects. #[arg( value_name = "HOOK|PROJECT", value_hint = ValueHint::Other, add = ArgValueCompleter::new(selector_completer) )] pub(crate) includes: Vec, /// Skip the specified hooks or projects. /// /// Supports flexible selector syntax: /// /// - `hook-id`: Skip all hooks with the specified ID across all projects /// /// - `project-path/`: Skip all hooks from the specified project /// /// - `project-path:hook-id`: Skip only the specified hook from the specified project /// /// Can be specified multiple times. Also accepts `PREK_SKIP` or `SKIP` environment variables (comma-delimited). #[arg(long = "skip", value_name = "HOOK|PROJECT", add = ArgValueCompleter::new(selector_completer))] pub(crate) skips: Vec, /// Run on all files in the repo. #[arg(short, long, conflicts_with_all = ["files", "from_ref", "to_ref"])] pub(crate) all_files: bool, /// Specific filenames to run hooks on. #[arg( long, conflicts_with_all = ["all_files", "from_ref", "to_ref"], num_args = 0.., value_hint = ValueHint::AnyPath) ] pub(crate) files: Vec, /// Run hooks on all files in the specified directories. /// /// You can specify multiple directories. It can be used in conjunction with `--files`. #[arg( short, long, value_name = "DIR", conflicts_with_all = ["all_files", "from_ref", "to_ref"], value_hint = ValueHint::DirPath )] pub(crate) directory: Vec, /// The original ref in a `...` diff expression. /// Files changed in this diff will be run through the hooks. #[arg(short = 's', long, alias = "source", value_hint = ValueHint::Other)] pub(crate) from_ref: Option, /// The destination ref in a `from_ref...to_ref` diff expression. /// Defaults to `HEAD` if `from_ref` is specified. #[arg( short = 'o', long, alias = "origin", requires = "from_ref", value_hint = ValueHint::Other, default_value_if("from_ref", ArgPredicate::IsPresent, "HEAD") )] pub(crate) to_ref: Option, /// Run hooks against the last commit. Equivalent to `--from-ref HEAD~1 --to-ref HEAD`. #[arg(long, conflicts_with_all = ["all_files", "files", "directory", "from_ref", "to_ref"])] pub(crate) last_commit: bool, /// The stage during which the hook is fired. /// /// When specified, only hooks configured for that stage (for example `manual`, /// `pre-commit`, or `pre-push`) will run. /// Defaults to `pre-commit` if not specified. /// For hooks specified directly in the command line, fallback to `manual` stage if no hooks found for `pre-commit` stage. #[arg(long, value_enum, alias = "hook-stage")] pub(crate) stage: Option, /// When hooks fail, run `git diff` directly afterward. #[arg(long)] pub(crate) show_diff_on_failure: bool, /// Stop running hooks after the first failure. #[arg(long)] pub(crate) fail_fast: bool, /// Do not run the hooks, but print the hooks that would have been run. #[arg(long)] pub(crate) dry_run: bool, #[command(flatten)] pub(crate) extra: RunExtraArgs, } #[derive(Debug, Clone, Default, Args)] pub(crate) struct TryRepoArgs { /// Repository to source hooks from. pub(crate) repo: String, /// Manually select a rev to run against, otherwise the `HEAD` revision will be used. #[arg(long, alias = "ref")] pub(crate) rev: Option, #[command(flatten)] pub(crate) run_args: RunArgs, } #[derive(Debug, Clone, Copy, clap::ValueEnum, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum ListOutputFormat { #[default] Text, Json, } #[derive(Debug, Clone, Copy, clap::ValueEnum, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum IdentifyOutputFormat { #[default] Text, Json, } #[derive(Debug, Clone, Default, Args)] pub(crate) struct ListBuiltinsArgs { /// The output format. #[arg(long, value_enum, default_value_t = ListOutputFormat::Text)] pub(crate) output_format: ListOutputFormat, } #[derive(Debug, Clone, Default, Args)] pub(crate) struct ListArgs { /// Include the specified hooks or projects. /// /// Supports flexible selector syntax: /// /// - `hook-id`: Run all hooks with the specified ID across all projects /// /// - `project-path/`: Run all hooks from the specified project /// /// - `project-path:hook-id`: Run only the specified hook from the specified project /// /// Can be specified multiple times to select multiple hooks/projects. #[arg( value_name = "HOOK|PROJECT", value_hint = ValueHint::Other, add = ArgValueCompleter::new(selector_completer) )] pub(crate) includes: Vec, /// Skip the specified hooks or projects. /// /// Supports flexible selector syntax: /// /// - `hook-id`: Skip all hooks with the specified ID across all projects /// /// - `project-path/`: Skip all hooks from the specified project /// /// - `project-path:hook-id`: Skip only the specified hook from the specified project /// /// Can be specified multiple times. Also accepts `PREK_SKIP` or `SKIP` environment variables (comma-delimited). #[arg(long = "skip", value_name = "HOOK|PROJECT", add = ArgValueCompleter::new(selector_completer))] pub(crate) skips: Vec, /// Show only hooks that has the specified stage. #[arg(long, value_enum)] pub(crate) hook_stage: Option, /// Show only hooks that are implemented in the specified language. #[arg(long, value_enum)] pub(crate) language: Option, /// The output format. #[arg(long, value_enum, default_value_t = ListOutputFormat::Text)] pub(crate) output_format: ListOutputFormat, } #[derive(Debug, Clone, Default, Args)] pub(crate) struct IdentifyArgs { /// The path(s) to the file(s) to identify. #[arg(value_name = "PATH", value_hint = ValueHint::AnyPath)] pub(crate) paths: Vec, /// The output format. #[arg(long, value_enum, default_value_t = IdentifyOutputFormat::Text)] pub(crate) output_format: IdentifyOutputFormat, } #[derive(Debug, Args)] pub(crate) struct ValidateConfigArgs { /// The path to the configuration file. #[arg(value_name = "CONFIG")] pub(crate) configs: Vec, } #[derive(Debug, Args)] pub(crate) struct ValidateManifestArgs { /// The path to the manifest file. #[arg(value_name = "MANIFEST")] pub(crate) manifests: Vec, } #[expect(clippy::option_option)] #[derive(Debug, Args)] pub(crate) struct SampleConfigArgs { /// Write the sample config to a file. /// /// Defaults to `.pre-commit-config.yaml` unless `--format toml` is set, /// which uses `prek.toml`. If a path is provided without `--format`, /// the format is inferred from the file extension (`.toml` uses TOML). #[arg( short, long, num_args = 0..=1, )] pub(crate) file: Option>, /// Select the sample configuration format. #[arg(long, value_enum)] pub(crate) format: Option, } #[derive(Debug, Copy, Clone, clap::ValueEnum)] pub(crate) enum SampleConfigFormat { Yaml, Toml, } #[derive(Debug)] pub(crate) enum SampleConfigTarget { Stdout, DefaultFile, Path(PathBuf), } impl From>> for SampleConfigTarget { fn from(value: Option>) -> Self { match value { None => Self::Stdout, Some(None) => Self::DefaultFile, Some(Some(path)) => Self::Path(path), } } } #[derive(Debug, Args)] pub(crate) struct AutoUpdateArgs { /// Update to the bleeding edge of the default branch instead of the latest tagged version. #[arg(long)] pub(crate) bleeding_edge: bool, /// Store "frozen" hashes in `rev` instead of tag names. #[arg(long)] pub(crate) freeze: bool, /// Only update this repository. This option may be specified multiple times. #[arg(long)] pub(crate) repo: Vec, /// Do not write changes to the config file, only display what would be changed. #[arg(long)] pub(crate) dry_run: bool, /// Number of threads to use. #[arg(short, long, default_value_t = 0)] pub(crate) jobs: usize, /// Minimum release age (in days) required for a version to be eligible. /// /// The age is computed from the tag creation timestamp for annotated tags, or from the tagged commit timestamp for lightweight tags. /// A value of `0` disables this check. #[arg( long, value_name = "DAYS", default_value_t = 0, conflicts_with = "bleeding_edge" )] pub(crate) cooldown_days: u8, } #[derive(Debug, Args)] pub(crate) struct HookImplArgs { /// Include the specified hooks or projects. /// /// Supports flexible selector syntax: /// /// - `hook-id`: Run all hooks with the specified ID across all projects /// /// - `project-path/`: Run all hooks from the specified project /// /// - `project-path:hook-id`: Run only the specified hook from the specified project /// /// Can be specified multiple times to select multiple hooks/projects. #[arg( value_name = "HOOK|PROJECT", value_hint = ValueHint::Other, add = ArgValueCompleter::new(selector_completer) )] pub(crate) includes: Vec, /// Skip the specified hooks or projects. /// /// Supports flexible selector syntax: /// /// - `hook-id`: Skip all hooks with the specified ID across all projects /// /// - `project-path/`: Skip all hooks from the specified project /// /// - `project-path:hook-id`: Skip only the specified hook from the specified project /// /// Can be specified multiple times. Also accepts `PREK_SKIP` or `SKIP` environment variables (comma-delimited). #[arg(long = "skip", value_name = "HOOK|PROJECT", add = ArgValueCompleter::new(selector_completer))] pub(crate) skips: Vec, #[arg(long)] pub(crate) hook_type: HookType, #[arg(long)] pub(crate) hook_dir: PathBuf, #[arg(long)] pub(crate) skip_on_missing_config: bool, /// The prek version that installs the hook. #[arg(long)] pub(crate) script_version: Option, #[arg(last = true)] pub(crate) args: Vec, } #[derive(Debug, Args)] pub(crate) struct CacheNamespace { #[command(subcommand)] pub(crate) command: CacheCommand, } #[derive(Debug, Args)] pub(crate) struct UtilNamespace { #[command(subcommand)] pub(crate) command: UtilCommand, } #[derive(Debug, Subcommand)] pub(crate) enum UtilCommand { /// Show file identification tags. Identify(IdentifyArgs), /// List all built-in hooks bundled with prek. ListBuiltins(ListBuiltinsArgs), /// Install Git shims in a directory intended for use with `git config init.templateDir`. #[command(alias = "init-templatedir")] InitTemplateDir(InitTemplateDirArgs), /// Convert a YAML configuration file to prek.toml. YamlToToml(YamlToTomlArgs), /// Generate shell completion scripts. #[command(hide = true)] GenerateShellCompletion(GenerateShellCompletionArgs), } #[derive(Debug, Args)] pub(crate) struct YamlToTomlArgs { /// The YAML configuration file to convert. If omitted, discovers /// `.pre-commit-config.yaml` or `.pre-commit-config.yml` in the current directory. #[arg(value_name = "CONFIG", value_hint = ValueHint::FilePath)] pub(crate) input: Option, /// Path to write the generated prek.toml file. /// Defaults to `prek.toml` in the same directory as the input file. #[arg(short, long, value_name = "OUTPUT", value_hint = ValueHint::FilePath)] pub(crate) output: Option, /// Overwrite the output file if it already exists. #[arg(long)] pub(crate) force: bool, } #[derive(Debug, Subcommand)] pub(crate) enum CacheCommand { /// Show the location of the prek cache. Dir, /// Remove unused cached repositories, hook environments, and other data. GC(CacheGcArgs), /// Remove all prek cached data. Clean, /// Show the size of the prek cache. Size(SizeArgs), } #[derive(Args, Debug)] pub struct SizeArgs { /// Display the cache size in human-readable format (e.g., `1.2 GiB` instead of raw bytes). #[arg(long = "human", short = 'H', alias = "human-readable")] pub(crate) human: bool, } #[derive(Debug, Args)] pub(crate) struct CacheGcArgs { /// Print what would be removed, but do not delete anything. #[arg(long)] pub(crate) dry_run: bool, } #[derive(Debug, Args)] pub(crate) struct SelfNamespace { #[command(subcommand)] pub(crate) command: SelfCommand, } #[derive(Debug, Subcommand)] pub(crate) enum SelfCommand { /// Update prek. Update(SelfUpdateArgs), } #[derive(Debug, Args)] pub(crate) struct SelfUpdateArgs { /// Update to the specified version. /// If not provided, prek will update to the latest version. pub target_version: Option, /// A GitHub token for authentication. /// A token is not required but can be used to reduce the chance of encountering rate limits. #[arg(long, env = EnvVars::GITHUB_TOKEN)] pub token: Option, } #[derive(Debug, Args)] pub(crate) struct GenerateShellCompletionArgs { /// The shell to generate the completion script for #[arg(value_enum)] pub shell: clap_complete::Shell, } #[derive(Debug, Args)] pub(crate) struct InitTemplateDirArgs { /// The directory in which to write the Git shim. pub(crate) directory: PathBuf, /// Assume cloned repos should have a `pre-commit` config. #[arg(long)] pub(crate) no_allow_missing_config: bool, /// Which Git shim(s) to install. /// /// Specifies which Git hook type(s) you want to install shims for. /// Can be specified multiple times to install shims for multiple hook types. /// /// If not specified, uses `default_install_hook_types` from the config file, /// or defaults to `pre-commit` if that is also not set. #[arg(short = 't', long = "hook-type", value_name = "HOOK_TYPE", value_enum)] pub(crate) hook_types: Vec, } #[cfg(test)] mod _gen { use crate::cli::Cli; use anyhow::{Result, bail}; use clap::{Command, CommandFactory}; use itertools::Itertools; use prek_consts::env_vars::EnvVars; use pretty_assertions::StrComparison; use std::cmp::max; use std::path::PathBuf; const ROOT_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../"); enum Mode { /// Update the content. Write, /// Don't write to the file, check if the file is up-to-date and error if not. Check, /// Write the generated help to stdout. DryRun, } fn generate(mut cmd: Command) -> String { let mut output = String::new(); cmd.build(); let mut parents = Vec::new(); output.push_str("# CLI Reference\n\n"); generate_command(&mut output, &cmd, &mut parents); let mut output = output.replace("\r\n", "\n"); // Trim trailing whitespace while output.ends_with('\n') { output.pop(); } output.push('\n'); output } #[allow(clippy::format_push_string)] fn generate_command<'a>( output: &mut String, command: &'a Command, parents: &mut Vec<&'a Command>, ) { if command.is_hide_set() { return; } // Generate the command header. let name = if parents.is_empty() { command.get_name().to_string() } else { format!( "{} {}", parents.iter().map(|cmd| cmd.get_name()).join(" "), command.get_name() ) }; // Display the top-level `prek` command at the same level as its children let level = max(2, parents.len() + 1); output.push_str(&format!("{} {name}\n\n", "#".repeat(level))); // Display the command description. if let Some(about) = command.get_long_about().or_else(|| command.get_about()) { output.push_str(&about.to_string()); output.push_str("\n\n"); } // Display the usage { // This appears to be the simplest way to get rendered usage from Clap, // it is complicated to render it manually. It's annoying that it // requires a mutable reference but it doesn't really matter. let mut command = command.clone(); output.push_str("

Usage

\n\n"); output.push_str(&format!( "```\n{}\n```", command .render_usage() .to_string() .trim_start_matches("Usage: "), )); output.push_str("\n\n"); } // Display a list of child commands let mut subcommands = command.get_subcommands().peekable(); let has_subcommands = subcommands.peek().is_some(); if has_subcommands { output.push_str("

Commands

\n\n"); output.push_str("
"); for subcommand in subcommands { if subcommand.is_hide_set() { continue; } let subcommand_name = format!("{name} {}", subcommand.get_name()); output.push_str(&format!( "
{subcommand_name}
", subcommand_name.replace(' ', "-") )); if let Some(about) = subcommand.get_about() { output.push_str(&format!( "
{}
\n", markdown::to_html(&about.to_string()) )); } } output.push_str("
\n\n"); } // Do not display options for commands with children if !has_subcommands { let name_key = name.replace(' ', "-"); // Display positional arguments let mut arguments = command .get_positionals() .filter(|arg| !arg.is_hide_set()) .peekable(); if arguments.peek().is_some() { output.push_str("

Arguments

\n\n"); output.push_str("
"); for arg in arguments { let id = format!("{name_key}--{}", arg.get_id()); output.push_str(&format!("
")); output.push_str(&format!( "{}", arg.get_value_names() .unwrap() .iter() .next() .unwrap() .to_string() .to_uppercase(), )); output.push_str("
"); if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) { output.push_str("
"); output.push_str(&format!("{}\n", markdown::to_html(&help.to_string()))); output.push_str("
"); } } output.push_str("
\n\n"); } // Display options and flags let mut options = command .get_arguments() .filter(|arg| !arg.is_positional()) .filter(|arg| !arg.is_hide_set()) .sorted_by_key(|arg| arg.get_id()) .peekable(); if options.peek().is_some() { output.push_str("

Options

\n\n"); output.push_str("
"); for opt in options { let Some(long) = opt.get_long() else { continue }; let id = format!("{name_key}--{long}"); output.push_str(&format!("
")); output.push_str(&format!("--{long}")); for long_alias in opt.get_all_aliases().into_iter().flatten() { output.push_str(&format!(", --{long_alias}")); } if let Some(short) = opt.get_short() { output.push_str(&format!(", -{short}")); } for short_alias in opt.get_all_short_aliases().into_iter().flatten() { output.push_str(&format!(", -{short_alias}")); } // Re-implements private `Arg::is_takes_value_set` used in `Command::get_opts` if opt .get_num_args() .unwrap_or_else(|| 1.into()) .takes_values() { if let Some(values) = opt.get_value_names() { for value in values { output.push_str(&format!( " {}", value.to_lowercase().replace('_', "-") )); } } } output.push_str("
"); if let Some(help) = opt.get_long_help().or_else(|| opt.get_help()) { output.push_str("
"); output.push_str(&format!("{}\n", markdown::to_html(&help.to_string()))); emit_env_option(opt, output); emit_default_option(opt, output); emit_possible_options(opt, output); output.push_str("
"); } } output.push_str("
"); } output.push_str("\n\n"); } parents.push(command); // Recurse to all the subcommands. for subcommand in command.get_subcommands() { generate_command(output, subcommand, parents); } parents.pop(); } fn emit_env_option(opt: &clap::Arg, output: &mut String) { if opt.is_hide_env_set() { return; } if let Some(env) = opt.get_env() { output.push_str(&markdown::to_html(&format!( "May also be set with the `{}` environment variable.", env.to_string_lossy() ))); } } fn emit_default_option(opt: &clap::Arg, output: &mut String) { if opt.is_hide_default_value_set() || !opt.get_num_args().expect("built").takes_values() { return; } let values = opt.get_default_values(); if !values.is_empty() { let value = format!( "\n[default: {}]", opt.get_default_values() .iter() .map(|s| s.to_string_lossy()) .join(",") ); output.push_str(&markdown::to_html(&value)); } } fn emit_possible_options(opt: &clap::Arg, output: &mut String) { if opt.is_hide_possible_values_set() { return; } let values = opt.get_possible_values(); if !values.is_empty() { let value = format!( "\nPossible values:\n{}", values .into_iter() .filter(|value| !value.is_hide_set()) .map(|value| { let name = value.get_name(); value.get_help().map_or_else( || format!(" - `{name}`"), |help| format!(" - `{name}`: {help}"), ) }) .collect_vec() .join("\n"), ); output.push_str(&markdown::to_html(&value)); } } #[test] fn generate_cli_reference() -> Result<()> { let mode = if EnvVars::is_set(EnvVars::PREK_GENERATE) { Mode::Write } else { Mode::Check }; let reference_string = generate(Cli::command()); let filename = "cli.md"; let reference_path = PathBuf::from(ROOT_DIR).join("docs").join(filename); match mode { Mode::DryRun => { anstream::println!("{reference_string}"); } Mode::Check => match fs_err::read_to_string(&reference_path) { Ok(current) => { if current == reference_string { anstream::println!("Up-to-date: {filename}"); } else { let comparison = StrComparison::new(¤t, &reference_string); bail!( "{filename} changed, please run `mise run generate` to update:\n{comparison}" ); } } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { bail!("{filename} not found, please run `mise run generate` to generate"); } Err(err) => { bail!("{filename} changed, please run `mise run generate` to update:\n{err}"); } }, Mode::Write => match fs_err::read_to_string(&reference_path) { Ok(current) => { if current == reference_string { anstream::println!("Up-to-date: {filename}"); } else { anstream::println!("Updating: {filename}"); fs_err::write(reference_path, reference_string.as_bytes())?; } } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { anstream::println!("Updating: {filename}"); fs_err::write(reference_path, reference_string.as_bytes())?; } Err(err) => { bail!( "{filename} changed, please run `cargo dev generate-cli-reference`:\n{err}" ); } }, } Ok(()) } } ================================================ FILE: crates/prek/src/cli/reporter.rs ================================================ use std::borrow::Cow; use std::sync::{Arc, Mutex, Weak}; use std::time::Duration; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use owo_colors::OwoColorize; use rustc_hash::FxHashMap; use unicode_width::UnicodeWidthStr; use crate::hook::Hook; use crate::printer::Printer; use crate::workspace; /// Current progress reporter used to suspend rendering while printing normal output. static CURRENT_REPORTER: Mutex>> = Mutex::new(None); /// Set the current reporter for lock acquisition warnings. fn set_current_reporter(reporter: Option<&Arc>) { *CURRENT_REPORTER.lock().unwrap() = reporter.map(Arc::downgrade); } /// Suspend progress rendering while emitting normal output. /// /// If a progress reporter is currently active, this runs `f` inside /// `indicatif::MultiProgress::suspend` to avoid corrupting the progress display. /// If no reporter is active (or it has already been dropped), this just runs `f`. pub(crate) fn suspend(f: impl FnOnce() + Send + 'static) { let reporter = CURRENT_REPORTER.lock().unwrap().clone(); match reporter.and_then(|r| r.upgrade()) { Some(reporter) => reporter.children.suspend(f), None => f(), } } #[derive(Default, Debug)] struct BarState { /// A map of progress bars, by ID. bars: FxHashMap, /// A monotonic counter for bar IDs. id: usize, } impl BarState { /// Returns a unique ID for a new progress bar. fn id(&mut self) -> usize { self.id += 1; self.id } } struct ProgressReporter { printer: Printer, root: ProgressBar, state: Arc>, children: MultiProgress, } impl ProgressReporter { fn new(root: ProgressBar, children: MultiProgress, printer: Printer) -> Self { Self { printer, root, state: Arc::default(), children, } } fn on_start(&self, msg: impl Into>) -> usize { let mut state = self.state.lock().unwrap(); let id = state.id(); let progress = self.children.insert_before( &self.root, ProgressBar::with_draw_target(None, self.printer.target()), ); progress.set_style(ProgressStyle::with_template("{wide_msg}").unwrap()); progress.set_message(msg); state.bars.insert(id, progress); id } fn on_progress(&self, id: usize) { let progress = { let mut state = self.state.lock().unwrap(); state.bars.remove(&id).unwrap() }; self.root.inc(1); progress.finish_and_clear(); } fn on_complete(&self) { self.root.set_message(""); self.root.finish_and_clear(); } } impl From for ProgressReporter { fn from(printer: Printer) -> Self { let multi = MultiProgress::with_draw_target(printer.target()); let root = multi.add(ProgressBar::with_draw_target(None, printer.target())); root.enable_steady_tick(Duration::from_millis(200)); root.set_style( ProgressStyle::with_template("{spinner:.white} {msg:.dim}") .unwrap() .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), ); Self::new(root, multi, printer) } } pub(crate) struct HookInitReporter { reporter: Arc, } impl HookInitReporter { pub(crate) fn new(printer: Printer) -> Self { let reporter = Arc::new(ProgressReporter::from(printer)); set_current_reporter(Some(&reporter)); Self { reporter } } } impl workspace::HookInitReporter for HookInitReporter { fn on_clone_start(&self, repo: &str) -> usize { self.reporter .root .set_message(format!("{}", "Cloning repos...".bold().cyan())); self.reporter .on_start(format!("{} {}", "Cloning".bold().cyan(), repo.dimmed())) } fn on_clone_complete(&self, id: usize) { self.reporter.on_progress(id); } fn on_complete(&self) { self.reporter.on_complete(); } } pub(crate) struct HookInstallReporter { reporter: Arc, } impl HookInstallReporter { pub(crate) fn new(printer: Printer) -> Self { let reporter = Arc::new(ProgressReporter::from(printer)); set_current_reporter(Some(&reporter)); Self { reporter } } } impl HookInstallReporter { pub fn on_install_start(&self, hook: &Hook) -> usize { self.reporter .root .set_message(format!("{}", "Installing hooks...".bold().cyan())); self.reporter.on_start(format!( "{} {}", "Installing".bold().cyan(), hook.id.dimmed(), )) } pub fn on_install_complete(&self, id: usize) { self.reporter.on_progress(id); } pub fn on_complete(&self) { self.reporter.on_complete(); } } pub(crate) struct HookRunReporter { reporter: Arc, dots: usize, } impl HookRunReporter { pub fn new(printer: Printer, dots: usize) -> Self { let reporter = Arc::new(ProgressReporter::from(printer)); set_current_reporter(Some(&reporter)); Self { reporter, dots } } pub fn on_run_start(&self, hook: &Hook, len: usize) -> usize { self.reporter .root .set_message(format!("{}", "Running hooks...".bold().cyan())); let mut state = self.reporter.state.lock().unwrap(); let id = state.id(); // len == 0 indicates an unknown length; use 1 to show an indeterminate bar. let len = if len == 0 { 1 } else { len }; let progress = self.reporter.children.insert_before( &self.reporter.root, ProgressBar::with_draw_target(Some(len as u64), self.reporter.printer.target()), ); let dots = self.dots.saturating_sub(hook.name.width()); progress.enable_steady_tick(Duration::from_millis(200)); progress.set_style( ProgressStyle::with_template(&format!("{{msg}}{{bar:{dots}.green/dim}}")) .unwrap() .progress_chars(".."), ); progress.set_message(hook.name.clone()); state.bars.insert(id, progress); id } pub fn on_run_progress(&self, id: usize, completed: u64) { let state = self.reporter.state.lock().unwrap(); let progress = &state.bars[&id]; progress.inc(completed); } pub fn on_run_complete(&self, id: usize) { let progress = { let mut state = self.reporter.state.lock().unwrap(); state.bars.remove(&id).unwrap() }; self.reporter.root.inc(1); // Clear the running line; final output is printed by the caller. progress.finish_and_clear(); } /// Temporarily suspend progress rendering while emitting normal output. /// /// This helps prevent the progress UI from being corrupted by concurrent writes. pub fn suspend(&self, f: impl FnOnce() -> R) -> R { self.reporter.children.suspend(f) } pub fn on_complete(&self) { self.reporter.on_complete(); } } #[derive(Clone)] pub(crate) struct AutoUpdateReporter { reporter: Arc, } impl AutoUpdateReporter { pub(crate) fn new(printer: Printer) -> Self { let reporter = Arc::new(ProgressReporter::from(printer)); set_current_reporter(Some(&reporter)); Self { reporter } } } impl AutoUpdateReporter { pub fn on_update_start(&self, repo: &str) -> usize { self.reporter .root .set_message(format!("{}", "Updating repos...".bold().cyan())); self.reporter .on_start(format!("{} {}", "Updating".bold().cyan(), repo.dimmed())) } pub fn on_update_complete(&self, id: usize) { self.reporter.on_progress(id); } pub fn on_complete(&self) { self.reporter.on_complete(); } } #[derive(Debug)] pub(crate) struct CleaningReporter { bar: ProgressBar, } impl CleaningReporter { pub(crate) fn new(printer: Printer, max: usize) -> Self { let bar = ProgressBar::with_draw_target(Some(max as u64), printer.target()); bar.set_style( ProgressStyle::with_template("{prefix} [{bar:20}] {percent}%") .unwrap() .progress_chars("=> "), ); bar.set_prefix(format!("{}", "Cleaning".bold().cyan())); Self { bar } } } impl CleaningReporter { pub(crate) fn on_clean(&self) { self.bar.inc(1); } pub(crate) fn on_complete(&self) { self.bar.finish_and_clear(); } } ================================================ FILE: crates/prek/src/cli/run/filter.rs ================================================ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use itertools::{Either, Itertools}; use path_clean::PathClean; use prek_consts::env_vars::EnvVars; use prek_identify::{TagSet, tags_from_path}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rustc_hash::FxHashSet; use tracing::{debug, error, instrument}; use crate::config::{FilePattern, Stage}; use crate::git::GIT_ROOT; use crate::hook::Hook; use crate::workspace::Project; use crate::{fs, git, warn_user}; /// Filter filenames by include/exclude patterns. pub(crate) struct FilenameFilter<'a> { include: Option<&'a FilePattern>, exclude: Option<&'a FilePattern>, } impl<'a> FilenameFilter<'a> { pub(crate) fn new(include: Option<&'a FilePattern>, exclude: Option<&'a FilePattern>) -> Self { Self { include, exclude } } pub(crate) fn filter(&self, filename: &Path) -> bool { let Some(filename) = filename.to_str() else { return false; }; if let Some(pattern) = &self.include { if !pattern.is_match(filename) { return false; } } if let Some(pattern) = &self.exclude { if pattern.is_match(filename) { return false; } } true } } /// Filter files by tags. pub(crate) struct FileTagFilter<'a> { all: Option<&'a TagSet>, any: Option<&'a TagSet>, exclude: Option<&'a TagSet>, } impl<'a> FileTagFilter<'a> { fn new( types: Option<&'a TagSet>, types_or: Option<&'a TagSet>, exclude_types: Option<&'a TagSet>, ) -> Self { Self { all: types, any: types_or, exclude: exclude_types, } } pub(crate) fn filter(&self, file_types: &TagSet) -> bool { if self.all.is_some_and(|s| !s.is_subset(file_types)) { return false; } if self .any .is_some_and(|s| !s.is_empty() && s.is_disjoint(file_types)) { return false; } if self.exclude.is_some_and(|s| !s.is_disjoint(file_types)) { return false; } true } } pub(crate) struct FileFilter<'a> { filenames: Vec<&'a Path>, filename_prefix: &'a Path, } impl<'a> FileFilter<'a> { /// Create a `FileFilter` for a project by filtering the input filenames with the project's relative path and include/exclude patterns. /// `filenames` are paths relative to the workspace root. #[instrument(level = "trace", skip_all, fields(project = %project))] pub(crate) fn for_project( filenames: I, project: &'a Project, mut consumed_files: Option<&mut FxHashSet<&'a Path>>, ) -> Self where I: Iterator + Send, { let filter = FilenameFilter::new( project.config().files.as_ref(), project.config().exclude.as_ref(), ); let orphan = project.config().orphan.unwrap_or(false); // The order of below filters matters. // If this is an orphan project, we must mark all files in its directory as consumed // *before* applying the project's include/exclude patterns. This ensures that even // files excluded by this project are still considered "owned" by it and hidden // from parent projects. let filenames = filenames .map(PathBuf::as_path) // Collect files that are inside the hook project directory. .filter(|filename| filename.starts_with(project.relative_path())) // Skip files that have already been consumed by subprojects. .filter(|filename| { if let Some(consumed_files) = consumed_files.as_mut() { if orphan { return consumed_files.insert(filename); } !consumed_files.contains(filename) } else { true } }) // Strip the project-relative prefix before applying project-level include/exclude patterns. .filter(|filename| { let relative = filename .strip_prefix(project.relative_path()) .expect("Filename should start with project relative path"); filter.filter(relative) }) .collect::>(); Self { filenames, filename_prefix: project.relative_path(), } } pub(crate) fn len(&self) -> usize { self.filenames.len() } /// Filter filenames by type tags for a specific hook. pub(crate) fn by_type( &self, types: Option<&TagSet>, types_or: Option<&TagSet>, exclude_types: Option<&TagSet>, ) -> Vec<&Path> { let filter = FileTagFilter::new(types, types_or, exclude_types); let filenames: Vec<_> = self .filenames .par_iter() .filter(|filename| match tags_from_path(filename) { Ok(tags) => filter.filter(&tags), Err(err) => { error!(filename = ?filename.display(), error = %err, "Failed to get tags"); false } }) .copied() .collect(); filenames } /// Filter filenames by file patterns and tags for a specific hook. #[instrument(level = "trace", skip_all, fields(hook = ?hook.id))] pub(crate) fn for_hook(&self, hook: &Hook) -> Vec<&Path> { // Filter by hook `files` and `exclude` patterns. let filter = FilenameFilter::new(hook.files.as_ref(), hook.exclude.as_ref()); let filenames = self.filenames.par_iter().filter(|filename| { // Strip the project-relative prefix before applying hook-level include/exclude patterns. if let Ok(relative) = filename.strip_prefix(self.filename_prefix) { filter.filter(relative) } else { false } }); // Filter by hook `types`, `types_or` and `exclude_types`. let filter = FileTagFilter::new( Some(&hook.types), Some(&hook.types_or), Some(&hook.exclude_types), ); let filenames = filenames.filter(|filename| match tags_from_path(filename) { Ok(tags) => filter.filter(&tags), Err(err) => { error!(filename = ?filename.display(), error = %err, "Failed to get tags"); false } }); // Strip the prefix to get relative paths. let filenames: Vec<_> = filenames .map(|p| { p.strip_prefix(self.filename_prefix) .expect("Filename should start with project relative path") }) .collect(); filenames } } #[derive(Default)] pub(crate) struct CollectOptions { pub(crate) hook_stage: Stage, pub(crate) from_ref: Option, pub(crate) to_ref: Option, pub(crate) all_files: bool, pub(crate) files: Vec, pub(crate) directories: Vec, pub(crate) commit_msg_filename: Option, } impl CollectOptions { pub(crate) fn all_files() -> Self { Self { all_files: true, ..Default::default() } } } /// Get all filenames to run hooks on. /// Returns a list of file paths relative to the workspace root. #[instrument(level = "trace", skip_all)] pub(crate) async fn collect_files(root: &Path, opts: CollectOptions) -> Result> { let CollectOptions { hook_stage, from_ref, to_ref, all_files, files, directories, commit_msg_filename, } = opts; let git_root = GIT_ROOT.as_ref()?; // The workspace root relative to the git root. let relative_root = root.strip_prefix(git_root).with_context(|| { format!( "Workspace root `{}` is not under git root `{}`", root.display(), git_root.display() ) })?; let filenames = collect_files_from_args( git_root, root, hook_stage, from_ref, to_ref, all_files, files, directories, commit_msg_filename, ) .await?; // Convert filenames to be relative to the workspace root. let mut filenames = filenames .into_iter() .filter_map(|filename| { // Only keep files under the workspace root. filename .strip_prefix(relative_root) .map(|p| fs::normalize_path(p.to_path_buf())) .ok() }) .collect::>(); // Sort filenames if in tests to make the order consistent. if EnvVars::is_set(EnvVars::PREK_INTERNAL__SORT_FILENAMES) { filenames.sort_unstable(); } Ok(filenames) } fn adjust_relative_path(path: &str, new_cwd: &Path) -> Result { let absolute = std::path::absolute(path)?.clean(); fs::relative_to(absolute, new_cwd) } /// Collect files to run hooks on. /// Returns a list of file paths relative to the git root. #[allow(clippy::too_many_arguments)] async fn collect_files_from_args( git_root: &Path, workspace_root: &Path, hook_stage: Stage, from_ref: Option, to_ref: Option, all_files: bool, files: Vec, directories: Vec, commit_msg_filename: Option, ) -> Result> { if !hook_stage.operate_on_files() { return Ok(vec![]); } if hook_stage == Stage::PrepareCommitMsg || hook_stage == Stage::CommitMsg { let path = commit_msg_filename.expect("commit_msg_filename should be set"); let path = adjust_relative_path(&path, git_root)?; return Ok(vec![path]); } if let (Some(from_ref), Some(to_ref)) = (from_ref, to_ref) { let files = git::get_changed_files(&from_ref, &to_ref, workspace_root).await?; debug!( "Files changed between {} and {}: {}", from_ref, to_ref, files.len() ); return Ok(files); } if !files.is_empty() || !directories.is_empty() { // By default, `pre-commit` add `types: [file]` for all hooks, // so `pre-commit` will ignore user provided directories. // We do the same here for compatibility. // For `types: [directory]`, `pre-commit` passes the directory names to the hook directly. // Fun fact: if a hook specified `types: [directory]`, it won't run in `--all-files` mode. let (exists, non_exists): (FxHashSet<_>, Vec<_>) = files.into_iter().partition_map(|filename| { if std::fs::exists(&filename).unwrap_or(false) { Either::Left(filename) } else { Either::Right(filename) } }); if !non_exists.is_empty() { if non_exists.len() == 1 { warn_user!( "This file does not exist and will be ignored: `{}`", non_exists[0] ); } else { warn_user!( "These files do not exist and will be ignored: `{}`", non_exists.join(", ") ); } } let mut exists = exists .into_iter() .map(|filename| adjust_relative_path(&filename, git_root).map(fs::normalize_path)) .collect::, _>>()?; for dir in directories { let dir = adjust_relative_path(&dir, git_root)?; let dir_files = git::ls_files(git_root, &dir).await?; for file in dir_files { let file = fs::normalize_path(file); exists.insert(file); } } debug!("Files passed as arguments: {}", exists.len()); return Ok(exists.into_iter().collect()); } if all_files { let files = git::ls_files(git_root, workspace_root).await?; debug!("All files in the workspace: {}", files.len()); return Ok(files); } if git::is_in_merge_conflict().await? { let files = git::get_conflicted_files(workspace_root).await?; debug!("Conflicted files: {}", files.len()); return Ok(files); } let files = git::get_staged_files(workspace_root).await?; debug!("Staged files: {}", files.len()); Ok(files) } #[cfg(test)] mod tests { use super::*; use crate::config::GlobPatterns; fn glob_pattern(pattern: &str) -> FilePattern { FilePattern::Glob(GlobPatterns::new(vec![pattern.to_string()]).unwrap()) } #[test] fn filename_filter_supports_glob_include_and_exclude() { let include = glob_pattern("src/**/*.rs"); let exclude = glob_pattern("src/**/ignored.rs"); let filter = FilenameFilter::new(Some(&include), Some(&exclude)); assert!(filter.filter(Path::new("src/lib/main.rs"))); assert!(!filter.filter(Path::new("src/lib/ignored.rs"))); assert!(!filter.filter(Path::new("tests/main.rs"))); } } ================================================ FILE: crates/prek/src/cli/run/keeper.rs ================================================ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Mutex; use anstream::eprintln; use anyhow::Result; use owo_colors::OwoColorize; use tracing::{debug, error, trace}; use prek_consts::env_vars::EnvVars; use crate::cleanup::add_cleanup; use crate::fs::Simplified; use crate::git::{self, GIT, git_cmd}; use crate::store::Store; static RESTORE_WORKTREE: Mutex> = Mutex::new(None); struct IntentToAddKeeper(Vec); struct WorkingTreeKeeper { root: PathBuf, patch: Option, } impl IntentToAddKeeper { async fn clean(root: &Path) -> Result { let files = git::intent_to_add_files(root).await?; if files.is_empty() { return Ok(Self(vec![])); } // TODO: xargs git_cmd("git rm")? .arg("rm") .arg("--cached") .arg("--") .args(&files) .check(true) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .await?; Ok(Self(files)) } fn restore(&self) -> Result<()> { // Restore the intent-to-add changes. if !self.0.is_empty() { Command::new(GIT.as_ref()?) .arg("add") .arg("--intent-to-add") .arg("--") // TODO: xargs .args(&self.0) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status()?; } Ok(()) } } impl Drop for IntentToAddKeeper { fn drop(&mut self) { if let Err(err) = self.restore() { eprintln!( "{}", format!("Failed to restore intent-to-add changes: {err}").red() ); } } } impl WorkingTreeKeeper { async fn clean(root: &Path, patch_dir: &Path) -> Result { let tree = git::write_tree().await?; let mut cmd = git_cmd("git diff-index")?; let output = cmd .arg("diff-index") .arg("--ignore-submodules") .arg("--binary") .arg("--exit-code") .arg("--no-color") .arg("--no-ext-diff") .arg(tree) .arg("--") .arg(root) .check(false) .output() .await?; if output.status.success() { debug!("Working tree is clean"); // No non-staged changes Ok(Self { root: root.to_path_buf(), patch: None, }) } else if output.status.code() == Some(1) { if output.stdout.trim_ascii().is_empty() { trace!("diff-index status code 1 with empty stdout"); // probably git auto crlf behavior quirks Ok(Self { root: root.to_path_buf(), patch: None, }) } else { let now = std::time::SystemTime::now(); let pid = std::process::id(); let patch_name = format!( "{}-{}.patch", now.duration_since(std::time::UNIX_EPOCH)?.as_millis(), pid ); let patch_path = patch_dir.join(&patch_name); debug!("Unstaged changes detected"); eprintln!( "{}", format!( "Unstaged changes detected, stashing unstaged changes to `{}`", patch_path.user_display() ) .yellow() .bold() ); fs_err::create_dir_all(patch_dir)?; fs_err::write(&patch_path, output.stdout)?; // Clean the working tree debug!("Cleaning working tree"); Self::checkout_working_tree(root)?; Ok(Self { root: root.to_path_buf(), patch: Some(patch_path), }) } } else { Err(cmd.check_status(output.status).unwrap_err().into()) } } fn checkout_working_tree(root: &Path) -> Result<()> { let output = Command::new(GIT.as_ref()?) .arg("-c") .arg("submodule.recurse=0") .arg("checkout") .arg("--") .arg(root) // prevent recursive post-checkout hooks .env(EnvVars::PREK_INTERNAL__SKIP_POST_CHECKOUT, "1") .output()?; if output.status.success() { Ok(()) } else { Err(anyhow::anyhow!( "Failed to checkout working tree: {output:?}" )) } } fn git_apply(patch: &Path) -> Result<()> { let output = Command::new(GIT.as_ref()?) .arg("apply") .arg("--whitespace=nowarn") .arg(patch) .output()?; if output.status.success() { Ok(()) } else { Err(anyhow::anyhow!("Failed to apply the patch: {output:?}")) } } fn restore(&self) -> Result<()> { let Some(patch) = self.patch.as_ref() else { return Ok(()); }; // Try to apply the patch if let Err(e) = Self::git_apply(patch) { error!("{e}"); eprintln!( "{}", "Stashed changes conflicted with changes made by hook, rolling back the hook changes".red().bold() ); // Discard any changes made by hooks, and try applying the patch again. Self::checkout_working_tree(&self.root)?; Self::git_apply(patch)?; } eprintln!( "{}", format!( "Restored working tree changes from `{}`", patch.user_display() ) .yellow() .bold() ); Ok(()) } } impl Drop for WorkingTreeKeeper { fn drop(&mut self) { if let Err(err) = self.restore() { eprintln!( "{}", format!("Failed to restore working tree changes: {err}").red() ); } } } /// Clean Git intent-to-add files and working tree changes, and restore them when dropped. pub struct WorkTreeKeeper { intent_to_add: Option, working_tree: Option, } #[derive(Default)] pub struct RestoreGuard { _guard: (), } impl Drop for RestoreGuard { fn drop(&mut self) { if let Some(mut keeper) = RESTORE_WORKTREE.lock().unwrap().take() { keeper.restore(); } } } impl WorkTreeKeeper { /// Clear intent-to-add changes from the index and clear the non-staged changes from the working directory. /// Restore them when the instance is dropped. pub async fn clean(store: &Store, root: &Path) -> Result { let cleaner = Self { intent_to_add: Some(IntentToAddKeeper::clean(root).await?), working_tree: Some(WorkingTreeKeeper::clean(root, &store.patches_dir()).await?), }; // Set to the global for the cleanup hook. *RESTORE_WORKTREE.lock().unwrap() = Some(cleaner); // Make sure restoration when ctrl-c is pressed. add_cleanup(|| { if let Some(guard) = &mut *RESTORE_WORKTREE.lock().unwrap() { guard.restore(); } }); Ok(RestoreGuard::default()) } /// Restore the intent-to-add changes and non-staged changes. fn restore(&mut self) { self.intent_to_add.take(); self.working_tree.take(); } } ================================================ FILE: crates/prek/src/cli/run/mod.rs ================================================ pub(crate) use filter::{CollectOptions, FileFilter, collect_files}; pub(crate) use run::{install_hooks, run}; pub(crate) use selector::{SelectorSource, Selectors}; mod filter; mod keeper; #[allow(clippy::module_inception)] mod run; mod selector; ================================================ FILE: crates/prek/src/cli/run/run.rs ================================================ use std::fmt::Write as _; use std::io::Write as _; use std::path::PathBuf; use std::rc::Rc; use std::sync::{Arc, LazyLock}; use anyhow::{Context, Result}; use futures::stream::{FuturesUnordered, StreamExt}; use mea::once::OnceCell; use mea::semaphore::Semaphore; use owo_colors::OwoColorize; use prek_consts::env_vars::EnvVars; use prek_consts::{PRE_COMMIT_CONFIG_YAML, PREK_TOML}; use rand::SeedableRng; use rand::prelude::{SliceRandom, StdRng}; use rustc_hash::{FxHashMap, FxHashSet}; use tracing::{debug, trace, warn}; use unicode_width::UnicodeWidthStr; use crate::cli::reporter::{HookInitReporter, HookInstallReporter, HookRunReporter}; use crate::cli::run::keeper::WorkTreeKeeper; use crate::cli::run::{CollectOptions, FileFilter, Selectors, collect_files}; use crate::cli::{ExitStatus, RunExtraArgs}; use crate::config::{Language, PassFilenames, Stage}; use crate::fs::CWD; use crate::git::GIT_ROOT; use crate::hook::{Hook, InstallInfo, InstalledHook, Repo}; use crate::printer::Printer; use crate::run::{CONCURRENCY, USE_COLOR}; use crate::store::Store; use crate::workspace::{Project, Workspace}; use crate::{git, warn_user}; #[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] pub(crate) async fn run( store: &Store, config: Option, includes: Vec, skips: Vec, hook_stage: Option, from_ref: Option, to_ref: Option, all_files: bool, files: Vec, directories: Vec, last_commit: bool, show_diff_on_failure: bool, fail_fast: bool, dry_run: bool, refresh: bool, extra_args: RunExtraArgs, verbose: bool, printer: Printer, ) -> Result { // Convert `--last-commit` to `HEAD~1..HEAD` let (from_ref, to_ref) = if last_commit { (Some("HEAD~1".to_string()), Some("HEAD".to_string())) } else { (from_ref, to_ref) }; // Prevent recursive post-checkout hooks. if hook_stage == Some(Stage::PostCheckout) && EnvVars::is_set(EnvVars::PREK_INTERNAL__SKIP_POST_CHECKOUT) { return Ok(ExitStatus::Success); } // Ensure we are in a git repository. LazyLock::force(&GIT_ROOT).as_ref()?; let should_stash = !all_files && files.is_empty() && directories.is_empty(); // Check if we have unresolved merge conflict files and fail fast. if should_stash && git::has_unmerged_paths().await? { anyhow::bail!("You have unmerged paths. Resolve them before running prek"); } let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?; let selectors = Selectors::load(&includes, &skips, &workspace_root)?; let mut workspace = Workspace::discover(store, workspace_root, config, Some(&selectors), refresh)?; if should_stash { workspace.check_configs_staged().await?; } let reporter = HookInitReporter::new(printer); let lock = store.lock_async().await?; store.track_configs(workspace.projects().iter().map(|p| p.config_file()))?; let hooks = workspace .init_hooks(store, Some(&reporter)) .await .context("Failed to init hooks")?; let selected_hooks: Vec<_> = hooks .into_iter() .filter(|h| selectors.matches_hook(h)) .map(Arc::new) .collect(); selectors.report_unused(); if selected_hooks.is_empty() { writeln!( printer.stderr(), "{}: No hooks found after filtering with the given selectors", "error".red().bold(), )?; if selectors.has_project_selectors() { writeln!( printer.stderr(), "\n{} If you just added a new `{}` or `{}`, try rerunning your command with the `{}` flag to rescan the workspace.", "hint:".bold().yellow(), PREK_TOML.cyan(), PRE_COMMIT_CONFIG_YAML.cyan(), "--refresh".cyan(), )?; } return Ok(ExitStatus::Failure); } let (filtered_hooks, hook_stage) = if let Some(hook_stage) = hook_stage { let hooks = selected_hooks .iter() .filter(|h| h.stages.contains(hook_stage)) .cloned() .collect::>(); (hooks, hook_stage) } else { // Try filtering by `pre-commit` stage first. let mut hook_stage = Stage::PreCommit; let mut hooks = selected_hooks .iter() .filter(|h| h.stages.contains(Stage::PreCommit)) .cloned() .collect::>(); if hooks.is_empty() && selectors.includes_only_hook_targets() { // If no hooks found for `pre-commit` stage, try fallback to `manual` stage for hooks specified directly. hook_stage = Stage::Manual; hooks = selected_hooks .iter() .filter(|h| h.stages.contains(Stage::Manual)) .cloned() .collect(); } (hooks, hook_stage) }; if filtered_hooks.is_empty() { debug!( stage = %hook_stage, "No hooks found for stage after filtering, exit early" ); return Ok(ExitStatus::Success); } debug!( "Hooks going to run: {:?}", filtered_hooks.iter().map(|h| &h.id).collect::>() ); let reporter = HookInstallReporter::new(printer); let installed_hooks = install_hooks(filtered_hooks, store, &reporter).await?; // Release the store lock. drop(lock); // Clear any unstaged changes from the git working directory. let mut _guard = None; if should_stash { _guard = Some( WorkTreeKeeper::clean(store, workspace.root()) .await .context("Failed to clean work tree")?, ); } set_env_vars(from_ref.as_ref(), to_ref.as_ref(), &extra_args); let filenames = collect_files( workspace.root(), CollectOptions { hook_stage, from_ref, to_ref, all_files, files, directories, commit_msg_filename: extra_args.commit_msg_filename, }, ) .await .context("Failed to collect files")?; // Change to the workspace root directory. std::env::set_current_dir(workspace.root()).with_context(|| { format!( "Failed to change directory to `{}`", workspace.root().display() ) })?; run_hooks( &workspace, &installed_hooks, filenames, store, show_diff_on_failure, fail_fast, dry_run, verbose, printer, ) .await } // `pre-commit` sets these environment variables for other git hooks. fn set_env_vars(from_ref: Option<&String>, to_ref: Option<&String>, args: &RunExtraArgs) { unsafe { std::env::set_var("PRE_COMMIT", "1"); if let Some(source) = &args.prepare_commit_message_source { std::env::set_var("PRE_COMMIT_COMMIT_MSG_SOURCE", source); } if let Some(object) = &args.commit_object_name { std::env::set_var("PRE_COMMIT_COMMIT_OBJECT_NAME", object); } if let Some(from_ref) = from_ref { std::env::set_var("PRE_COMMIT_ORIGIN", from_ref); std::env::set_var("PRE_COMMIT_FROM_REF", from_ref); } if let Some(to_ref) = to_ref { std::env::set_var("PRE_COMMIT_SOURCE", to_ref); std::env::set_var("PRE_COMMIT_TO_REF", to_ref); } if let Some(upstream) = &args.pre_rebase_upstream { std::env::set_var("PRE_COMMIT_PRE_REBASE_UPSTREAM", upstream); } if let Some(branch) = &args.pre_rebase_branch { std::env::set_var("PRE_COMMIT_PRE_REBASE_BRANCH", branch); } if let Some(branch) = &args.local_branch { std::env::set_var("PRE_COMMIT_LOCAL_BRANCH", branch); } if let Some(branch) = &args.remote_branch { std::env::set_var("PRE_COMMIT_REMOTE_BRANCH", branch); } if let Some(name) = &args.remote_name { std::env::set_var("PRE_COMMIT_REMOTE_NAME", name); } if let Some(url) = &args.remote_url { std::env::set_var("PRE_COMMIT_REMOTE_URL", url); } if let Some(checkout) = &args.checkout_type { std::env::set_var("PRE_COMMIT_CHECKOUT_TYPE", checkout); } if args.is_squash_merge { std::env::set_var("PRE_COMMIT_SQUASH_MERGE", "1"); } if let Some(command) = &args.rewrite_command { std::env::set_var("PRE_COMMIT_REWRITE_COMMAND", command); } } } #[derive(Debug)] struct LazyInstallInfo { info: Arc, health: OnceCell, } impl LazyInstallInfo { fn new(info: Arc) -> Self { Self { info, health: OnceCell::new(), } } fn matches(&self, hook: &Hook) -> bool { self.info.matches(hook) } fn info(&self) -> Arc { self.info.clone() } async fn ensure_healthy(&self) -> bool { let info = self.info.clone(); *self .health .get_or_init(async move || match info.check_health().await { Ok(()) => true, Err(err) => { warn!( %err, path = %info.env_path.display(), "Skipping unhealthy installed hook" ); false } }) .await } } pub async fn install_hooks( hooks: Vec>, store: &Store, reporter: &HookInstallReporter, ) -> Result> { let num_hooks = hooks.len(); let mut result = Vec::with_capacity(hooks.len()); let store_hooks = Rc::new( store .installed_hooks() .await .into_iter() .map(LazyInstallInfo::new) .collect::>(), ); // Group hooks by language to enable parallel installation across different languages. let mut hooks_by_language = FxHashMap::default(); for hook in hooks { let mut language = hook.language; if hook.language == Language::Pygrep { // Treat `pygrep` hooks as `python` hooks for installation purposes. // They share the same installation logic. language = Language::Python; } hooks_by_language .entry(language) .or_insert_with(Vec::new) .push(hook); } let mut futures = FuturesUnordered::new(); let semaphore = Rc::new(Semaphore::new(*CONCURRENCY)); for (_, hooks) in hooks_by_language { let partitions = partition_hooks(&hooks); for hooks in partitions { let semaphore = Rc::clone(&semaphore); let store_hooks = Rc::clone(&store_hooks); futures.push(async move { let mut hook_envs = Vec::with_capacity(hooks.len()); let mut newly_installed = Vec::new(); for hook in hooks { if matches!(hook.repo(), Repo::Meta { .. } | Repo::Builtin { .. }) { debug!( "Hook `{}` is a meta or builtin hook, no installation needed", &hook ); hook_envs.push(InstalledHook::NoNeedInstall(hook)); continue; } let mut matched_info = None; for env in &newly_installed { if let InstalledHook::Installed { info, .. } = env { if info.matches(&hook) { matched_info = Some(info.clone()); break; } } } if matched_info.is_none() { for env in store_hooks.iter() { if env.matches(&hook) { if env.ensure_healthy().await { matched_info = Some(env.info()); break; } } } } if let Some(info) = matched_info { debug!( "Found installed environment for hook `{hook}` at `{}`", info.env_path.display() ); hook_envs.push(InstalledHook::Installed { hook, info }); continue; } let _permit = semaphore.acquire(1).await; let installed_hook = hook .language .install(hook.clone(), store, reporter) .await .with_context(|| format!("Failed to install hook `{hook}`"))?; installed_hook .mark_as_installed(store) .await .with_context(|| format!("Failed to mark hook `{hook}` as installed"))?; match &installed_hook { InstalledHook::Installed { info, .. } => { debug!("Installed hook `{hook}` in `{}`", info.env_path.display()); } InstalledHook::NoNeedInstall { .. } => { debug!("Hook `{hook}` does not need installation"); } } newly_installed.push(installed_hook); } // Add newly installed hooks to the list. hook_envs.extend(newly_installed); anyhow::Ok(hook_envs) }); } } while let Some(hooks) = futures.next().await { result.extend(hooks?); } reporter.on_complete(); debug_assert_eq!( num_hooks, result.len(), "Number of hooks installed should match the number of hooks provided" ); Ok(result) } /// Partition hooks into groups where hooks in the same group have same dependencies. /// Hooks in different groups can be installed in parallel. fn partition_hooks(hooks: &[Arc]) -> Vec>> { if hooks.is_empty() { return vec![]; } let n = hooks.len(); let mut visited = vec![false; n]; let mut groups = Vec::new(); // DFS to find all connected sets #[allow(clippy::items_after_statements)] fn dfs( index: usize, hooks: &[Arc], visited: &mut [bool], current_group: &mut Vec, ) { visited[index] = true; current_group.push(index); for i in 0..hooks.len() { if !visited[i] && hooks[index].env_key_dependencies() == hooks[i].env_key_dependencies() { dfs(i, hooks, visited, current_group); } } } // Find all connected components for i in 0..n { if !visited[i] { let mut current_group = Vec::new(); dfs(i, hooks, &mut visited, &mut current_group); // Convert indices back to actual sets let group_sets: Vec> = current_group .into_iter() .map(|idx| hooks[idx].clone()) .collect(); groups.push(group_sets); } } groups } struct StatusPrinter { printer: Printer, columns: usize, } impl StatusPrinter { const PASSED: &'static str = "Passed"; const FAILED: &'static str = "Failed"; const SKIPPED: &'static str = "Skipped"; const DRY_RUN: &'static str = "Dry Run"; const NO_FILES: &'static str = "(no files to check)"; const UNIMPLEMENTED: &'static str = "(unimplemented yet)"; fn for_hooks(hooks: &[InstalledHook], printer: Printer) -> Self { let name_len = hooks .iter() .map(|hook| hook.name.width()) .max() .unwrap_or(0); let columns = std::cmp::max( 79, // Hook name...(no files to check)Skipped name_len + 3 + Self::NO_FILES.len() + Self::SKIPPED.len(), ); Self { printer, columns } } fn printer(&self) -> Printer { self.printer } fn bar_len(&self) -> usize { self.columns - Self::PASSED.len() } fn write( &self, hook_name: &str, prefix: &str, status: RunStatus, ) -> Result<(), std::fmt::Error> { let (suffix, status_line, status_width) = match status { RunStatus::NoFiles => ( Self::NO_FILES, Self::SKIPPED.black().on_cyan().to_string(), Self::SKIPPED.width(), ), RunStatus::Unimplemented => ( Self::UNIMPLEMENTED, Self::SKIPPED.black().on_yellow().to_string(), Self::SKIPPED.width(), ), RunStatus::DryRun => ( "", Self::DRY_RUN.on_yellow().to_string(), Self::DRY_RUN.width(), ), RunStatus::Success => ( "", Self::PASSED.on_green().to_string(), Self::PASSED.width(), ), RunStatus::Failed => ("", Self::FAILED.on_red().to_string(), Self::FAILED.width()), }; let (prefix, prefix_width) = if prefix.is_empty() { (String::new(), 0) } else { (prefix.dimmed().to_string(), prefix.width()) }; let used_width = prefix_width + hook_name.width() + suffix.width() + status_width; let dots = self.columns.saturating_sub(used_width); let line = format!( "{prefix}{hook_name}{}{suffix}{status_line}", ".".repeat(dots), ); match status { RunStatus::Failed => { writeln!(self.printer.stdout_important(), "{line}") } _ => writeln!(self.printer.stdout(), "{line}"), } } } /// Run all hooks. #[allow(clippy::fn_params_excessive_bools)] async fn run_hooks( workspace: &Workspace, hooks: &[InstalledHook], filenames: Vec, store: &Store, show_diff_on_failure: bool, fail_fast: bool, dry_run: bool, verbose: bool, printer: Printer, ) -> Result { debug_assert!(!hooks.is_empty(), "No hooks to run"); let status_printer = StatusPrinter::for_hooks(hooks, printer); let reporter = HookRunReporter::new(printer, status_printer.bar_len()); let mut success = true; // Group hooks by project to run them in order of their depth in the workspace. #[allow(clippy::mutable_key_type)] let mut project_to_hooks: FxHashMap<&Project, Vec> = FxHashMap::default(); for hook in hooks { project_to_hooks .entry(hook.project()) .or_default() .push(hook.clone()); } let projects_len = project_to_hooks.len(); let mut first = true; let mut file_modified = false; let mut has_unimplemented = false; // Track files that have been consumed by orphan projects. let mut consumed_files = FxHashSet::default(); 'outer: for project in workspace.all_projects() { let filter = FileFilter::for_project(filenames.iter(), project, Some(&mut consumed_files)); let Some(mut hooks) = project_to_hooks.remove(project) else { continue; }; trace!( "Files for project `{project}` after filtered: {}", filter.len() ); // Sort hooks by priority (lower number means higher priority). // If two hooks have the same priority, preserve their original order from the config. hooks.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.idx.cmp(&b.idx))); if projects_len > 1 || !project.is_root() { reporter.suspend(|| { writeln!( status_printer.printer().stdout(), "{}{}", if first { "" } else { "\n" }, format!("Running hooks for `{}`:", project.to_string().cyan()).bold() ) })?; first = false; } let mut prev_diff = git::get_diff(project.path()).await?; let project_fail_fast = fail_fast || project.config().fail_fast.unwrap_or(false); for group_range in PriorityGroupRanges::new(&hooks) { let group_hooks = hooks[group_range].to_vec(); let mut group_results = run_priority_group(group_hooks, &filter, store, dry_run, &reporter).await?; // Print results in a stable order (same order as config within the project). group_results.sort_unstable_by(|a, b| a.hook.idx.cmp(&b.hook.idx)); // Check if any files were modified by this group of hooks. let all_skipped = group_results.iter().all(|r| r.status.is_skipped()); let group_modified_files = if !all_skipped { let curr_diff = git::get_diff(project.path()).await?; let group_modified_files = curr_diff != prev_diff; prev_diff = curr_diff; group_modified_files } else { false }; if group_modified_files { file_modified = true; } reporter.suspend(|| { render_priority_group( printer, &status_printer, &group_results, verbose, group_modified_files, ) })?; let hook_fail_fast = apply_group_outcome( &group_results, group_modified_files, &mut success, &mut has_unimplemented, ); if !success && (project_fail_fast || hook_fail_fast) { break 'outer; } } } reporter.on_complete(); if has_unimplemented { warn_user!( "Some hooks were skipped because their languages are unimplemented.\nWe're working hard to support more languages. Check out current support status at {}.", "https://prek.j178.dev/languages/".cyan().underline() ); } if !success && show_diff_on_failure && file_modified { if EnvVars::is_under_ci() { writeln!( printer.stdout(), "{}", indoc::formatdoc! { "\n{}: Some hooks made changes to the files. If you are seeing this message in CI, reproduce locally with: `{}` To run prek as part of Git workflow, use `{}` to set up Git shims.\n", "hint".yellow().bold(), "prek run --all-files".cyan(), "prek install".cyan() } )?; } writeln!(printer.stdout_important(), "All changes made by hooks:")?; let color = if *USE_COLOR { "--color=always" } else { "--color=never" }; git::git_cmd("git diff")? .arg("--no-pager") .arg("diff") .arg("--no-ext-diff") .arg(color) .arg("--") .arg(workspace.root()) .check(true) .spawn()? .wait() .await?; } if success { Ok(ExitStatus::Success) } else { Ok(ExitStatus::Failure) } } struct PriorityGroupRanges<'a> { hooks: &'a [InstalledHook], idx: usize, } impl<'a> PriorityGroupRanges<'a> { fn new(hooks: &'a [InstalledHook]) -> Self { Self { hooks, idx: 0 } } } impl Iterator for PriorityGroupRanges<'_> { type Item = std::ops::Range; fn next(&mut self) -> Option { if self.idx >= self.hooks.len() { return None; } let start = self.idx; let priority = self.hooks[start].priority; let mut end = start + 1; while end < self.hooks.len() && self.hooks[end].priority == priority { end += 1; } self.idx = end; Some(start..end) } } async fn run_priority_group( group_hooks: Vec, filter: &FileFilter<'_>, store: &Store, dry_run: bool, reporter: &HookRunReporter, ) -> Result> { debug!( "Running priority group with priority {} with concurrency {}: {:?}", group_hooks[0].priority, *CONCURRENCY, group_hooks.iter().map(|h| &h.id).collect::>() ); let mut results = futures::stream::iter( group_hooks .into_iter() .map(|hook| run_hook(hook, filter, store, dry_run, reporter)), ) .buffer_unordered(*CONCURRENCY); let mut group_results = Vec::new(); while let Some(result) = results.next().await { group_results.push(result?); } Ok(group_results) } fn render_priority_group( printer: Printer, status_printer: &StatusPrinter, group_results: &[RunResult], verbose: bool, group_modified_files: bool, ) -> Result<()> { // Only show a special group UI when the group failed due to file modifications. // Hooks in a priority group run in parallel, so we can't attribute modifications to a single hook. let show_group_ui = group_modified_files && group_results.len() > 1; let single_hook_modified_files = group_results.len() == 1 && group_modified_files; let group_prefix = if show_group_ui { format!("{}", " │ ".dimmed()) } else { String::new() }; if show_group_ui { status_printer.write( "Files were modified by following hooks", "", RunStatus::Failed, )?; } for (i, result) in group_results.iter().enumerate() { let prefix = if show_group_ui { if i == 0 { " ┌ " } else if i + 1 == group_results.len() { " └ " } else { " │ " } } else { "" }; // If a single hook modified files, treat it as failed. let status = if single_hook_modified_files && result.status == RunStatus::Success { RunStatus::Failed } else { result.status }; status_printer.write(&result.hook.name, prefix, status)?; if matches!(status, RunStatus::NoFiles | RunStatus::Unimplemented) { continue; } let mut stdout = match status { RunStatus::Failed => printer.stdout_important(), _ => printer.stdout(), }; if verbose || result.hook.verbose || status == RunStatus::Failed { writeln!( stdout, "{group_prefix}{}", format!("- hook id: {}", result.hook.id).dimmed() )?; if verbose || result.hook.verbose { writeln!( stdout, "{group_prefix}{}", format!("- duration: {:.2?}s", result.duration.as_secs_f64()).dimmed() )?; } if result.exit_status != 0 { writeln!( stdout, "{group_prefix}{}", format!("- exit code: {}", result.exit_status).dimmed() )?; } if single_hook_modified_files { writeln!( stdout, "{group_prefix}{}", "- files were modified by this hook".dimmed() )?; } let output = result.output.trim_ascii(); if !output.is_empty() { if let Some(file) = result.hook.log_file.as_deref() { let mut file = fs_err::OpenOptions::new() .create(true) .append(true) .open(file)?; file.write_all(output)?; file.flush()?; } else { if show_group_ui { writeln!(stdout, "{}", " │".dimmed())?; } else { writeln!(stdout)?; } let text = String::from_utf8_lossy(output); for line in text.lines() { if line.is_empty() { if show_group_ui { writeln!(stdout, "{}", " │".dimmed())?; } else { writeln!(stdout)?; } } else { if show_group_ui { writeln!(stdout, "{group_prefix}{line}")?; } else { writeln!(stdout, " {line}")?; } } } } } } } Ok(()) } fn apply_group_outcome( group_results: &[RunResult], group_modified_files: bool, success: &mut bool, has_unimplemented: &mut bool, ) -> bool { let mut hook_fail_fast = false; for RunResult { hook, status, .. } in group_results { *has_unimplemented |= status.is_unimplemented(); let ok = if group_modified_files { false } else { status.as_bool() }; *success &= ok; if !ok && hook.fail_fast { hook_fail_fast = true; } } hook_fail_fast } /// Shuffle the files so that they more evenly fill out the xargs /// partitions, but do it deterministically in case a hook cares about ordering. fn shuffle(filenames: &mut [T]) { const SEED: u64 = 1_542_676_187; let mut rng = StdRng::seed_from_u64(SEED); filenames.shuffle(&mut rng); } #[derive(Copy, Clone, Eq, PartialEq)] enum RunStatus { Success, Failed, DryRun, NoFiles, Unimplemented, } impl RunStatus { fn as_bool(self) -> bool { matches!( self, Self::Success | Self::NoFiles | Self::DryRun | Self::Unimplemented ) } fn is_unimplemented(self) -> bool { matches!(self, Self::Unimplemented) } fn is_skipped(self) -> bool { matches!(self, Self::DryRun | Self::NoFiles | Self::Unimplemented) } } struct RunResult { hook: InstalledHook, status: RunStatus, duration: std::time::Duration, exit_status: i32, output: Vec, } impl RunResult { fn from_status(hook: InstalledHook, status: RunStatus) -> Self { Self { hook, status, duration: std::time::Duration::ZERO, exit_status: 0, output: Vec::new(), } } } async fn run_hook( hook: InstalledHook, filter: &FileFilter<'_>, store: &Store, dry_run: bool, reporter: &HookRunReporter, ) -> Result { let mut filenames = filter.for_hook(&hook); trace!( "Files for hook `{}` after filtered: {}", hook.id, filenames.len() ); if filenames.is_empty() && !hook.always_run { return Ok(RunResult::from_status(hook, RunStatus::NoFiles)); } if !Language::supported(hook.language) { return Ok(RunResult::from_status(hook, RunStatus::Unimplemented)); } let start = std::time::Instant::now(); let filenames = match hook.pass_filenames { PassFilenames::All | PassFilenames::Limited(_) => { shuffle(&mut filenames); filenames } PassFilenames::None => vec![], }; let (exit_status, hook_output) = if dry_run { let mut output = Vec::new(); if !filenames.is_empty() { writeln!( output, "`{}` would be run on {} files:", hook, filenames.len() )?; } for filename in filenames { writeln!(output, "- {}", filename.display())?; } (0, output) } else { hook.language .run(&hook, &filenames, store, reporter) .await .with_context(|| format!("Failed to run hook `{hook}`"))? }; let duration = start.elapsed(); let run_status = if dry_run { RunStatus::DryRun } else if exit_status == 0 { RunStatus::Success } else { RunStatus::Failed }; Ok(RunResult { hook, status: run_status, duration, exit_status, output: hook_output, }) } #[cfg(test)] mod tests { use super::*; #[test] fn status_printer_write_dots_saturates_instead_of_underflow() { let status_printer = StatusPrinter { printer: Printer::Silent, columns: 10, }; // This would underflow if computed with plain `-` on `usize`. let long_name = "this hook name is definitely longer than ten columns"; status_printer .write(long_name, "", RunStatus::Failed) .expect("write should not fail"); } } ================================================ FILE: crates/prek/src/cli/run/selector.rs ================================================ use std::borrow::Cow; use std::fmt::Display; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use crate::hook::Hook; use crate::warn_user; use anyhow::anyhow; use itertools::Itertools; use path_clean::PathClean; use prek_consts::env_vars::EnvVars; use rustc_hash::FxHashSet; use tracing::trace; #[derive(Debug, thiserror::Error)] pub(crate) enum Error { #[error("Invalid selector: `{selector}`")] InvalidSelector { selector: String, #[source] source: anyhow::Error, }, #[error("Invalid project path: `{path}`")] InvalidPath { path: String, #[source] source: anyhow::Error, }, } #[derive(Debug, Clone, Copy)] pub(crate) enum SelectorSource { CliArg, CliFlag(&'static str), EnvVar(&'static str), } #[derive(Debug, Clone)] pub(crate) enum SelectorExpr { HookId(String), ProjectPrefix(PathBuf), ProjectHook { project_path: PathBuf, hook_id: String, }, } #[derive(Debug, Clone)] pub(crate) struct Selector { source: SelectorSource, original: String, expr: SelectorExpr, } impl Display for Selector { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.expr { SelectorExpr::HookId(hook_id) => write!(f, "{hook_id}"), SelectorExpr::ProjectPrefix(project_path) => { if project_path.as_os_str().is_empty() { write!(f, "./") } else { write!(f, "{}/", project_path.display()) } } SelectorExpr::ProjectHook { project_path, hook_id, } => { if project_path.as_os_str().is_empty() { write!(f, ".:{hook_id}") } else { write!(f, "{}:{hook_id}", project_path.display()) } } } } } impl Selector { pub(crate) fn as_flag(&self) -> Cow<'_, str> { match &self.source { SelectorSource::CliArg => Cow::Borrowed(&self.original), SelectorSource::CliFlag(flag) => Cow::Owned(format!("{}={}", flag, self.original)), SelectorSource::EnvVar(var) => Cow::Owned(format!("{}={}", var, self.original)), } } pub(crate) fn as_normalized_flag(&self) -> String { match &self.source { SelectorSource::CliArg => self.to_string(), SelectorSource::CliFlag(flag) => format!("{flag}={self}"), SelectorSource::EnvVar(var) => format!("{var}={self}"), } } pub(crate) fn source(&self) -> &SelectorSource { &self.source } pub(crate) fn kind_str(&self) -> &'static str { match &self.expr { SelectorExpr::HookId(_) | SelectorExpr::ProjectHook { .. } => "hooks", SelectorExpr::ProjectPrefix(_) => "projects", } } } impl Selector { pub(crate) fn matches_hook(&self, hook: &Hook) -> bool { match &self.expr { SelectorExpr::HookId(hook_id) => { // For bare hook IDs, check if it matches the hook &hook.id == hook_id || &hook.alias == hook_id } SelectorExpr::ProjectPrefix(project_path) => { // For project paths, check if the hook belongs to that project. hook.project().relative_path().starts_with(project_path) } SelectorExpr::ProjectHook { project_path, hook_id, } => { // For project:hook syntax, check both (&hook.id == hook_id || &hook.alias == hook_id) && project_path == hook.project().relative_path() } } } } #[derive(Debug, Clone, Default)] pub(crate) struct Selectors { includes: Vec, skips: Vec, usage: Arc>, } impl Selectors { /// Load include and skip selectors from CLI args and environment variables. pub(crate) fn load( includes: &[String], skips: &[String], workspace_root: &Path, ) -> Result { let includes = includes .iter() .unique() .map(|selector| { parse_single_selector( selector, workspace_root, SelectorSource::CliArg, RealFileSystem, ) }) .collect::, _>>()?; trace!( "Include selectors: `{}`", includes .iter() .map(ToString::to_string) .collect::>() .join(", ") ); let skips = load_skips(skips, workspace_root, RealFileSystem)?; trace!( "Skip selectors: `{}`", skips .iter() .map(ToString::to_string) .collect::>() .join(", ") ); Ok(Self { includes, skips, usage: Arc::default(), }) } pub(crate) fn includes(&self) -> &[Selector] { &self.includes } pub(crate) fn skips(&self) -> &[Selector] { &self.skips } pub(crate) fn has_project_selectors(&self) -> bool { self.includes.iter().any(|include| { matches!( include.expr, SelectorExpr::ProjectPrefix(_) | SelectorExpr::ProjectHook { .. } ) }) } pub(crate) fn includes_only_hook_targets(&self) -> bool { !self.includes.is_empty() && self.includes.iter().all(|s| { matches!( s.expr, SelectorExpr::HookId(_) | SelectorExpr::ProjectHook { .. } ) }) } /// Check if a hook matches any of the selection criteria. pub(crate) fn matches_hook(&self, hook: &Hook) -> bool { let mut usage = self.usage.lock().unwrap(); // Always check every selector to track usage let mut skipped = false; for (idx, skip) in self.skips.iter().enumerate() { if skip.matches_hook(hook) { usage.use_skip(idx); skipped = true; } } if skipped { return false; } if self.includes.is_empty() { return true; // No `includes` mean all hooks are included } let mut included = false; for (idx, include) in self.includes.iter().enumerate() { if include.matches_hook(hook) { usage.use_include(idx); included = true; } } included } pub(crate) fn matches_hook_id(&self, hook_id: &str) -> bool { let mut usage = self.usage.lock().unwrap(); // Always check every selector to track usage let mut skipped = false; for (idx, skip) in self.skips.iter().enumerate() { if let SelectorExpr::HookId(id) = &skip.expr { if id == hook_id { usage.use_skip(idx); skipped = true; } } } if skipped { return false; } if self.includes.is_empty() { return true; // No `includes` mean all hooks are included } let mut included = false; for (idx, include) in self.includes.iter().enumerate() { if let SelectorExpr::HookId(id) = &include.expr { if id == hook_id { usage.use_include(idx); included = true; } } } included } pub(crate) fn matches_path(&self, path: &Path) -> bool { let mut usage = self.usage.lock().unwrap(); let mut skipped = false; for (idx, skip) in self.skips.iter().enumerate() { if let SelectorExpr::ProjectPrefix(project_path) = &skip.expr { if path.starts_with(project_path) { usage.use_skip(idx); skipped = true; } } } if skipped { return false; } // If no project prefix selectors are present, all paths are included if !self .includes .iter() .any(|include| matches!(include.expr, SelectorExpr::ProjectPrefix(_))) { return true; } let mut included = false; for (idx, include) in self.includes.iter().enumerate() { if let SelectorExpr::ProjectPrefix(project_path) = &include.expr { if path.starts_with(project_path) { usage.use_include(idx); included = true; } } } included } pub(crate) fn report_unused(&self) { let usage = self.usage.lock().unwrap(); usage.report_unused(self); } } #[derive(Default, Debug)] struct SelectorUsage { used_includes: FxHashSet, used_skips: FxHashSet, } impl SelectorUsage { fn use_include(&mut self, idx: usize) { self.used_includes.insert(idx); } fn use_skip(&mut self, idx: usize) { self.used_skips.insert(idx); } fn report_unused(&self, selectors: &Selectors) { let unused = selectors .includes .iter() .enumerate() .filter(|(idx, _)| !self.used_includes.contains(idx)) .chain( selectors .skips .iter() .enumerate() .filter(|(idx, _)| !self.used_skips.contains(idx)), ) .collect::>(); match unused.as_slice() { [] => {} [(_, selector)] => { let flag = selector.as_flag(); let normalized = selector.as_normalized_flag(); if flag == normalized { warn_user!( "selector `{flag}` did not match any {}", selector.kind_str() ); } else { warn_user!( "selector `{flag}` ({}) did not match any {}", format!("normalized to `{normalized}`").dimmed(), selector.kind_str() ); } } _ => { let warning = unused .iter() .map(|(_, sel)| { let flag = sel.as_flag(); let normalized = sel.as_normalized_flag(); if flag == normalized { format!(" - `{flag}`") } else { format!( " - `{flag}` ({})", format!("normalized to `{normalized}`").dimmed() ) } }) .collect::>() .join("\n"); warn_user!("the following selectors did not match any hooks or projects:"); anstream::eprintln!("{warning}"); } } } } /// Parse a single selector string into a Selection enum. fn parse_single_selector( input: &str, workspace_root: &Path, source: SelectorSource, fs: FS, ) -> Result { // Handle `project:hook` syntax if let Some((project_path, hook_id)) = input.split_once(':') { if hook_id.is_empty() { return Err(Error::InvalidSelector { selector: input.to_string(), source: anyhow!("hook ID part is empty"), }); } if project_path.is_empty() { return Ok(Selector { source, original: input.to_string(), expr: SelectorExpr::HookId(hook_id.to_string()), }); } let project_path = normalize_path(project_path, workspace_root, fs).map_err(|e| { Error::InvalidSelector { selector: input.to_string(), source: anyhow!(e), } })?; return Ok(Selector { source, original: input.to_string(), expr: SelectorExpr::ProjectHook { project_path, hook_id: hook_id.to_string(), }, }); } // Handle project paths if input == "." || input.contains('/') { let project_path = normalize_path(input, workspace_root, fs).map_err(|e| Error::InvalidSelector { selector: input.to_string(), source: anyhow!(e), })?; return Ok(Selector { source, original: input.to_string(), expr: SelectorExpr::ProjectPrefix(project_path), }); } // Ambiguous case: treat as hook ID for backward compatibility if input.is_empty() { return Err(Error::InvalidSelector { selector: input.to_string(), source: anyhow!("cannot be empty"), }); } Ok(Selector { source, original: input.to_string(), expr: SelectorExpr::HookId(input.to_string()), }) } /// Trait to abstract filesystem operations for easier testing. pub trait FileSystem: Copy { fn absolute>(&self, path: P) -> std::io::Result; } #[derive(Copy, Clone)] pub struct RealFileSystem; impl FileSystem for RealFileSystem { fn absolute>(&self, path: P) -> std::io::Result { Ok(std::path::absolute(path)?.clean()) } } /// Normalize a project path to the relative path from the workspace root. /// In workspace root: /// './project/' -> 'project' /// 'project/sub/' -> 'project/sub' /// '.' -> '' /// './' -> '' /// '..' -> Error /// '../project/' -> Error /// '/absolute/path/' -> if inside workspace, relative path; else Error /// In subdirectory of workspace (e.g., 'workspace/subdir'): /// './project/' -> 'subdir/project' /// 'project/' -> 'subdir/project' /// '../project/' -> 'project' /// '..' -> '' fn normalize_path( path: &str, workspace_root: &Path, fs: FS, ) -> Result { let absolute_path = fs.absolute(path).map_err(|e| Error::InvalidPath { path: path.to_string(), source: anyhow!(e), })?; let absolute_path = absolute_path.clean(); let rel_path = absolute_path .strip_prefix(workspace_root) .map_err(|_| Error::InvalidPath { path: path.to_string(), source: anyhow!("path is outside the workspace root"), })?; Ok(rel_path.to_path_buf()) } /// Parse skip selectors from CLI args and environment variables pub(crate) fn load_skips( cli_skips: &[String], workspace_root: &Path, fs: FS, ) -> Result, Error> { let prek_skip = EnvVars::var(EnvVars::PREK_SKIP); let skip = EnvVars::var(EnvVars::SKIP); let (skips, source) = if !cli_skips.is_empty() { ( cli_skips.iter().map(String::as_str).collect::>(), SelectorSource::CliFlag("--skip"), ) } else if let Ok(s) = &prek_skip { ( parse_comma_separated(s).collect(), SelectorSource::EnvVar(EnvVars::PREK_SKIP), ) } else if let Ok(s) = &skip { ( parse_comma_separated(s).collect(), SelectorSource::EnvVar(EnvVars::SKIP), ) } else { return Ok(vec![]); }; skips .into_iter() .unique() .map(|skip| parse_single_selector(skip, workspace_root, source, fs)) .collect() } /// Parse comma-separated values, trimming whitespace and filtering empty strings fn parse_comma_separated(input: &str) -> impl Iterator { input.split(',').map(str::trim).filter(|s| !s.is_empty()) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; struct MockFileSystem { current_dir: TempDir, } impl FileSystem for &MockFileSystem { fn absolute>(&self, path: P) -> std::io::Result { let p = path.as_ref(); if p.is_absolute() { Ok(p.to_path_buf()) } else { Ok(self.current_dir.path().join(p)) } } } impl MockFileSystem { fn root(&self) -> &Path { self.current_dir.path() } } fn create_test_workspace() -> anyhow::Result { let temp_dir = TempDir::new()?; std::fs::create_dir_all(temp_dir.path().join("src"))?; std::fs::create_dir_all(temp_dir.path().join("src/backend"))?; Ok(MockFileSystem { current_dir: temp_dir, }) } #[test] fn test_parse_single_selector_hook_id() -> anyhow::Result<()> { let fs = create_test_workspace()?; // Test explicit hook ID with colon prefix let selector = parse_single_selector(":black", fs.root(), SelectorSource::CliArg, &fs)?; assert!(matches!(selector.expr, SelectorExpr::HookId(ref id) if id == "black")); let selector = parse_single_selector(":lint:ruff", fs.root(), SelectorSource::CliArg, &fs)?; assert!(matches!(selector.expr, SelectorExpr::HookId(ref id) if id == "lint:ruff")); // Test bare hook ID (backward compatibility) let selector = parse_single_selector("black", fs.root(), SelectorSource::CliArg, &fs)?; assert!(matches!(selector.expr, SelectorExpr::HookId(ref id) if id == "black")); Ok(()) } #[test] fn test_parse_single_selector_project_prefix() -> anyhow::Result<()> { let fs = create_test_workspace()?; // Test project path with slash let selector = parse_single_selector("src/", fs.root(), SelectorSource::CliArg, &fs)?; assert!( matches!(selector.expr, SelectorExpr::ProjectPrefix(ref path) if path == &PathBuf::from("src")) ); // Test current directory let selector = parse_single_selector(".", fs.root(), SelectorSource::CliArg, &fs)?; assert!( matches!(selector.expr, SelectorExpr::ProjectPrefix(ref path) if path == &PathBuf::from("")) ); let selector = parse_single_selector("./", fs.root(), SelectorSource::CliArg, &fs)?; assert!( matches!(selector.expr, SelectorExpr::ProjectPrefix(ref path) if path == &PathBuf::from("")) ); Ok(()) } #[test] fn test_parse_single_selector_project_hook() -> anyhow::Result<()> { let fs = create_test_workspace()?; let selector = parse_single_selector("src:black", fs.root(), SelectorSource::CliArg, &fs)?; match selector.expr { SelectorExpr::ProjectHook { project_path, hook_id, } => { assert_eq!(project_path, PathBuf::from("src")); assert_eq!(hook_id, "black"); } _ => panic!("Expected ProjectHook"), } let selector = parse_single_selector("src:lint:ruff", fs.root(), SelectorSource::CliArg, &fs)?; match selector.expr { SelectorExpr::ProjectHook { project_path, hook_id, } => { assert_eq!(project_path, PathBuf::from("src")); assert_eq!(hook_id, "lint:ruff"); } _ => panic!("Expected ProjectHook"), } Ok(()) } #[test] fn test_parse_single_selector_invalid() -> anyhow::Result<()> { let fs = create_test_workspace()?; // Test empty hook ID let result = parse_single_selector(":", fs.root(), SelectorSource::CliArg, &fs); assert!(result.is_err()); // Test empty hook ID in project:hook let result = parse_single_selector("src:", fs.root(), SelectorSource::CliArg, &fs); assert!(result.is_err()); // Test empty string let result = parse_single_selector("", fs.root(), SelectorSource::CliArg, &fs); assert!(result.is_err()); Ok(()) } #[test] fn test_normalize_path() -> anyhow::Result<()> { let fs = create_test_workspace()?; // Test relative path let result = normalize_path("src", fs.root(), &fs)?; assert_eq!(result, PathBuf::from("src")); // Test nested path let result = normalize_path("src/backend", fs.root(), &fs)?; assert_eq!(result, PathBuf::from("src/backend")); // Test current directory let result = normalize_path(".", fs.root(), &fs)?; assert_eq!(result, PathBuf::from("")); // Test path outside workspace - create a temp dir outside workspace let outside_dir = TempDir::new()?; let outside_path = outside_dir.path().to_string_lossy(); let result = normalize_path(&outside_path, fs.root(), &fs); assert!(result.is_err()); Ok(()) } #[test] fn test_selector_display() -> anyhow::Result<()> { let fs = create_test_workspace()?; let selector = parse_single_selector("black", fs.root(), SelectorSource::CliArg, &fs)?; assert_eq!(selector.to_string(), "black"); let selector = parse_single_selector(":black", fs.root(), SelectorSource::CliArg, &fs)?; assert_eq!(selector.to_string(), "black"); let selector = parse_single_selector(":lint:ruff", fs.root(), SelectorSource::CliArg, &fs)?; assert_eq!(selector.to_string(), "lint:ruff"); let selector = parse_single_selector("src/", fs.root(), SelectorSource::CliArg, &fs)?; assert_eq!(selector.to_string(), "src/"); let selector = parse_single_selector("./src/", fs.root(), SelectorSource::CliArg, &fs)?; assert_eq!(selector.to_string(), "src/"); let selector = parse_single_selector("src/", fs.root(), SelectorSource::CliArg, &fs)?; assert_eq!(selector.to_string(), "src/"); let selector = parse_single_selector(".", fs.root(), SelectorSource::CliArg, &fs)?; assert_eq!(selector.to_string(), "./"); let selector = parse_single_selector("./", fs.root(), SelectorSource::CliArg, &fs)?; assert_eq!(selector.to_string(), "./"); let selector = parse_single_selector("src:black", fs.root(), SelectorSource::CliArg, &fs)?; assert_eq!(selector.to_string(), "src:black"); let selector = parse_single_selector("./src:black", fs.root(), SelectorSource::CliArg, &fs)?; assert_eq!(selector.to_string(), "src:black"); let selector = parse_single_selector("./src/:black", fs.root(), SelectorSource::CliArg, &fs)?; assert_eq!(selector.to_string(), "src:black"); let selector = parse_single_selector("src:lint:ruff", fs.root(), SelectorSource::CliArg, &fs)?; assert_eq!(selector.to_string(), "src:lint:ruff"); Ok(()) } #[test] fn test_selector_as_flag() { let selector = Selector { source: SelectorSource::CliArg, original: "black".to_string(), expr: SelectorExpr::HookId("black".to_string()), }; assert_eq!(selector.as_flag(), "black"); let selector = Selector { source: SelectorSource::CliFlag("--skip"), original: "black".to_string(), expr: SelectorExpr::HookId("black".to_string()), }; assert_eq!(selector.as_flag(), "--skip=black"); let selector = Selector { source: SelectorSource::EnvVar("SKIP"), original: "black".to_string(), expr: SelectorExpr::HookId("black".to_string()), }; assert_eq!(selector.as_flag(), "SKIP=black"); } } ================================================ FILE: crates/prek/src/cli/sample_config.rs ================================================ use std::fmt::Write as _; use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::Result; use owo_colors::OwoColorize; use prek_consts::{PRE_COMMIT_CONFIG_YAML, PREK_TOML}; use crate::cli::{ExitStatus, SampleConfigFormat, SampleConfigTarget}; use crate::fs::Simplified; use crate::printer::Printer; static SAMPLE_CONFIG_YAML: &str = indoc::indoc! {" # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: 'https://github.com/pre-commit/pre-commit-hooks' rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files "}; static SAMPLE_CONFIG_TOML: &str = indoc::indoc! {r#" # Configuration file for `prek`, a git hook framework written in Rust. # See https://prek.j178.dev for more information. #:schema https://www.schemastore.org/prek.json [[repos]] repo = "builtin" hooks = [ { id = "trailing-whitespace" }, { id = "end-of-file-fixer" }, { id = "check-added-large-files" }, ] "#}; pub(crate) fn sample_config( target: SampleConfigTarget, format: Option, printer: Printer, ) -> Result { let (path, format) = match (target, format) { (SampleConfigTarget::Path(path), Some(format)) => (Some(path), format), (SampleConfigTarget::Path(path), None) => match path.extension() { Some(ext) if ext.eq_ignore_ascii_case("toml") => (Some(path), SampleConfigFormat::Toml), _ => (Some(path), SampleConfigFormat::Yaml), }, (SampleConfigTarget::DefaultFile, Some(format)) => match format { SampleConfigFormat::Toml => (Some(PathBuf::from(PREK_TOML)), format), SampleConfigFormat::Yaml => (Some(PathBuf::from(PRE_COMMIT_CONFIG_YAML)), format), }, (SampleConfigTarget::DefaultFile, None) => ( Some(PathBuf::from(PRE_COMMIT_CONFIG_YAML)), SampleConfigFormat::Yaml, ), (SampleConfigTarget::Stdout, Some(format)) => (None, format), (SampleConfigTarget::Stdout, None) => (None, SampleConfigFormat::Yaml), }; if let Some(path) = path { fs_err::create_dir_all(path.parent().unwrap_or(Path::new(".")))?; let mut file = match fs_err::OpenOptions::new() .write(true) .create_new(true) .open(&path) { Ok(f) => f, Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { anyhow::bail!("File `{}` already exists", path.simplified_display().cyan()); } Err(err) => return Err(err.into()), }; match format { SampleConfigFormat::Yaml => write!(file, "{SAMPLE_CONFIG_YAML}")?, SampleConfigFormat::Toml => write!(file, "{SAMPLE_CONFIG_TOML}")?, } writeln!( printer.stdout(), "Written to `{}`", path.simplified_display().cyan() )?; return Ok(ExitStatus::Success); } // TODO: default to prek.toml in the future? match format { SampleConfigFormat::Yaml => { write!(printer.stdout_important(), "{SAMPLE_CONFIG_YAML}")?; } SampleConfigFormat::Toml => { write!(printer.stdout_important(), "{SAMPLE_CONFIG_TOML}")?; } } Ok(ExitStatus::Success) } ================================================ FILE: crates/prek/src/cli/self_update.rs ================================================ // MIT License // // Copyright (c) 2023 Astral Software Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. use std::env; use std::fmt::Write; use anyhow::Result; use axoupdater::{AxoUpdater, AxoupdateError, UpdateRequest}; use owo_colors::OwoColorize; use tracing::{debug, enabled}; use crate::cli::ExitStatus; use crate::install_source::InstallSource; use crate::printer::Printer; fn format_install_hint() -> String { match InstallSource::detect() { Some(s) => format!( "{}{} You installed prek via {}. To update, run `{}`", "hint".cyan().bold(), ":".bold(), s.description(), s.update_instructions() ), None => format!( "{}{} If you installed prek with pip, brew, or another package manager, update prek with `pip install --upgrade`, `brew upgrade`, or similar.", "hint".cyan().bold(), ":".bold() ), } } /// Attempt to update the prek binary. pub(crate) async fn self_update( version: Option, token: Option, printer: Printer, ) -> Result { let mut updater = AxoUpdater::new_for("prek"); if enabled!(tracing::Level::DEBUG) { unsafe { env::set_var("INSTALLER_PRINT_VERBOSE", "1") }; updater.enable_installer_output(); } else { updater.disable_installer_output(); } if let Some(ref token) = token { updater.set_github_token(token); } // Load the "install receipt" for the current binary. If the receipt is not found, then // prek was likely installed via a package manager. let Ok(updater) = updater.load_receipt() else { debug!("no receipt found; assuming prek was installed via a package manager"); writeln!( printer.stderr(), "{}{} Self-update is only available for prek binaries installed via the standalone installation scripts.", "error".red().bold(), ":".bold(), )?; writeln!(printer.stderr(), "{}", format_install_hint())?; return Ok(ExitStatus::Error); }; // Ensure the receipt is for the current binary. If it's not, then the user likely has multiple // prek binaries installed, and the current binary was _not_ installed via the standalone // installation scripts. if !updater.check_receipt_is_for_this_executable()? { debug!( "receipt is not for this executable; assuming prek was installed via a package manager" ); writeln!( printer.stderr(), "{}{} Self-update is only available for prek binaries installed via the standalone installation scripts.", "error".red().bold(), ":".bold(), )?; writeln!(printer.stderr(), "{}", format_install_hint())?; return Ok(ExitStatus::Error); } writeln!( printer.stderr(), "{}", format_args!( "{}{} Checking for updates...", "info".cyan().bold(), ":".bold() ) )?; let update_request = if let Some(version) = version { UpdateRequest::SpecificTag(version) } else { UpdateRequest::Latest }; updater.configure_version_specifier(update_request); // Run the updater. This involves a network request, since we need to determine the latest // available version of prek. match updater.run().await { Ok(Some(result)) => { let version_information = if let Some(old_version) = result.old_version { format!( "from {} to {}", format!("v{old_version}").bold().white(), format!("v{}", result.new_version).bold().white(), ) } else { format!("to {}", format!("v{}", result.new_version).bold().white()) }; writeln!( printer.stderr(), "{}", format_args!( "{}{} Upgraded prek {}! {}", "success".green().bold(), ":".bold(), version_information, format!( "https://github.com/j178/prek/releases/tag/{}", result.new_version_tag ) .cyan() ) )?; } Ok(None) => { writeln!( printer.stderr(), "{}", format_args!( "{}{} You're on the latest version of prek ({})", "success".green().bold(), ":".bold(), format!("v{}", env!("CARGO_PKG_VERSION")).bold().white() ) )?; } Err(err) => { return if let AxoupdateError::Reqwest(err) = err { if err.status() == Some(http::StatusCode::FORBIDDEN) && token.is_none() { writeln!( printer.stderr(), "{}", format_args!( "{}{} GitHub API rate limit exceeded. Please provide a GitHub token via the {} option.", "error".red().bold(), ":".bold(), "`--token`".green().bold() ) )?; Ok(ExitStatus::Error) } else { Err(err.into()) } } else { Err(err.into()) }; } } Ok(ExitStatus::Success) } ================================================ FILE: crates/prek/src/cli/try_repo.rs ================================================ use std::borrow::Cow; use std::fmt::Write; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use owo_colors::OwoColorize; use prek_consts::PREK_TOML; use tempfile::TempDir; use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Value}; use crate::cli::ExitStatus; use crate::cli::run::Selectors; use crate::config; use crate::git; use crate::git::GIT_ROOT; use crate::printer::Printer; use crate::store::Store; use crate::warn_user; async fn get_head_rev(repo: &Path) -> Result { let head_rev = git::git_cmd("get head rev")? .arg("rev-parse") .arg("HEAD") .current_dir(repo) .output() .await? .stdout; let head_rev = String::from_utf8_lossy(&head_rev).trim().to_string(); Ok(head_rev) } async fn clone_and_commit(repo_path: &Path, head_rev: &str, tmp_dir: &Path) -> Result { let shadow = tmp_dir.join("shadow-repo"); git::git_cmd("clone shadow repo")? .arg("clone") .arg(repo_path) .arg(&shadow) .output() .await?; git::git_cmd("checkout shadow repo")? .arg("checkout") .arg(head_rev) .arg("-b") .arg("_prek_tmp") .current_dir(&shadow) .output() .await?; let index_path = shadow.join(".git/index"); let objects_path = shadow.join(".git/objects"); let staged_files = git::get_staged_files(repo_path).await?; if !staged_files.is_empty() { git::git_cmd("add staged files to shadow")? .arg("add") .arg("--") .args(&staged_files) .current_dir(repo_path) .env("GIT_INDEX_FILE", &index_path) .env("GIT_OBJECT_DIRECTORY", &objects_path) .output() .await?; } let mut add_u_cmd = git::git_cmd("add unstaged to shadow")?; add_u_cmd .arg("add") .arg("--update") // Update tracked files .current_dir(repo_path) .env("GIT_INDEX_FILE", &index_path) .env("GIT_OBJECT_DIRECTORY", &objects_path) .output() .await?; git::git_cmd("git commit")? .arg("commit") .arg("-m") .arg("Temporary commit by prek try-repo") .arg("--no-gpg-sign") .arg("--no-edit") .arg("--no-verify") .current_dir(&shadow) .env("GIT_AUTHOR_NAME", "prek test") .env("GIT_AUTHOR_EMAIL", "test@example.com") .env("GIT_COMMITTER_NAME", "prek test") .env("GIT_COMMITTER_EMAIL", "test@example.com") .output() .await?; Ok(shadow) } async fn prepare_repo_and_rev<'a>( repo: &'a str, rev: Option<&'a str>, tmp_dir: &'a Path, ) -> Result<(Cow<'a, str>, String)> { let repo_path = Path::new(repo); let is_local = repo_path.is_dir(); // If rev is provided, use it directly. if let Some(rev) = rev { return Ok((Cow::Borrowed(repo), rev.to_string())); } // Get HEAD revision let head_rev = if is_local { get_head_rev(repo_path).await? } else { // For remote repositories, use ls-remote let head_rev = git::git_cmd("get head rev")? .arg("ls-remote") .arg("--exit-code") .arg(repo) .arg("HEAD") .output() .await? .stdout; String::from_utf8_lossy(&head_rev) .split_ascii_whitespace() .next() .context("Failed to parse HEAD revision from git ls-remote output")? .to_string() }; // If repo is a local repo with uncommitted changes, create a shadow repo to commit the changes. if is_local && git::has_diff("HEAD", repo_path).await? { warn_user!("Creating temporary repo with uncommitted changes..."); let shadow = clone_and_commit(repo_path, &head_rev, tmp_dir).await?; let head_rev = get_head_rev(&shadow).await?; Ok((Cow::Owned(shadow.to_string_lossy().to_string()), head_rev)) } else { Ok((Cow::Borrowed(repo), head_rev)) } } fn render_repo_config_toml(repo_path: &str, rev: &str, hooks: Vec) -> String { let mut doc = DocumentMut::new(); let mut repo_table = toml_edit::Table::new(); repo_table["repo"] = toml_edit::value(repo_path); repo_table["rev"] = toml_edit::value(rev); let mut hooks_array = Array::new(); hooks_array.set_trailing_comma(true); hooks_array.set_trailing("\n"); for hook_id in hooks { let mut hook_table = InlineTable::new(); hook_table.insert("id", hook_id.into()); let mut value = Value::InlineTable(hook_table); value.decor_mut().set_prefix("\n "); hooks_array.push(value); } repo_table.insert("hooks", Item::Value(Value::Array(hooks_array))); let mut repos = ArrayOfTables::new(); repos.push(repo_table); doc["repos"] = Item::ArrayOfTables(repos); doc.to_string() } pub(crate) async fn try_repo( config: Option, repo: String, rev: Option, run_args: crate::cli::RunArgs, refresh: bool, verbose: bool, printer: Printer, ) -> Result { if config.is_some() { warn_user!("`--config` option is ignored when using `try-repo`"); } let store = Store::from_settings()?; let tmp_dir = TempDir::with_prefix_in("try-repo-", store.scratch_path())?; let (repo_path, rev) = prepare_repo_and_rev(&repo, rev.as_deref(), tmp_dir.path()) .await .context("Failed to determine repository and revision")?; let store = Store::from_path(tmp_dir.path()).init()?; let repo_config = config::RemoteRepo::new(repo_path.to_string(), rev.clone(), vec![]); let repo_clone_path = store.clone_repo(&repo_config, None).await?; let selectors = Selectors::load(&run_args.includes, &run_args.skips, GIT_ROOT.as_ref()?)?; let manifest = config::read_manifest(&repo_clone_path.join(prek_consts::PRE_COMMIT_HOOKS_YAML))?; let hooks = manifest .hooks .into_iter() .filter(|hook| selectors.matches_hook_id(&hook.id)) .map(|hook| hook.id) .collect::>(); let config_str = render_repo_config_toml(&repo_path, &rev, hooks); let config_file = tmp_dir.path().join(PREK_TOML); fs_err::tokio::write(&config_file, &config_str).await?; writeln!( printer.stdout(), "{}", format!("Using generated `{PREK_TOML}`:").cyan().bold() )?; writeln!(printer.stdout(), "{}", config_str.dimmed())?; crate::cli::run( &store, Some(config_file), vec![], vec![], run_args.stage, run_args.from_ref, run_args.to_ref, run_args.all_files, run_args.files, run_args.directory, run_args.last_commit, run_args.show_diff_on_failure, run_args.fail_fast, run_args.dry_run, refresh, run_args.extra, verbose, printer, ) .await } ================================================ FILE: crates/prek/src/cli/validate.rs ================================================ use std::error::Error; use std::fmt::Write; use std::iter; use std::path::PathBuf; use anyhow::Result; use owo_colors::OwoColorize; use crate::cli::ExitStatus; use crate::config::{read_config, read_manifest}; use crate::printer::Printer; use crate::warn_user; pub(crate) fn validate_configs(configs: Vec, printer: Printer) -> Result { let mut status = ExitStatus::Success; if configs.is_empty() { warn_user!("No configs to check"); return Ok(ExitStatus::Success); } for config in configs { if let Err(err) = read_config(&config) { writeln!(printer.stderr(), "{}: {}", "error".red().bold(), err)?; for source in iter::successors(err.source(), |&err| err.source()) { writeln!( printer.stderr(), " {}: {}", "caused by".red().bold(), source )?; } status = ExitStatus::Failure; } } if status == ExitStatus::Success { writeln!( printer.stderr(), "{}: All configs are valid", "success".green().bold() )?; } Ok(status) } pub(crate) fn validate_manifest(manifests: Vec, printer: Printer) -> Result { let mut status = ExitStatus::Success; if manifests.is_empty() { warn_user!("No manifests to check"); return Ok(ExitStatus::Success); } for manifest in manifests { if let Err(err) = read_manifest(&manifest) { writeln!(printer.stderr(), "{}: {}", "error".red().bold(), err)?; for source in iter::successors(err.source(), |&err| err.source()) { writeln!( printer.stderr(), " {}: {}", "caused by".red().bold(), source )?; } status = ExitStatus::Failure; } } if status == ExitStatus::Success { writeln!( printer.stderr(), "{}: All manifests are valid", "success".green().bold() )?; } Ok(status) } ================================================ FILE: crates/prek/src/cli/yaml_to_toml.rs ================================================ use std::fmt::Write as _; use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use owo_colors::OwoColorize; use prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_CONFIG_YML, PREK_TOML}; use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Table, Value}; use crate::cli::ExitStatus; use crate::config; use crate::fs::Simplified; use crate::printer::Printer; /// Resolve the input config path, falling back to `.pre-commit-config.yaml` or /// `.pre-commit-config.yml` in the current directory. fn resolve_input(input: Option) -> Result { if let Some(path) = input { return Ok(path); } let yaml = Path::new(PRE_COMMIT_CONFIG_YAML); if yaml.is_file() { return Ok(yaml.to_path_buf()); } let yml = Path::new(PRE_COMMIT_CONFIG_YML); if yml.is_file() { return Ok(yml.to_path_buf()); } anyhow::bail!( "No `{}` or `{}` found in the current directory\n\n\ {} Provide a path explicitly: {}", PRE_COMMIT_CONFIG_YAML.cyan(), PRE_COMMIT_CONFIG_YML.cyan(), "hint:".yellow().bold(), "prek util yaml-to-toml ".cyan() ); } pub(crate) fn yaml_to_toml( input: Option, output: Option, force: bool, printer: Printer, ) -> Result { let input = resolve_input(input)?; // Validate the input file first. let _ = config::load_config(&input)?; let content = fs_err::read_to_string(&input)?; let value: serde_json::Value = serde_saphyr::from_str(&content)?; let output = output.unwrap_or_else(|| input.parent().unwrap_or(Path::new(".")).join(PREK_TOML)); if output == input { anyhow::bail!( "Output path `{}` matches input; choose a different output path", output.simplified_display().cyan() ); } let mut rendered = json_to_toml(&value)?; if !rendered.ends_with('\n') { rendered.push('\n'); } if let Some(parent) = output.parent() { fs_err::create_dir_all(parent)?; } let mut options = fs_err::OpenOptions::new(); options.write(true); if force { options.create(true).truncate(true); } else { options.create_new(true); } let mut file = match options.open(&output) { Ok(file) => file, Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { anyhow::bail!( "File `{}` already exists (use `--force` to overwrite)", output.simplified_display().cyan() ); } Err(err) => return Err(err.into()), }; file.write_all(rendered.as_bytes())?; writeln!( printer.stdout(), "Converted `{}` → `{}`", input.simplified_display().cyan(), output.simplified_display().cyan() )?; Ok(ExitStatus::Success) } fn json_to_toml(value: &serde_json::Value) -> Result { let map = value .as_object() .context("Expected a top-level mapping in the config file")?; let mut doc = DocumentMut::new(); doc.decor_mut().set_prefix(indoc::indoc! {r" # Configuration file for `prek`, a git hook framework written in Rust. # See https://prek.j178.dev for more information. #:schema https://www.schemastore.org/prek.json "}); for (key, value) in map { if key == "repos" { let repos = value.as_array().context("`repos` must be an array")?; doc["repos"] = repos_to_array_of_tables(repos)?.into(); continue; } doc[key] = json_to_toml_value(value).into(); } Ok(doc.to_string()) } fn json_to_toml_value(value: &serde_json::Value) -> Value { match value { serde_json::Value::Null => Value::from(""), serde_json::Value::Bool(value) => Value::from(*value), serde_json::Value::Number(value) => { if let Some(value) = value.as_i64() { Value::from(value) } else if let Some(value) = value.as_f64() { Value::from(value) } else { Value::from(0.0) } } serde_json::Value::String(value) => Value::from(value.as_str()), serde_json::Value::Array(values) => { json_array_to_value_with_indent(values, " ", " ", false) } serde_json::Value::Object(values) => Value::InlineTable(json_object_to_inline(values)), } } fn json_array_to_value_with_indent( values: &[serde_json::Value], item_indent: &str, closing_indent: &str, force_multiline: bool, ) -> Value { let mut array = Array::new(); if values.len() == 1 && !force_multiline { let value = match &values[0] { serde_json::Value::Object(map) => Value::InlineTable(json_object_to_inline(map)), _ => json_to_toml_value(&values[0]), }; array.push(value); array.set_trailing(""); return Value::Array(array); } for value in values { let mut value = match value { serde_json::Value::Object(map) => Value::InlineTable(json_object_to_inline(map)), _ => json_to_toml_value(value), }; value.decor_mut().set_prefix(format!("\n{item_indent}")); array.push(value); } array.set_trailing(format!("\n{closing_indent}")); Value::Array(array) } fn json_object_to_inline(values: &serde_json::Map) -> InlineTable { let mut table = InlineTable::new(); for (key, value) in values { let value = match value { serde_json::Value::Array(values) => { json_array_to_value_with_indent(values, " ", " ", false) } _ => json_to_toml_value(value), }; table.insert(key.as_str(), value); } format_inline_table_multiline(&mut table, " ", " "); table } fn format_inline_table_multiline(table: &mut InlineTable, base_indent: &str, closing_indent: &str) { let len = table.len(); if len <= 1 { return; } for (idx, (mut key, value)) in table.iter_mut().enumerate() { key.leaf_decor_mut().set_prefix(format!("\n{base_indent}")); key.leaf_decor_mut().set_suffix(" "); let suffix = if idx + 1 == len { format!("\n{closing_indent}") } else { String::new() }; value.decor_mut().set_prefix(" "); value.decor_mut().set_suffix(suffix); if let Value::InlineTable(inner) = value { let nested_base = format!("{base_indent} "); let nested_closing = format!("{closing_indent} "); format_inline_table_multiline(inner, &nested_base, &nested_closing); } } } fn repos_to_array_of_tables(values: &[serde_json::Value]) -> Result { let mut array = ArrayOfTables::new(); for value in values { let map = value .as_object() .context("Each repo entry must be a mapping")?; let mut table = Table::new(); for (key, value) in map { if key == "hooks" { let hooks = value.as_array().context("`hooks` must be an array")?; table[key] = json_array_to_value_with_indent(hooks, " ", "", true).into(); continue; } table[key] = json_to_toml_value(value).into(); } array.push(table); } Ok(array) } ================================================ FILE: crates/prek/src/config.rs ================================================ use std::collections::{BTreeMap, BTreeSet}; use std::error::Error as _; use std::fmt::Display; use std::ops::RangeInclusive; use std::path::Path; use anyhow::Result; use clap::ValueEnum; use fancy_regex::Regex; use globset::{Glob, GlobSet, GlobSetBuilder}; use itertools::Itertools; use prek_identify::TagSet; use rustc_hash::FxHashMap; use serde::de::{DeserializeSeed, Error as DeError, MapAccess, Visitor}; use serde::{Deserialize, Deserializer, Serialize}; use crate::fs::Simplified; use crate::install_source::InstallSource; #[cfg(feature = "schemars")] use crate::schema::{schema_repo_builtin, schema_repo_local, schema_repo_meta, schema_repo_remote}; use crate::version; use crate::warn_user; use crate::warn_user_once; #[derive(Clone)] pub(crate) struct GlobPatterns { patterns: Vec, set: GlobSet, } impl GlobPatterns { pub(crate) fn new(patterns: Vec) -> Result { let mut builder = GlobSetBuilder::new(); for pattern in &patterns { builder.add(Glob::new(pattern)?); } let set = builder.build()?; Ok(Self { patterns, set }) } fn is_match(&self, value: &str) -> bool { self.set.is_match(Path::new(value)) } } impl std::fmt::Debug for GlobPatterns { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("GlobPatterns") .field("patterns", &self.patterns) .finish_non_exhaustive() } } enum FilePatternWire { Glob { glob: String }, GlobList { glob: Vec }, Regex(String), } impl<'de> Deserialize<'de> for FilePatternWire { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { struct FilePatternVisitor; struct GlobFieldVisitor; impl<'de> DeserializeSeed<'de> for GlobFieldVisitor { type Value = FilePatternWire; fn deserialize(self, deserializer: D) -> Result where D: Deserializer<'de>, { deserializer.deserialize_any(self) } } impl<'de> Visitor<'de> for GlobFieldVisitor { type Value = FilePatternWire; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a string or a list of strings") } fn visit_str(self, value: &str) -> Result where E: DeError, { Ok(FilePatternWire::Glob { glob: value.to_owned(), }) } fn visit_string(self, value: String) -> Result where E: DeError, { Ok(FilePatternWire::Glob { glob: value }) } fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, { Ok(FilePatternWire::GlobList { glob: Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new( seq, ))?, }) } } impl<'de> Visitor<'de> for FilePatternVisitor { type Value = FilePatternWire; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str( "a regex string or a mapping with `glob` set to a string or list of strings", ) } fn visit_str(self, value: &str) -> Result where E: DeError, { Ok(FilePatternWire::Regex(value.to_owned())) } fn visit_string(self, value: String) -> Result where E: DeError, { Ok(FilePatternWire::Regex(value)) } fn visit_map(self, mut map: M) -> Result where M: MapAccess<'de>, { let mut glob = None; while let Some(key) = map.next_key::()? { match key.as_str() { "glob" => { if glob.is_some() { return Err(M::Error::duplicate_field("glob")); } glob = Some(map.next_value_seed(GlobFieldVisitor)?); } _ => { return Err(M::Error::unknown_field(&key, &["glob"])); } } } glob.ok_or_else(|| M::Error::missing_field("glob")) } } deserializer.deserialize_any(FilePatternVisitor) } } #[derive(Debug, thiserror::Error)] enum FilePatternWireError { #[error(transparent)] Glob(#[from] globset::Error), #[error(transparent)] Regex(#[from] fancy_regex::Error), } #[derive(Debug, Clone, Deserialize)] #[serde(try_from = "FilePatternWire")] pub(crate) enum FilePattern { Regex(Regex), Glob(GlobPatterns), } impl FilePattern { pub(crate) fn new_glob(patterns: Vec) -> Result { Ok(Self::Glob(GlobPatterns::new(patterns)?)) } pub(crate) fn new_regex(pattern: &str) -> Result { Ok(Self::Regex(Regex::new(pattern)?)) } pub(crate) fn is_match(&self, str: &str) -> bool { match self { FilePattern::Regex(regex) => regex.is_match(str).unwrap_or(false), FilePattern::Glob(globs) => globs.is_match(str), } } } impl Display for FilePattern { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { FilePattern::Regex(regex) => write!(f, "regex: {}", regex.as_str()), FilePattern::Glob(globs) => { let patterns = globs.patterns.iter().join(", "); write!(f, "glob: [{patterns}]") } } } } impl TryFrom for FilePattern { type Error = FilePatternWireError; fn try_from(value: FilePatternWire) -> Result { match value { FilePatternWire::Glob { glob } => Ok(Self::Glob(GlobPatterns::new(vec![glob])?)), FilePatternWire::GlobList { glob } => Ok(Self::Glob(GlobPatterns::new(glob)?)), FilePatternWire::Regex(pattern) => Ok(Self::Regex(Regex::new(&pattern)?)), } } } #[derive( Debug, Copy, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, clap::ValueEnum, strum::AsRefStr, strum::Display, )] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub enum Language { Bun, Conda, Coursier, Dart, Deno, Docker, DockerImage, Dotnet, Fail, Golang, Haskell, Julia, Lua, Node, Perl, Pygrep, Python, R, Ruby, Rust, #[serde(alias = "unsupported_script")] Script, Swift, #[serde(alias = "unsupported")] System, } #[derive( Debug, Clone, Copy, Default, Deserialize, clap::ValueEnum, strum::AsRefStr, strum::Display, )] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub(crate) enum HookType { CommitMsg, PostCheckout, PostCommit, PostMerge, PostRewrite, #[default] PreCommit, PreMergeCommit, PrePush, PreRebase, PrepareCommitMsg, } impl HookType { /// Return the number of arguments this hook type expects. pub fn num_args(self) -> RangeInclusive { match self { Self::CommitMsg => 1..=1, Self::PostCheckout => 3..=3, Self::PreCommit => 0..=0, Self::PostCommit => 0..=0, Self::PreMergeCommit => 0..=0, Self::PostMerge => 1..=1, Self::PostRewrite => 1..=1, Self::PrePush => 2..=2, Self::PreRebase => 1..=2, Self::PrepareCommitMsg => 1..=3, } } } #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Hash, Deserialize, Serialize, clap::ValueEnum, strum::AsRefStr, strum::Display, )] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub(crate) enum Stage { Manual, CommitMsg, PostCheckout, PostCommit, PostMerge, PostRewrite, #[default] #[serde(alias = "commit")] PreCommit, #[serde(alias = "merge-commit")] PreMergeCommit, #[serde(alias = "push")] PrePush, PreRebase, PrepareCommitMsg, } impl From for Stage { fn from(value: HookType) -> Self { match value { HookType::CommitMsg => Self::CommitMsg, HookType::PostCheckout => Self::PostCheckout, HookType::PostCommit => Self::PostCommit, HookType::PostMerge => Self::PostMerge, HookType::PostRewrite => Self::PostRewrite, HookType::PreCommit => Self::PreCommit, HookType::PreMergeCommit => Self::PreMergeCommit, HookType::PrePush => Self::PrePush, HookType::PreRebase => Self::PreRebase, HookType::PrepareCommitMsg => Self::PrepareCommitMsg, } } } impl Stage { pub fn operate_on_files(self) -> bool { matches!( self, Stage::Manual | Stage::CommitMsg | Stage::PreCommit | Stage::PreMergeCommit | Stage::PrePush | Stage::PrepareCommitMsg ) } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) enum Stages { All, Some(BTreeSet), } impl Stages { pub(crate) fn is_empty(&self) -> bool { matches!(self, Self::Some(stages) if stages.is_empty()) } pub(crate) fn contains(&self, stage: Stage) -> bool { match self { Self::All => true, Self::Some(stages) => stages.contains(&stage), } } pub(crate) fn to_vec(&self) -> Vec { match self { Self::All => Stage::value_variants().to_vec(), Self::Some(stages) => stages.iter().copied().collect(), } } } impl Display for Stages { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::All => write!(f, "all"), Self::Some(stages) => { if stages.is_empty() { write!(f, "none") } else { let stages_str = stages.iter().map(ToString::to_string).join(", "); write!(f, "{stages_str}") } } } } } impl From> for Stages { fn from(value: Vec) -> Self { let stages: BTreeSet<_> = value.into_iter().collect(); if stages.len() == Stage::value_variants().len() { Self::All } else { Self::Some(stages) } } } impl From<[Stage; N]> for Stages { fn from(value: [Stage; N]) -> Self { Self::from(Vec::from(value)) } } impl<'de> Deserialize<'de> for Stages { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let stages = Vec::::deserialize(deserializer)?; Ok(Self::from(stages)) } } /// Controls whether filenames are appended to a hook's command line. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PassFilenames { /// Pass all matching filenames (default). Corresponds to `pass_filenames: /// true`. All, /// Pass no filenames. Corresponds to `pass_filenames: false`. None, /// Pass at most `n` filenames per invocation. Corresponds to /// `pass_filenames: n`. Limited(std::num::NonZeroUsize), } impl<'de> Deserialize<'de> for PassFilenames { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { struct PassFilenamesVisitor; impl serde::de::Visitor<'_> for PassFilenamesVisitor { type Value = PassFilenames; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("a boolean or a positive integer") } fn visit_bool(self, v: bool) -> Result { Ok(if v { PassFilenames::All } else { PassFilenames::None }) } fn visit_u64(self, v: u64) -> Result { let n = usize::try_from(v) .ok() .and_then(std::num::NonZeroUsize::new) .ok_or_else(|| { E::custom( "pass_filenames must be a positive integer; use `false` to pass no filenames", ) })?; Ok(PassFilenames::Limited(n)) } fn visit_i64(self, v: i64) -> Result { if v <= 0 { return Err(E::custom( "pass_filenames must be a positive integer; use `false` to pass no filenames", )); } #[allow(clippy::cast_sign_loss)] self.visit_u64(v as u64) } } deserializer.deserialize_any(PassFilenamesVisitor) } } /// Common hook options. #[derive(Debug, Clone, Default, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub(crate) struct HookOptions { /// Not documented in the official docs. pub alias: Option, /// The pattern of files to run on. pub files: Option, /// Exclude files that were matched by `files`. /// Default is `$^`, which matches nothing. pub exclude: Option, /// List of file types to run on (AND). /// Default is `[file]`, which matches all files. pub types: Option, /// List of file types to run on (OR). /// Default is `[]`. pub types_or: Option, /// List of file types to exclude. /// Default is `[]`. pub exclude_types: Option, /// Not documented in the official docs. pub additional_dependencies: Option>, /// Additional arguments to pass to the hook. pub args: Option>, /// Environment variables to set for the hook. pub env: Option>, /// This hook will run even if there are no matching files. /// Default is false. pub always_run: Option, /// If this hook fails, don't run any more hooks. /// Default is false. pub fail_fast: Option, /// Append filenames that would be checked to the hook entry as arguments. /// Default is true. pub pass_filenames: Option, /// A description of the hook. For metadata only. pub description: Option, /// Run the hook on a specific version of the language. /// Default is `default`. /// See . pub language_version: Option, /// Write the output of the hook to a file when the hook fails or verbose is enabled. pub log_file: Option, /// This hook will execute using a single process instead of in parallel. /// Default is false. pub require_serial: Option, /// Select which Git hook stages this hook runs for. /// Default all stages are selected. /// See . pub stages: Option, /// Print the output of the hook even if it passes. /// Default is false. pub verbose: Option, /// The minimum version of prek required to run this hook. #[serde(deserialize_with = "deserialize_and_validate_minimum_version", default)] pub minimum_prek_version: Option, #[serde(skip_serializing, flatten)] pub _unused_keys: BTreeMap, } impl HookOptions { pub fn update(&mut self, other: &Self) { macro_rules! update_if_some { ($($field:ident),* $(,)?) => { $( if other.$field.is_some() { self.$field.clone_from(&other.$field); } )* }; } update_if_some!( alias, files, exclude, types, types_or, exclude_types, additional_dependencies, args, always_run, fail_fast, pass_filenames, description, language_version, log_file, require_serial, stages, verbose, minimum_prek_version, ); // Merge environment variables. if let Some(other_env) = &other.env { if let Some(self_env) = &mut self.env { self_env.extend(other_env.clone()); } else { self.env.clone_from(&other.env); } } } } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub(crate) struct ManifestHook { /// The id of the hook. pub id: String, /// The name of the hook. pub name: String, /// The command to run. It can contain arguments that will not be overridden. pub entry: String, /// The language of the hook. Tells prek how to install and run the hook. pub language: Language, #[serde(flatten)] pub options: HookOptions, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "snake_case")] #[serde(transparent)] pub(crate) struct Manifest { pub hooks: Vec, } /// A remote hook in the configuration file. /// /// All keys in manifest hook dict are valid in a config hook dict, but are optional. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub(crate) struct RemoteHook { /// The id of the hook. pub id: String, /// Override the name of the hook. pub name: Option, /// Override the entrypoint. Not documented in the official docs but works. pub entry: Option, /// Override the language. Not documented in the official docs but works. pub language: Option, /// Priority used by the scheduler to determine ordering and concurrency. /// Hooks with the same priority can run in parallel. /// /// This is only allowed in project config files (e.g. `.pre-commit-config.yaml`). /// It is not allowed in manifests (e.g. `.pre-commit-hooks.yaml`). pub priority: Option, #[serde(flatten)] pub options: HookOptions, } /// A local hook in the configuration file. /// /// This is similar to `ManifestHook`, but includes config-only fields (like `priority`). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub(crate) struct LocalHook { /// The id of the hook. pub id: String, /// The name of the hook. pub name: String, /// The command to run. It can contain arguments that will not be overridden. pub entry: String, /// The language of the hook. Tells prek how to install and run the hook. pub language: Language, /// Priority used by the scheduler to determine ordering and concurrency. /// Hooks with the same priority can run in parallel. pub priority: Option, #[serde(flatten)] pub options: HookOptions, } /// A meta hook predefined in pre-commit. /// /// It's the same as the manifest hook definition but with only a few predefined id allowed. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "snake_case")] #[serde(try_from = "RemoteHook")] pub(crate) struct MetaHook { /// The id of the hook. pub id: String, /// The name of the hook. pub name: String, /// Priority used by the scheduler to determine ordering and concurrency. /// Hooks with the same priority can run in parallel. pub priority: Option, #[serde(flatten)] pub options: HookOptions, } #[derive(Debug, thiserror::Error)] pub(crate) enum PredefinedHookWireError { #[error("unknown {kind} hook id `{id}`")] UnknownId { kind: PredefinedHookKind, id: String, }, #[error("language must be `system` for {kind} hooks")] InvalidLanguage { kind: PredefinedHookKind }, #[error("`entry` is not allowed for {kind} hooks")] EntryNotAllowed { kind: PredefinedHookKind }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PredefinedHookKind { Meta, Builtin, } impl Display for PredefinedHookKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Meta => f.write_str("meta"), Self::Builtin => f.write_str("builtin"), } } } impl TryFrom for MetaHook { type Error = PredefinedHookWireError; fn try_from(hook_options: RemoteHook) -> Result { let mut meta_hook = MetaHook::from_id(&hook_options.id).map_err(|()| { PredefinedHookWireError::UnknownId { kind: PredefinedHookKind::Meta, id: hook_options.id.clone(), } })?; if hook_options.language.is_some_and(|l| l != Language::System) { return Err(PredefinedHookWireError::InvalidLanguage { kind: PredefinedHookKind::Meta, }); } if hook_options.entry.is_some() { return Err(PredefinedHookWireError::EntryNotAllowed { kind: PredefinedHookKind::Meta, }); } if let Some(name) = &hook_options.name { meta_hook.name.clone_from(name); } if hook_options.priority.is_some() { meta_hook.priority = hook_options.priority; } meta_hook.options.update(&hook_options.options); Ok(meta_hook) } } /// A builtin hook predefined in prek. /// Basically the same as meta hooks, but defined under `builtin` repo, and do other non-meta checks. #[derive(Debug, Clone, Deserialize)] #[serde(try_from = "RemoteHook")] pub(crate) struct BuiltinHook { /// The id of the hook. pub id: String, /// The name of the hook. /// /// This is populated from the predefined builtin hook definition. pub name: String, /// The command to run. It can contain arguments that will not be overridden. pub entry: String, /// Priority used by the scheduler to determine ordering and concurrency. /// Hooks with the same priority can run in parallel. pub priority: Option, /// Common hook options. /// /// Builtin hooks allow the same set of options overrides as other hooks. #[serde(flatten)] pub options: HookOptions, } impl TryFrom for BuiltinHook { type Error = PredefinedHookWireError; fn try_from(hook_options: RemoteHook) -> Result { let mut builtin_hook = BuiltinHook::from_id(&hook_options.id).map_err(|()| { PredefinedHookWireError::UnknownId { kind: PredefinedHookKind::Builtin, id: hook_options.id.clone(), } })?; if hook_options.language.is_some_and(|l| l != Language::System) { return Err(PredefinedHookWireError::InvalidLanguage { kind: PredefinedHookKind::Builtin, }); } if hook_options.entry.is_some() { return Err(PredefinedHookWireError::EntryNotAllowed { kind: PredefinedHookKind::Builtin, }); } if let Some(name) = &hook_options.name { builtin_hook.name.clone_from(name); } if hook_options.priority.is_some() { builtin_hook.priority = hook_options.priority; } builtin_hook.options.update(&hook_options.options); Ok(builtin_hook) } } #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub(crate) struct RemoteRepo { #[cfg_attr(feature = "schemars", schemars(schema_with = "schema_repo_remote"))] pub repo: String, pub rev: String, #[serde(skip_serializing)] pub hooks: Vec, #[serde(skip_serializing, flatten)] _unused_keys: BTreeMap, } impl RemoteRepo { pub fn new(repo: String, rev: String, hooks: Vec) -> Self { Self { repo, rev, hooks, _unused_keys: BTreeMap::new(), } } } impl PartialEq for RemoteRepo { fn eq(&self, other: &Self) -> bool { self.repo == other.repo && self.rev == other.rev } } impl Eq for RemoteRepo {} impl std::hash::Hash for RemoteRepo { fn hash(&self, state: &mut H) { self.repo.hash(state); self.rev.hash(state); } } impl Display for RemoteRepo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}@{}", self.repo, self.rev) } } #[derive(Debug, Clone, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub(crate) struct LocalRepo { #[cfg_attr(feature = "schemars", schemars(schema_with = "schema_repo_local"))] pub repo: String, pub hooks: Vec, #[serde(skip_serializing, flatten)] _unused_keys: BTreeMap, } impl Display for LocalRepo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("local") } } #[derive(Debug, Clone, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub(crate) struct MetaRepo { #[cfg_attr(feature = "schemars", schemars(schema_with = "schema_repo_meta"))] pub repo: String, pub hooks: Vec, #[serde(skip_serializing, flatten)] _unused_keys: BTreeMap, } impl Display for MetaRepo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("meta") } } #[derive(Debug, Clone, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub(crate) struct BuiltinRepo { #[cfg_attr(feature = "schemars", schemars(schema_with = "schema_repo_builtin"))] pub repo: String, pub hooks: Vec, #[serde(skip_serializing, flatten)] _unused_keys: BTreeMap, } #[derive(Debug, Clone)] pub(crate) enum Repo { Remote(RemoteRepo), Local(LocalRepo), Meta(MetaRepo), Builtin(BuiltinRepo), } impl<'de> Deserialize<'de> for Repo { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { struct RepoVisitor; impl<'de> Visitor<'de> for RepoVisitor { type Value = Repo; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a repo mapping") } fn visit_map(self, mut map: M) -> Result where M: MapAccess<'de>, { enum HooksValue { Remote(Vec), Local(Vec), Meta(Vec), Builtin(Vec), } let mut repo: Option = None; let mut rev: Option = None; let mut hooks: Option = None; let mut unused = BTreeMap::new(); while let Some(key) = map.next_key::()? { match key.as_str() { "repo" => { let repo_value: String = map.next_value()?; repo = Some(repo_value); } "rev" => { rev = Some(map.next_value()?); } "hooks" => { hooks = Some(match repo.as_deref() { Some("local") => HooksValue::Local(map.next_value()?), Some("meta") => HooksValue::Meta(map.next_value()?), Some("builtin") => HooksValue::Builtin(map.next_value()?), // Not seen `repo` yet, assume remote. _ => HooksValue::Remote(map.next_value()?), }); } _ => { let value = map.next_value::()?; unused.insert(key, value); } } } let repo_value = repo.ok_or_else(|| M::Error::missing_field("repo"))?; match repo_value.as_str() { "local" => { if rev.is_some() { return Err(M::Error::custom("`rev` is not allowed for local repos")); } let hooks = match hooks.ok_or_else(|| M::Error::missing_field("hooks"))? { HooksValue::Local(hooks) => hooks, HooksValue::Remote(hooks) => hooks .into_iter() .map(remote_hook_to_local::) .collect::, _>>()?, HooksValue::Meta(_) | HooksValue::Builtin(_) => { return Err(M::Error::custom("invalid hooks for local repo")); } }; Ok(Repo::Local(LocalRepo { repo: "local".to_string(), hooks, _unused_keys: unused, })) } "meta" => { if rev.is_some() { return Err(M::Error::custom("`rev` is not allowed for meta repos")); } let hooks = match hooks.ok_or_else(|| M::Error::missing_field("hooks"))? { HooksValue::Meta(hooks) => hooks, HooksValue::Remote(hooks) => hooks .into_iter() .map(|hook| MetaHook::try_from(hook).map_err(M::Error::custom)) .collect::, _>>()?, HooksValue::Local(_) | HooksValue::Builtin(_) => { return Err(M::Error::custom("invalid hooks for meta repo")); } }; Ok(Repo::Meta(MetaRepo { repo: "meta".to_string(), hooks, _unused_keys: unused, })) } "builtin" => { if rev.is_some() { return Err(M::Error::custom("`rev` is not allowed for builtin repos")); } let hooks = match hooks.ok_or_else(|| M::Error::missing_field("hooks"))? { HooksValue::Builtin(hooks) => hooks, HooksValue::Remote(hooks) => hooks .into_iter() .map(|hook| BuiltinHook::try_from(hook).map_err(M::Error::custom)) .collect::, _>>()?, HooksValue::Local(_) | HooksValue::Meta(_) => { return Err(M::Error::custom("invalid hooks for builtin repo")); } }; Ok(Repo::Builtin(BuiltinRepo { repo: "builtin".to_string(), hooks, _unused_keys: unused, })) } _ => { let rev = rev.ok_or_else(|| M::Error::missing_field("rev"))?; let hooks = match hooks.ok_or_else(|| M::Error::missing_field("hooks"))? { HooksValue::Remote(hooks) => hooks, HooksValue::Local(_) | HooksValue::Meta(_) | HooksValue::Builtin(_) => { return Err(M::Error::custom("invalid hooks for remote repo")); } }; Ok(Repo::Remote(RemoteRepo { repo: repo_value, rev, hooks, _unused_keys: unused, })) } } } } deserializer.deserialize_map(RepoVisitor) } } fn remote_hook_to_local(hook: RemoteHook) -> Result where E: DeError, { Ok(LocalHook { id: hook.id, name: hook.name.ok_or_else(|| E::missing_field("name"))?, entry: hook.entry.ok_or_else(|| E::missing_field("entry"))?, language: hook.language.ok_or_else(|| E::missing_field("language"))?, priority: hook.priority, options: hook.options, }) } // TODO: warn sensible regex #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "snake_case")] #[cfg_attr( feature = "schemars", derive(schemars::JsonSchema), schemars(title = "prek.toml"), schemars(description = "The configuration file for prek, a git hook manager written in Rust."), schemars(extend("$id" = "https://www.schemastore.org/prek.json")), schemars(extend("x-tombi-toml-version" = "v1.1.0")), )] pub(crate) struct Config { pub repos: Vec, /// A list of `--hook-types` which will be used by default when running `prek install`. /// Default is `[pre-commit]`. pub default_install_hook_types: Option>, /// A mapping from language to the default `language_version`. pub default_language_version: Option>, /// A configuration-wide default for the stages property of hooks. /// Default to all stages. pub default_stages: Option, /// Global file include pattern. pub files: Option, /// Global file exclude pattern. pub exclude: Option, /// Set to true to have prek stop running hooks after the first failure. /// Default is false. pub fail_fast: Option, /// The minimum version of prek required to run this configuration. #[serde(deserialize_with = "deserialize_and_validate_minimum_version", default)] pub minimum_prek_version: Option, /// Set to true to isolate this project from parent configurations in workspace mode. /// When true, files in this project are "consumed" by this project and will not be processed /// by parent projects. /// When false (default), files in subprojects are processed by both the subproject and /// any parent projects that contain them. pub orphan: Option, #[serde(skip_serializing, flatten)] _unused_keys: BTreeMap, } #[derive(Debug, thiserror::Error)] pub(crate) enum Error { #[error(transparent)] Io(#[from] std::io::Error), #[error("Failed to parse `{0}`")] Yaml(String, #[source] Box), #[error("Failed to parse `{0}`")] Toml(String, #[source] Box), } impl Error { /// Warn the user if the config error is a parse error (not "file not found"). pub(crate) fn warn_parse_error(&self) { // Skip file not found errors. if matches!(self, Self::Io(e) if e.kind() == std::io::ErrorKind::NotFound) { return; } if let Some(cause) = self.source() { warn_user_once!("{self}: {cause}"); } else { warn_user_once!("{self}"); } } } /// Keys that prek does not use. const EXPECTED_UNUSED: &[&str] = &["minimum_pre_commit_version", "ci"]; fn push_unused_paths<'a, I>(acc: &mut Vec, prefix: &str, keys: I) where I: Iterator, { for key in keys { let path = if prefix.is_empty() { key.to_string() } else { format!("{prefix}.{key}") }; acc.push(path); } } fn collect_unused_paths(config: &Config) -> Vec { let mut paths = Vec::new(); push_unused_paths( &mut paths, "", config._unused_keys.keys().filter_map(|key| { let key = key.as_str(); (!EXPECTED_UNUSED.contains(&key)).then_some(key) }), ); for (repo_idx, repo) in config.repos.iter().enumerate() { let repo_prefix = format!("repos[{repo_idx}]"); let (repo_unused_keys, hooks_options): (_, Box>) = match repo { Repo::Remote(remote) => ( &remote._unused_keys, Box::new(remote.hooks.iter().map(|h| &h.options)), ), Repo::Local(local) => ( &local._unused_keys, Box::new(local.hooks.iter().map(|h| &h.options)), ), Repo::Meta(meta) => ( &meta._unused_keys, Box::new(meta.hooks.iter().map(|h| &h.options)), ), Repo::Builtin(builtin) => ( &builtin._unused_keys, Box::new(builtin.hooks.iter().map(|h| &h.options)), ), }; push_unused_paths( &mut paths, &repo_prefix, repo_unused_keys.keys().map(String::as_str), ); for (hook_idx, options) in hooks_options.enumerate() { let hook_prefix = format!("{repo_prefix}.hooks[{hook_idx}]"); push_unused_paths( &mut paths, &hook_prefix, options._unused_keys.keys().map(String::as_str), ); } } paths } fn warn_unused_paths(path: &Path, entries: &[String]) { if entries.is_empty() { return; } if entries.len() < 4 { let inline = entries .iter() .map(|entry| format!("`{}`", entry.yellow())) .join(", "); warn_user!( "Ignored unexpected keys in `{}`: {inline}", path.user_display().cyan() ); } else { let list = entries .iter() .map(|entry| format!(" - `{}`", entry.yellow())) .join("\n"); warn_user!( "Ignored unexpected keys in `{}`:\n{list}", path.user_display().cyan() ); } } /// Read the configuration file from the given path. pub(crate) fn load_config(path: &Path) -> Result { let content = fs_err::read_to_string(path)?; let config = match path.extension() { Some(ext) if ext.eq_ignore_ascii_case("toml") => toml::from_str(&content) .map_err(|e| Error::Toml(path.user_display().to_string(), Box::new(e)))?, _ => serde_saphyr::from_str(&content) .map_err(|e| Error::Yaml(path.user_display().to_string(), Box::new(e)))?, }; Ok(config) } /// Read the configuration file from the given path, and warn about certain issues. pub(crate) fn read_config(path: &Path) -> Result { let config = load_config(path)?; let unused_paths = collect_unused_paths(&config); warn_unused_paths(path, &unused_paths); // Check for mutable revs and warn the user. let repos_has_mutable_rev = config .repos .iter() .filter_map(|repo| { if let Repo::Remote(repo) = repo { let rev = &repo.rev; // A rev is considered mutable if it doesn't contain a '.' (like a version) // and is not a hexadecimal string (like a commit SHA). if !rev.contains('.') && !looks_like_sha(rev) { return Some(repo); } } None }) .collect::>(); if !repos_has_mutable_rev.is_empty() { let msg = repos_has_mutable_rev .iter() .map(|repo| format!("{}: {}", repo.repo.cyan(), repo.rev.yellow())) .join("\n"); warn_user!( "{}", indoc::formatdoc! { r#" The following repos have mutable `rev` fields (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 for more details. hint: `prek auto-update` often fixes this", "#, msg } ); } Ok(config) } /// Read the manifest file from the given path. pub(crate) fn read_manifest(path: &Path) -> Result { let content = fs_err::read_to_string(path)?; let manifest: Manifest = serde_saphyr::from_str(&content) .map_err(|e| Error::Yaml(path.user_display().to_string(), Box::new(e)))?; Ok(manifest) } /// Check if a string looks like a git SHA fn looks_like_sha(s: &str) -> bool { !s.is_empty() && s.as_bytes().iter().all(u8::is_ascii_hexdigit) } fn deserialize_and_validate_minimum_version<'de, D>( deserializer: D, ) -> Result, D::Error> where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; if s.is_empty() { return Ok(None); } let version = s .parse::() .map_err(serde::de::Error::custom)?; let cur_version = version::version() .version .parse::() .expect("Invalid prek version"); if version > cur_version { let hint = InstallSource::detect() .map(|s| format!("To update, run `{}`.", s.update_instructions())) .unwrap_or("Please consider updating prek".to_string()); return Err(serde::de::Error::custom(format!( "Required minimum prek version `{version}` is greater than current version `{cur_version}`; {hint}", ))); } Ok(Some(s)) } #[cfg(test)] mod tests { use super::*; use std::io::Write as _; /// Filter to replace dynamic version in snapshots const VERSION_FILTER: (&str, &str) = ( r"current version `\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?`", "current version `[CURRENT_VERSION]`", ); #[test] fn stages_deserialize_empty_as_empty() { #[derive(Debug, Deserialize)] struct Wrapper { stages: Stages, } let parsed: Wrapper = serde_saphyr::from_str("stages: []\n").expect("stages should parse"); assert_eq!(parsed.stages, Stages::Some(BTreeSet::new())); assert!(!parsed.stages.contains(Stage::Manual)); assert!(!parsed.stages.contains(Stage::PreCommit)); } #[test] fn config_default_stages_deserialize_empty_as_empty() { let parsed: Config = serde_saphyr::from_str("repos: []\ndefault_stages: []\n").expect("config should parse"); assert_eq!(parsed.default_stages, Some(Stages::Some(BTreeSet::new()))); } #[test] fn config_default_stages_omitted_keeps_none() { let parsed: Config = serde_saphyr::from_str("repos: []\n").expect("config should parse"); assert_eq!(parsed.default_stages, None); } #[test] fn stages_deserialize_to_subset() { #[derive(Debug, Deserialize)] struct Wrapper { stages: Stages, } let parsed: Wrapper = serde_saphyr::from_str("stages: [pre-commit, manual]\n").expect("stages should parse"); assert!(parsed.stages.contains(Stage::PreCommit)); assert!(parsed.stages.contains(Stage::Manual)); assert!(!parsed.stages.contains(Stage::PrePush)); } #[test] fn parse_file_patterns_regex_and_glob() { #[derive(Debug, Deserialize)] struct Wrapper { files: FilePattern, exclude: FilePattern, } let regex_yaml = indoc::indoc! {r" files: ^src/ exclude: ^target/ "}; let parsed: Wrapper = serde_saphyr::from_str(regex_yaml).expect("regex patterns should parse"); assert!( matches!(parsed.files, FilePattern::Regex(_)), "expected regex pattern" ); assert!(parsed.files.is_match("src/main.rs")); assert!(!parsed.files.is_match("other/main.rs")); assert!(parsed.exclude.is_match("target/debug/app")); let glob_yaml = indoc::indoc! {r" files: glob: src/**/*.rs exclude: glob: target/** "}; let parsed: Wrapper = serde_saphyr::from_str(glob_yaml).expect("glob patterns should parse"); assert!( matches!(parsed.files, FilePattern::Glob(_)), "expected glob pattern" ); assert!(parsed.files.is_match("src/lib/main.rs")); assert!(!parsed.files.is_match("src/lib/main.py")); assert!(parsed.exclude.is_match("target/debug/app")); assert!(!parsed.exclude.is_match("src/lib/main.rs")); let glob_list_yaml = indoc::indoc! {r" files: glob: - src/**/*.rs - crates/**/src/**/*.rs exclude: glob: - target/** - dist/** "}; let parsed: Wrapper = serde_saphyr::from_str(glob_list_yaml).expect("glob list patterns should parse"); assert!(parsed.files.is_match("src/lib/main.rs")); assert!(parsed.files.is_match("crates/foo/src/lib.rs")); assert!(!parsed.files.is_match("tests/main.rs")); assert!(parsed.exclude.is_match("target/debug/app")); assert!(parsed.exclude.is_match("dist/app")); } #[test] fn file_patterns_expose_sources_and_display() { let pattern: FilePattern = serde_saphyr::from_str(indoc::indoc! {r" glob: - src/**/*.rs - crates/**/src/**/*.rs "}) .expect("glob list should parse"); assert_eq!( pattern.to_string(), "glob: [src/**/*.rs, crates/**/src/**/*.rs]" ); assert!(pattern.is_match("src/main.rs")); assert!(pattern.is_match("crates/foo/src/lib.rs")); assert!(!pattern.is_match("tests/main.rs")); } #[test] fn empty_glob_list_matches_nothing() { let pattern = serde_saphyr::from_str::("glob: []").unwrap(); assert!(!pattern.is_match("any/file.rs")); assert!(!pattern.is_match("")); } #[test] fn invalid_glob_pattern_errors() { let err = serde_saphyr::from_str::("glob: \"[\"") .expect_err("invalid glob should fail"); let msg = err.to_string().to_lowercase(); assert!( msg.contains("glob"), "error should mention glob issues: {msg}" ); } #[test] fn parse_repos() { let yaml = indoc::indoc! {r" repos: - repo: local hooks: - id: cargo-fmt name: cargo fmt entry: cargo fmt -- language: system "}; let result = serde_saphyr::from_str::(yaml).unwrap(); insta::assert_debug_snapshot!(result); // Local hook should not have `rev` let yaml = indoc::indoc! {r" repos: - repo: local rev: v1.0.0 hooks: - id: cargo-fmt name: cargo fmt language: system entry: cargo fmt types: - rust "}; // Error on extra `rev` field, but not other fields let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::assert_snapshot!(err, @" error: line 2 column 5: `rev` is not allowed for local repos --> :2:5 | 1 | repos: 2 | - repo: local | ^ `rev` is not allowed for local repos 3 | rev: v1.0.0 4 | hooks: | "); // Allow but warn on extra fields (other than `rev`) let yaml = indoc::indoc! {r" repos: - repo: local unknown_field: some_value hooks: - id: cargo-fmt name: cargo fmt entry: cargo fmt language: system types: - rust "}; let result = serde_saphyr::from_str::(yaml).unwrap(); insta::assert_debug_snapshot!(result); // Remote hook should have `rev`. let yaml = indoc::indoc! {r" repos: - repo: https://github.com/crate-ci/typos rev: v1.0.0 hooks: - id: typos "}; let result = serde_saphyr::from_str::(yaml).unwrap(); insta::assert_debug_snapshot!(result); let yaml = indoc::indoc! {r" repos: - repo: https://github.com/crate-ci/typos hooks: - id: typos "}; let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::assert_snapshot!(err, @" error: line 3 column 5: missing field `rev` --> :3:5 | 1 | repos: 2 | - repo: https://github.com/crate-ci/typos 3 | hooks: | ^ missing field `rev` 4 | - id: typos | "); // Allow `rev` before `repo` let yaml = indoc::indoc! {r" repos: - rev: v1.0.0 repo: https://github.com/crate-ci/typos hooks: - id: typos "}; let result = serde_saphyr::from_str::(yaml).unwrap(); insta::assert_debug_snapshot!(result); let yaml = indoc::indoc! {r" repos: - rev: v1.0.0 repo: local hooks: - id: typos "}; let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::assert_snapshot!(err, @" error: line 5 column 9: missing field `name` --> :5:9 | 3 | repo: local 4 | hooks: 5 | - id: typos | ^ missing field `name` "); let yaml = indoc::indoc! {r" repos: - rev: v1.0.0 repo: meta hooks: - id: typos "}; let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::assert_snapshot!(err, @" error: line 5 column 9: unknown meta hook id `typos` --> :5:9 | 3 | repo: meta 4 | hooks: 5 | - id: typos | ^ unknown meta hook id `typos` "); let yaml = indoc::indoc! {r" repos: - rev: v1.0.0 repo: builtin hooks: - id: typos "}; let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::assert_snapshot!(err, @" error: line 5 column 9: unknown builtin hook id `typos` --> :5:9 | 3 | repo: builtin 4 | hooks: 5 | - id: typos | ^ unknown builtin hook id `typos` "); } #[test] fn parse_hooks() { // Remote hook only `id` is required. let yaml = indoc::indoc! { r" repos: - repo: https://github.com/crate-ci/typos rev: v1.0.0 hooks: - name: typos alias: typo "}; let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::assert_snapshot!(err, @" error: line 6 column 9: missing field `id` --> :6:9 | 4 | hooks: 5 | - name: typos 6 | alias: typo | ^ missing field `id` "); // Local hook should have `id`, `name`, and `entry` and `language`. let yaml = indoc::indoc! { r" repos: - repo: local hooks: - id: cargo-fmt name: cargo fmt entry: cargo fmt types: - rust "}; let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::assert_snapshot!(err, @" error: line 7 column 9: missing field `language` --> :7:9 | 5 | name: cargo fmt 6 | entry: cargo fmt 7 | types: | ^ missing field `language` 8 | - rust | "); let yaml = indoc::indoc! { r" repos: - repo: local hooks: - id: cargo-fmt name: cargo fmt entry: cargo fmt language: rust "}; let result = serde_saphyr::from_str::(yaml).unwrap(); insta::assert_debug_snapshot!(result); } #[test] fn meta_hooks() { // Invalid rev let yaml = indoc::indoc! { r" repos: - repo: meta rev: v1.0.0 hooks: - name: typos alias: typo "}; let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::assert_snapshot!(err, @" error: line 6 column 9: missing field `id` --> :6:9 | 4 | hooks: 5 | - name: typos 6 | alias: typo | ^ missing field `id` "); // Invalid meta hook id let yaml = indoc::indoc! { r" repos: - repo: meta hooks: - id: hello "}; let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::assert_snapshot!(err, @" error: line 4 column 9: unknown meta hook id `hello` --> :4:9 | 2 | - repo: meta 3 | hooks: 4 | - id: hello | ^ unknown meta hook id `hello` "); // Invalid language let yaml = indoc::indoc! { r" repos: - repo: meta hooks: - id: check-hooks-apply language: python "}; let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::assert_snapshot!(err, @" error: line 4 column 9: language must be `system` for meta hooks --> :4:9 | 2 | - repo: meta 3 | hooks: 4 | - id: check-hooks-apply | ^ language must be `system` for meta hooks 5 | language: python | "); // Invalid entry let yaml = indoc::indoc! { r" repos: - repo: meta hooks: - id: check-hooks-apply entry: echo hell world "}; let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::assert_snapshot!(err, @" error: line 4 column 9: `entry` is not allowed for meta hooks --> :4:9 | 2 | - repo: meta 3 | hooks: 4 | - id: check-hooks-apply | ^ `entry` is not allowed for meta hooks 5 | entry: echo hell world | "); // Valid meta hook let yaml = indoc::indoc! { r" repos: - repo: meta hooks: - id: check-hooks-apply - id: check-useless-excludes - id: identity "}; let result = serde_saphyr::from_str::(yaml).unwrap(); insta::assert_debug_snapshot!(result); } #[test] fn language_version() { let yaml = indoc::indoc! { r" repos: - repo: local hooks: - id: hook-1 name: hook 1 entry: echo hello world language: system language_version: default - id: hook-2 name: hook 2 entry: echo hello world language: system language_version: system - id: hook-3 name: hook 3 entry: echo hello world language: system language_version: '3.8' "}; let result = serde_saphyr::from_str::(yaml); insta::assert_debug_snapshot!(result); } #[test] fn test_read_yaml_config() -> Result<()> { let config = read_config(Path::new("tests/fixtures/uv-pre-commit-config.yaml"))?; insta::assert_debug_snapshot!(config); Ok(()) } #[test] fn test_read_toml_config() -> Result<()> { let dir = tempfile::tempdir()?; let toml_path = dir.path().join("prek.toml"); fs_err::write( &toml_path, indoc::indoc! {r#" fail_fast = true [[repos]] repo = "local" [[repos.hooks]] id = "cargo-fmt" name = "cargo fmt" entry = "cargo fmt --" language = "system" [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v6.0.0" hooks = [ { id = "trailing-whitespace" }, { id = "end-of-file-fixer", args = ["--fix", "crlf"] } ] "#}, )?; let config = read_config(&toml_path)?; insta::assert_debug_snapshot!(config); Ok(()) } #[test] fn test_read_invalid_toml_config() { let raw = indoc::indoc! {r#" fail_fast = true [[repos]] repo = "local" [[repos.hooks]] id = "cargo-fmt" name = "cargo fmt" entry = "cargo fmt --" language = "system" [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" hooks = [ { id = "trailing-whitespace" }, { id = "end-of-file-fixer", args = ["--fix", "crlf"] } ] "#}; let err = toml::from_str::(raw).unwrap_err(); insta::assert_snapshot!(err, @" TOML parse error at line 12, column 1 | 12 | [[repos]] | ^^^^^^^^^ missing field `rev` "); let raw = indoc::indoc! {r#" fail_fast = true [[repos]] repo = "local" rev = "v1.0.0" [[repos.hooks]] id = "cargo-fmt" name = "cargo fmt" entry = "cargo fmt --" language = "system" [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v6.0.0" hooks = [ { id = "trailing-whitespace" }, { id = "end-of-file-fixer", args = ["--fix", "crlf"] } ] "#}; let err = toml::from_str::(raw).unwrap_err(); insta::assert_snapshot!(err, @" TOML parse error at line 3, column 1 | 3 | [[repos]] | ^^^^^^^^^ `rev` is not allowed for local repos "); } #[test] fn test_read_manifest() -> Result<()> { let manifest = read_manifest(Path::new("tests/fixtures/uv-pre-commit-hooks.yaml"))?; insta::assert_debug_snapshot!(manifest); Ok(()) } #[test] fn test_minimum_prek_version() { // Test that missing minimum_prek_version field doesn't cause an error let yaml = indoc::indoc! {r" repos: - repo: local hooks: - id: test-hook name: Test Hook entry: echo test language: system "}; let config = serde_saphyr::from_str::(yaml).unwrap(); assert!(config.minimum_prek_version.is_none()); // Test that empty minimum_prek_version field is treated as None let yaml = indoc::indoc! {r" repos: - repo: local hooks: - id: test-hook name: Test Hook entry: echo test language: system minimum_prek_version: '' "}; let config = serde_saphyr::from_str::(yaml).unwrap(); assert!(config.minimum_prek_version.is_none()); // Test that valid minimum_prek_version field works in top-level config let yaml = indoc::indoc! {r" repos: - repo: local hooks: - id: test-hook name: Test Hook entry: echo test language: system minimum_prek_version: '10.0.0' "}; let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::with_settings!({ filters => vec![VERSION_FILTER] }, { insta::assert_snapshot!(err, @" error: line 8 column 23: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek --> :8:23 | 6 | entry: echo test 7 | language: system 8 | minimum_prek_version: '10.0.0' | ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek "); }); // Test that valid minimum_prek_version field works in hook config let yaml = indoc::indoc! {r" - id: test-hook name: Test Hook entry: echo test language: system minimum_prek_version: '10.0.0' "}; let err = serde_saphyr::from_str::(yaml).unwrap_err(); insta::with_settings!({ filters => vec![VERSION_FILTER] }, { insta::assert_snapshot!(err, @" error: line 1 column 3: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek --> :1:3 | 1 | - id: test-hook | ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek 2 | name: Test Hook 3 | entry: echo test | "); }); } #[test] fn test_validate_type_tags() { // Valid tags should parse successfully let yaml_valid = indoc::indoc! { r" repos: - repo: local hooks: - id: my-hook name: My Hook entry: echo language: system types: [python, file] types_or: [text, binary] exclude_types: [symlink] "}; let result = serde_saphyr::from_str::(yaml_valid); assert!(result.is_ok(), "Should parse valid tags successfully"); // Empty lists and missing keys should also be fine let yaml_empty = indoc::indoc! { r" repos: - repo: local hooks: - id: my-hook name: My Hook entry: echo language: system types: [] exclude_types: [] # types_or is missing, which is also valid "}; let result_empty = serde_saphyr::from_str::(yaml_empty); assert!( result_empty.is_ok(), "Should parse empty/missing tags successfully" ); // Invalid tag in 'types' should fail let yaml_invalid_types = indoc::indoc! { r" repos: - repo: local hooks: - id: my-hook name: My Hook entry: echo language: system types: [pythoon] # Deliberate typo "}; let err = serde_saphyr::from_str::(yaml_invalid_types).unwrap_err(); insta::assert_snapshot!(err, @" error: line 4 column 9: Type tag `pythoon` is not recognized. Check for typos or upgrade prek to get new tags. --> :4:9 | 2 | - repo: local 3 | hooks: 4 | - id: my-hook | ^ Type tag `pythoon` is not recognized. Check for typos or upgrade prek to get new tags. 5 | name: My Hook 6 | entry: echo | "); // Invalid tag in 'types_or' should fail let yaml_invalid_types_or = indoc::indoc! { r" repos: - repo: local hooks: - id: my-hook name: My Hook entry: echo language: system types_or: [invalidtag] "}; let err = serde_saphyr::from_str::(yaml_invalid_types_or).unwrap_err(); insta::assert_snapshot!(err, @" error: line 4 column 9: Type tag `invalidtag` is not recognized. Check for typos or upgrade prek to get new tags. --> :4:9 | 2 | - repo: local 3 | hooks: 4 | - id: my-hook | ^ Type tag `invalidtag` is not recognized. Check for typos or upgrade prek to get new tags. 5 | name: My Hook 6 | entry: echo | "); // Invalid tag in 'exclude_types' should fail let yaml_invalid_exclude_types = indoc::indoc! { r" repos: - repo: local hooks: - id: my-hook name: My Hook entry: echo language: system exclude_types: [not-a-real-tag] "}; let err = serde_saphyr::from_str::(yaml_invalid_exclude_types).unwrap_err(); insta::assert_snapshot!(err, @" error: line 4 column 9: Type tag `not-a-real-tag` is not recognized. Check for typos or upgrade prek to get new tags. --> :4:9 | 2 | - repo: local 3 | hooks: 4 | - id: my-hook | ^ Type tag `not-a-real-tag` is not recognized. Check for typos or upgrade prek to get new tags. 5 | name: My Hook 6 | entry: echo | "); } #[test] fn read_config_with_merge_keys() -> Result<()> { let yaml = indoc::indoc! {r#" repos: - repo: local hooks: - id: mypy-local name: Local mypy entry: python tools/pre_commit/mypy.py 0 "local" <<: &mypy_common language: python types_or: [python, pyi] - id: mypy-3.10 name: Mypy 3.10 entry: python tools/pre_commit/mypy.py 1 "3.10" <<: *mypy_common "#}; let mut file = tempfile::NamedTempFile::new()?; file.write_all(yaml.as_bytes())?; let config = read_config(file.path())?; insta::assert_debug_snapshot!(config); Ok(()) } #[test] fn read_config_with_nested_merge_keys() -> Result<()> { let yaml = indoc::indoc! {r" local: &local language: system pass_filenames: false require_serial: true local-commit: &local-commit <<: *local stages: [pre-commit] repos: - repo: local hooks: - id: test-yaml name: Test YAML compatibility entry: prek --help <<: *local-commit "}; let mut file = tempfile::NamedTempFile::new()?; file.write_all(yaml.as_bytes())?; let config = read_config(file.path())?; insta::assert_debug_snapshot!(config); Ok(()) } #[test] fn test_list_with_unindented_square() { let yaml = indoc::indoc! {r#" repos: - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.18.2 hooks: - id: mypy exclude: tests/data args: [ "--pretty", "--show-error-codes" ] additional_dependencies: [ 'keyring==24.2.0', 'nox==2024.03.02', 'pytest', 'types-docutils==0.20.0.3', 'types-setuptools==68.2.0.0', 'types-freezegun==1.1.10', 'types-pyyaml==6.0.12.12', 'typing-extensions', ] "#}; let result = serde_saphyr::from_str::(yaml); assert!(result.is_ok()); } #[test] fn test_numeric_rev_is_parsed_as_string() { // Because we define `rev` as a String, `serde-saphyr` can automatically parse numeric // revs as strings. let yaml = indoc::indoc! {r" repos: - repo: https://github.com/pre-commit/mirrors-mypy rev: 1.0 hooks: - id: mypy "}; let config = serde_saphyr::from_str::(yaml).unwrap(); insta::assert_debug_snapshot!(config); } #[test] fn pass_filenames_zero_is_rejected() { let yaml = indoc::indoc! {r" repos: - repo: local hooks: - id: invalid-pass-filenames-zero name: invalid pass_filenames zero entry: echo language: system pass_filenames: 0 "}; let result = serde_saphyr::from_str::(yaml); assert!(result.is_err()); } #[test] fn pass_filenames_negative_is_rejected() { let yaml = indoc::indoc! {r" repos: - repo: local hooks: - id: invalid-pass-filenames-negative name: invalid pass_filenames negative entry: echo language: system pass_filenames: -1 "}; let result = serde_saphyr::from_str::(yaml); assert!(result.is_err()); } #[test] fn pass_filenames_string_is_rejected() { let yaml = indoc::indoc! {r#" repos: - repo: local hooks: - id: invalid-pass-filenames-string name: invalid pass_filenames string entry: echo language: system pass_filenames: "foo" "#}; let result = serde_saphyr::from_str::(yaml); assert!(result.is_err()); } } ================================================ FILE: crates/prek/src/fs.rs ================================================ // MIT License // // Copyright (c) 2023 Astral Software Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. use std::fmt::Display; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use std::sync::Mutex; use std::time::Duration; use rustc_hash::FxHashMap; #[cfg(test)] use rustc_hash::FxHashSet; use tracing::{debug, error, info, trace}; use crate::cli::reporter; pub static CWD: LazyLock = LazyLock::new(|| std::env::current_dir().expect("The current directory must be exist")); static IN_PROCESS_LOCK_HELD_COUNTS: LazyLock>> = LazyLock::new(Default::default); #[cfg(test)] static LOCK_WARNING_PATHS: LazyLock>> = LazyLock::new(Default::default); // Test-only override: treat contention for specific lock paths as cross-process so we emit the // warning even if the lock is held by the current process. This lets unit tests exercise the // warning logic without spawning another process, and avoids affecting unrelated locks/tests. #[cfg(test)] static FORCE_CROSS_PROCESS_LOCK_WARNING_FOR: LazyLock>> = LazyLock::new(Default::default); /// A file lock that is automatically released when dropped. #[derive(Debug)] pub struct LockedFile { file: fs_err::File, path: PathBuf, } impl LockedFile { /// Inner implementation for [`LockedFile::acquire_blocking`] and [`LockedFile::acquire`]. fn lock_file_blocking( file: fs_err::File, resource: &str, ) -> Result { trace!( resource, path = %file.path().display(), "Checking lock", ); match file.try_lock() { Ok(()) => { debug!(resource, "Acquired lock"); Ok(file) } Err(err) => { // Log error code and enum kind to help debugging more exotic failures if !matches!(err, std::fs::TryLockError::WouldBlock) { trace!(error = ?err, "Try lock error"); } info!( resource, path = %file.path().display(), "Waiting to acquire lock", ); file.lock().map_err(|err| { // Not a fs_err method, we need to build our own path context std::io::Error::other(format!( "Could not acquire lock for `{resource}` at `{}`: {}", file.path().display(), err )) })?; trace!(resource, "Acquired lock"); Ok(file) } } } /// Acquire a cross-process lock for a resource using a file at the provided path. pub async fn acquire( path: impl AsRef, resource: impl Display, ) -> Result { let path = path.as_ref().to_path_buf(); let file = fs_err::File::create(&path)?; let resource = resource.to_string(); let mut task = tokio::task::spawn_blocking(move || Self::lock_file_blocking(file, &resource)); let warning_path = path.clone(); let file = tokio::select! { result = &mut task => result??, () = tokio::time::sleep(Duration::from_secs(1)) => { let held_by_this_process = { let held_by_this_process = IN_PROCESS_LOCK_HELD_COUNTS .lock() .unwrap() .get(&warning_path) .is_some_and(|count| *count > 0); #[cfg(test)] { let forced_cross_process = FORCE_CROSS_PROCESS_LOCK_WARNING_FOR .lock() .unwrap() .contains(&warning_path); if forced_cross_process { false } else { held_by_this_process } } #[cfg(not(test))] { held_by_this_process } }; if !held_by_this_process { reporter::suspend(move || { #[cfg(test)] { LOCK_WARNING_PATHS.lock().unwrap().insert(warning_path); } #[cfg(not(test))] { crate::warn_user!( "Waiting to acquire lock at `{}`. Another prek process may still be running", warning_path.display() ); } }); } task.await?? } }; { let mut held = IN_PROCESS_LOCK_HELD_COUNTS.lock().unwrap(); *held.entry(path.clone()).or_insert(0) += 1; } Ok(Self { file, path }) } } impl Drop for LockedFile { fn drop(&mut self) { if let Err(err) = self.file.file().unlock() { error!( "Failed to unlock {}; program may be stuck: {}", self.file.path().display(), err ); } else { let mut held = IN_PROCESS_LOCK_HELD_COUNTS.lock().unwrap(); if let Some(count) = held.get_mut(&self.path) { *count = count.saturating_sub(1); if *count == 0 { held.remove(&self.path); } } trace!(path = %self.file.path().display(), "Released lock"); } } } /// Normalizes a path to use `/` as a separator everywhere, even on platforms /// that recognize other characters as separators. #[cfg(unix)] pub(crate) fn normalize_path(path: PathBuf) -> PathBuf { // UNIX only uses /, so we're good. path } /// Normalizes a path to use `/` as a separator everywhere, even on platforms /// that recognize other characters as separators. #[cfg(not(unix))] pub(crate) fn normalize_path(path: PathBuf) -> PathBuf { use std::ffi::OsString; use std::path::is_separator; let mut path = path.into_os_string().into_encoded_bytes(); for c in &mut path { if *c == b'/' || !is_separator(char::from(*c)) { continue; } *c = b'/'; } match String::from_utf8(path) { Ok(s) => PathBuf::from(s), Err(e) => { let path = e.into_bytes(); PathBuf::from(OsString::from(String::from_utf8_lossy(&path).as_ref())) } } } /// Compute a path describing `path` relative to `base`. /// /// `lib/python/site-packages/foo/__init__.py` and `lib/python/site-packages` -> `foo/__init__.py` /// `lib/marker.txt` and `lib/python/site-packages` -> `../../marker.txt` /// `bin/foo_launcher` and `lib/python/site-packages` -> `../../../bin/foo_launcher` /// /// Returns `Err` if there is no relative path between `path` and `base` (for example, if the paths /// are on different drives on Windows). pub fn relative_to( path: impl AsRef, base: impl AsRef, ) -> Result { // Find the longest common prefix, and also return the path stripped from that prefix let (stripped, common_prefix) = base .as_ref() .ancestors() .find_map(|ancestor| { // Simplifying removes the UNC path prefix on windows. dunce::simplified(path.as_ref()) .strip_prefix(dunce::simplified(ancestor)) .ok() .map(|stripped| (stripped, ancestor)) }) .ok_or_else(|| { std::io::Error::other(format!( "Trivial strip failed: {} vs. {}", path.as_ref().display(), base.as_ref().display() )) })?; // go as many levels up as required let levels_up = base.as_ref().components().count() - common_prefix.components().count(); let up = std::iter::repeat_n("..", levels_up).collect::(); Ok(up.join(stripped)) } pub trait Simplified { /// Simplify a [`Path`]. /// /// On Windows, this will strip the `\\?\` prefix from paths. On other platforms, it's a no-op. fn simplified(&self) -> &Path; /// Render a [`Path`] for display. /// /// On Windows, this will strip the `\\?\` prefix from paths. On other platforms, it's /// equivalent to [`std::path::Display`]. fn simplified_display(&self) -> impl Display; /// Render a [`Path`] for user-facing display. /// /// Like [`simplified_display`], but relativizes the path against the current working directory. fn user_display(&self) -> impl Display; } impl> Simplified for T { fn simplified(&self) -> &Path { dunce::simplified(self.as_ref()) } fn simplified_display(&self) -> impl Display { dunce::simplified(self.as_ref()).display() } fn user_display(&self) -> impl Display { let path = dunce::simplified(self.as_ref()); // If current working directory is root, display the path as-is. if CWD.ancestors().nth(1).is_none() { return path.display(); } // Attempt to strip the current working directory, then the canonicalized current working // directory, in case they differ. let path = path.strip_prefix(CWD.simplified()).unwrap_or(path); path.display() } } #[cfg(test)] mod tests { use std::time::Duration; #[tokio::test] async fn lock_warning_suppressed_for_in_process_contention() { let tmp = tempfile::tempdir().expect("tempdir"); let lock_path = tmp.path().join(".lock"); // First acquire should succeed immediately. let lock1 = super::LockedFile::acquire(&lock_path, "test-lock") .await .expect("acquire lock1"); let held_count = super::IN_PROCESS_LOCK_HELD_COUNTS .lock() .unwrap() .get(&lock_path) .copied(); assert_eq!( held_count, Some(1), "expected held-count to be set after first acquire" ); // Second acquire should block, but since the lock is held by this process, it should NOT // trigger the "Another prek process" warning. let lock_path2 = lock_path.clone(); let task = tokio::spawn(async move { super::LockedFile::acquire(lock_path2, "test-lock").await }); tokio::time::sleep(Duration::from_millis(1100)).await; let warning = super::LOCK_WARNING_PATHS .lock() .unwrap() .contains(&lock_path); assert!( !warning, "expected no warning for in-process contention, got: {warning:?}" ); drop(lock1); task.await.expect("join task").expect("acquire lock2"); } #[tokio::test] async fn lock_warning_emitted_when_forced_cross_process() { let tmp = tempfile::tempdir().expect("tempdir"); let lock_path = tmp.path().join(".lock"); super::FORCE_CROSS_PROCESS_LOCK_WARNING_FOR .lock() .unwrap() .insert(lock_path.clone()); // First acquire should succeed immediately. let lock1 = super::LockedFile::acquire(&lock_path, "test-lock") .await .expect("acquire lock1"); // Second acquire should block and emit the warning due to the forced override. let lock_path2 = lock_path.clone(); let task = tokio::spawn(async move { super::LockedFile::acquire(lock_path2, "test-lock").await }); tokio::time::sleep(Duration::from_millis(1100)).await; let warning = super::LOCK_WARNING_PATHS .lock() .unwrap() .contains(&lock_path); assert!( warning, "expected warning when forced cross-process mode is enabled" ); // Cleanup. super::FORCE_CROSS_PROCESS_LOCK_WARNING_FOR .lock() .unwrap() .remove(&lock_path); drop(lock1); task.await.expect("join task").expect("acquire lock2"); } } ================================================ FILE: crates/prek/src/git.rs ================================================ use std::borrow::Cow; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::str::Utf8Error; use std::sync::LazyLock; use anyhow::Result; use path_clean::PathClean; use prek_consts::env_vars::EnvVars; use rustc_hash::FxHashSet; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tracing::{debug, instrument, warn}; use crate::process; use crate::process::{Cmd, StatusError}; #[derive(Debug, thiserror::Error)] pub(crate) enum Error { #[error(transparent)] Command(#[from] process::Error), #[error("Failed to find git: {0}")] GitNotFound(#[from] which::Error), #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] UTF8(#[from] Utf8Error), } pub(crate) static GIT: LazyLock> = LazyLock::new(|| which::which("git")); pub(crate) static GIT_ROOT: LazyLock> = LazyLock::new(|| { get_root().inspect(|root| { debug!("Git root: {}", root.display()); }) }); /// Remove some `GIT_` environment variables exposed by `git`. /// /// For some commands, like `git commit -a` or `git commit -p`, git creates a `.git/index.lock` file /// and set `GIT_INDEX_FILE` to point to it. /// We need to keep the `GIT_INDEX_FILE` env var to make sure `git write-tree` works correctly. /// pub(crate) static GIT_ENV_TO_REMOVE: LazyLock> = LazyLock::new(|| { let keep = &[ "GIT_EXEC_PATH", "GIT_SSH", "GIT_SSH_COMMAND", "GIT_SSL_CAINFO", "GIT_SSL_NO_VERIFY", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS", "GIT_HTTP_PROXY_AUTHMETHOD", "GIT_ALLOW_PROTOCOL", "GIT_ASKPASS", ]; std::env::vars() .filter(|(k, _)| { k.starts_with("GIT_") && !k.starts_with("GIT_CONFIG_KEY_") && !k.starts_with("GIT_CONFIG_VALUE_") && !keep.contains(&k.as_str()) }) .collect() }); pub(crate) fn git_cmd(summary: &str) -> Result { let mut cmd = Cmd::new(GIT.as_ref().map_err(|&e| Error::GitNotFound(e))?, summary); cmd.arg("-c").arg("core.useBuiltinFSMonitor=false"); Ok(cmd) } fn zsplit(s: &[u8]) -> Result, Utf8Error> { s.split(|&b| b == b'\0') .filter(|slice| !slice.is_empty()) .map(|slice| str::from_utf8(slice).map(PathBuf::from)) .collect() } pub(crate) async fn intent_to_add_files(root: &Path) -> Result, Error> { let output = git_cmd("get intent to add files")? .arg("diff") .arg("--no-ext-diff") .arg("--ignore-submodules") .arg("--diff-filter=A") .arg("--name-only") .arg("-z") .arg("--") .arg(root) .check(true) .output() .await?; Ok(zsplit(&output.stdout)?) } pub(crate) async fn get_added_files(root: &Path) -> Result, Error> { let output = git_cmd("get added files")? .current_dir(root) .arg("diff") .arg("--staged") .arg("--name-only") .arg("--diff-filter=A") .arg("-z") // Use NUL as line terminator .check(true) .output() .await?; Ok(zsplit(&output.stdout)?) } pub(crate) async fn get_changed_files( old: &str, new: &str, root: &Path, ) -> Result, Error> { let build_cmd = |range: String| -> Result { let mut cmd = git_cmd("get changed files")?; cmd.arg("diff") .arg("--name-only") .arg("--diff-filter=ACMRT") .arg("--no-ext-diff") // Disable external diff drivers .arg("-z") // Use NUL as line terminator .arg(range) .arg("--") .arg(root); Ok(cmd) }; // Try three-dot syntax first (merge-base diff), which works for commits let output = build_cmd(format!("{old}...{new}"))? .check(false) .output() .await?; if output.status.success() { return Ok(zsplit(&output.stdout)?); } // Fall back to two-dot syntax, which works with both commits and trees let output = build_cmd(format!("{old}..{new}"))? .check(true) .output() .await?; Ok(zsplit(&output.stdout)?) } #[instrument(level = "trace")] pub(crate) async fn ls_files(cwd: &Path, path: &Path) -> Result, Error> { let output = git_cmd("git ls-files")? .current_dir(cwd) .arg("ls-files") .arg("-z") .arg("--") .arg(path) .check(true) .output() .await?; Ok(zsplit(&output.stdout)?) } pub(crate) async fn get_git_dir() -> Result { let output = git_cmd("get git dir")? .arg("rev-parse") .arg("--git-dir") .check(true) .output() .await?; Ok(PathBuf::from( String::from_utf8_lossy(&output.stdout).trim_ascii(), )) } pub(crate) async fn get_git_common_dir() -> Result { let output = git_cmd("get git common dir")? .arg("rev-parse") .arg("--git-common-dir") .check(true) .output() .await?; if output.stdout.trim_ascii().is_empty() { Ok(get_git_dir().await?) } else { Ok(PathBuf::from( String::from_utf8_lossy(&output.stdout).trim_ascii(), )) } } pub(crate) async fn get_staged_files(root: &Path) -> Result, Error> { let output = git_cmd("get staged files")? .current_dir(root) .arg("diff") .arg("--cached") .arg("--name-only") .arg("--diff-filter=ACMRTUXB") // Everything except for D .arg("--no-ext-diff") // Disable external diff drivers .arg("-z") // Use NUL as line terminator .check(true) .output() .await?; Ok(zsplit(&output.stdout)?) } pub(crate) async fn files_not_staged(files: &[&Path]) -> Result> { let output = git_cmd("git diff")? .arg("diff") .arg("--exit-code") .arg("--name-only") .arg("--no-ext-diff") .arg("-z") // Use NUL as line terminator .args(files) .check(false) .output() .await?; if output.status.code().is_some_and(|code| code == 1) { return Ok(zsplit(&output.stdout)?); } Ok(vec![]) } pub(crate) async fn has_unmerged_paths() -> Result { let output = git_cmd("check has unmerged paths")? .arg("ls-files") .arg("--unmerged") .check(true) .output() .await?; Ok(!output.stdout.trim_ascii().is_empty()) } pub(crate) async fn has_diff(rev: &str, path: &Path) -> Result { let status = git_cmd("check diff")? .arg("diff") .arg("--quiet") .arg(rev) .current_dir(path) .check(false) .status() .await?; Ok(status.code() == Some(1)) } pub(crate) async fn is_in_merge_conflict() -> Result { let git_dir = get_git_dir().await?; Ok(git_dir.join("MERGE_HEAD").try_exists()? && git_dir.join("MERGE_MSG").try_exists()?) } pub(crate) async fn get_conflicted_files(root: &Path) -> Result, Error> { let tree = git_cmd("git write-tree")? .arg("write-tree") .check(true) .output() .await?; let output = git_cmd("get conflicted files")? .arg("diff") .arg("--name-only") .arg("--no-ext-diff") // Disable external diff drivers .arg("-z") // Use NUL as line terminator .arg("-m") // Show diffs for merge commits in the default format. .arg(String::from_utf8_lossy(&tree.stdout).trim_ascii()) .arg("HEAD") .arg("MERGE_HEAD") .arg("--") .arg(root) .check(true) .output() .await?; Ok(zsplit(&output.stdout)? .into_iter() .chain(parse_merge_msg_for_conflicts().await?) .collect::>() .into_iter() .collect()) } async fn parse_merge_msg_for_conflicts() -> Result, Error> { let git_dir = get_git_dir().await?; let merge_msg = git_dir.join("MERGE_MSG"); let content = fs_err::tokio::read_to_string(&merge_msg).await?; let conflicts = content .lines() // Conflicted files start with tabs .filter(|line| line.starts_with('\t') || line.starts_with("#\t")) .map(|line| line.trim_start_matches('#').trim().to_string()) .map(PathBuf::from) .collect(); Ok(conflicts) } #[instrument(level = "trace")] pub(crate) async fn get_diff(path: &Path) -> Result, Error> { let output = git_cmd("git diff")? .arg("diff") .arg("--no-ext-diff") // Disable external diff drivers .arg("--no-textconv") .arg("--ignore-submodules") .arg("--") .arg(path) .check(true) .output() .await?; Ok(output.stdout) } /// Create a tree object from the current index. /// /// The name of the new tree object is printed to standard output. /// The index must be in a fully merged state. pub(crate) async fn write_tree() -> Result { let output = git_cmd("git write-tree")? .arg("write-tree") .check(true) .output() .await?; Ok(String::from_utf8_lossy(&output.stdout) .trim_ascii() .to_string()) } /// Get the path of the top-level directory of the working tree. #[instrument(level = "trace")] pub(crate) fn get_root() -> Result { let git = GIT.as_ref().map_err(|&e| Error::GitNotFound(e))?; let output = std::process::Command::new(git) .arg("rev-parse") .arg("--show-toplevel") .output()?; if !output.status.success() { return Err(Error::Command(process::Error::Status { summary: "get git root".to_string(), error: StatusError { status: output.status, output: Some(output), }, })); } Ok(PathBuf::from( String::from_utf8_lossy(&output.stdout).trim_ascii(), )) } pub(crate) async fn init_repo(url: &str, path: &Path) -> Result<(), Error> { let url = if Path::new(url).is_dir() { // If the URL is a local path, convert it to an absolute path Cow::Owned( std::path::absolute(url)? .clean() .to_string_lossy() .to_string(), ) } else { Cow::Borrowed(url) }; git_cmd("init git repo")? // Unset `extensions.objectFormat` if set, just follow what hash the remote uses. .arg("-c") .arg("init.defaultObjectFormat=") .arg("init") .arg("--template=") .arg(path) .remove_git_envs() .check(true) .output() .await?; git_cmd("add git remote")? .current_dir(path) .arg("remote") .arg("add") .arg("origin") .arg(&*url) .remove_git_envs() .check(true) .output() .await?; Ok(()) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum TerminalPrompt { Disabled, Enabled, } impl TerminalPrompt { fn env_value(self) -> &'static str { match self { Self::Disabled => "0", Self::Enabled => "1", } } } /// Return whether a git clone failure looks like an authentication error. pub(crate) fn is_auth_error(err: &Error) -> bool { let Error::Command(process::Error::Status { error: StatusError { output: Some(output), .. }, .. }) = err else { return false; }; let error = String::from_utf8_lossy(&output.stderr).to_lowercase(); [ "terminal prompts disabled", "could not read username", "could not read password", "authentication failed", "http basic: access denied", "missing or invalid credentials", "could not authenticate to server", ] .iter() .any(|needle| error.contains(needle)) } async fn shallow_clone( rev: &str, path: &Path, terminal_prompt: TerminalPrompt, ) -> Result<(), Error> { git_cmd("git shallow clone")? .current_dir(path) .arg("-c") .arg("protocol.version=2") .arg("fetch") .arg("origin") .arg(rev) .arg("--depth=1") .remove_git_envs() .env(EnvVars::LC_ALL, "C") .env(EnvVars::GIT_TERMINAL_PROMPT, terminal_prompt.env_value()) .check(true) .output() .await?; git_cmd("git checkout")? .current_dir(path) .arg("checkout") .arg("FETCH_HEAD") .remove_git_envs() .env(EnvVars::PREK_INTERNAL__SKIP_POST_CHECKOUT, "1") .env(EnvVars::LC_ALL, "C") .env(EnvVars::GIT_TERMINAL_PROMPT, terminal_prompt.env_value()) .check(true) .output() .await?; git_cmd("update git submodules")? .current_dir(path) .arg("-c") .arg("protocol.version=2") .arg("submodule") .arg("update") .arg("--init") .arg("--recursive") .arg("--depth=1") .remove_git_envs() .env(EnvVars::LC_ALL, "C") .env(EnvVars::GIT_TERMINAL_PROMPT, terminal_prompt.env_value()) .check(true) .output() .await?; Ok(()) } async fn full_clone(rev: &str, path: &Path, terminal_prompt: TerminalPrompt) -> Result<(), Error> { git_cmd("git full clone")? .current_dir(path) .arg("fetch") .arg("origin") .arg("--tags") .remove_git_envs() .env(EnvVars::LC_ALL, "C") .env(EnvVars::GIT_TERMINAL_PROMPT, terminal_prompt.env_value()) .check(true) .output() .await?; git_cmd("git checkout")? .current_dir(path) .arg("checkout") .arg(rev) .remove_git_envs() .env(EnvVars::PREK_INTERNAL__SKIP_POST_CHECKOUT, "1") .env(EnvVars::LC_ALL, "C") .env(EnvVars::GIT_TERMINAL_PROMPT, terminal_prompt.env_value()) .check(true) .output() .await?; git_cmd("update git submodules")? .current_dir(path) .arg("submodule") .arg("update") .arg("--init") .arg("--recursive") .remove_git_envs() .env(EnvVars::LC_ALL, "C") .env(EnvVars::GIT_TERMINAL_PROMPT, terminal_prompt.env_value()) .check(true) .output() .await?; Ok(()) } async fn clone_repo_attempt( rev: &str, path: &Path, terminal_prompt: TerminalPrompt, ) -> Result<(), Error> { if let Err(err) = shallow_clone(rev, path, terminal_prompt).await { if is_auth_error(&err) { warn!(?err, "Failed to shallow clone due to authentication error"); return Err(err); } warn!(?err, "Failed to shallow clone, falling back to full clone"); return full_clone(rev, path, terminal_prompt).await; } Ok(()) } /// Clone a repository into an initialized destination with the requested terminal prompt mode. pub(crate) async fn clone_repo( url: &str, rev: &str, path: &Path, terminal_prompt: TerminalPrompt, ) -> Result<(), Error> { init_repo(url, path).await?; clone_repo_attempt(rev, path, terminal_prompt).await } pub(crate) async fn has_hooks_path_set() -> Result { let output = git_cmd("get git hooks path")? .arg("config") .arg("--get") .arg("core.hooksPath") .check(false) .output() .await?; if output.status.success() { Ok(!output.stdout.trim_ascii().is_empty()) } else { Ok(false) } } /// Compute the file mode for a newly created file based on `core.sharedRepository`. /// /// This mirrors the relevant parts of Git's `git_config_perm` in `setup.c` /// and `calc_shared_perm` in `path.c`. fn shared_repository_file_mode(value: &str, mode: u32) -> Option { const PERM_GROUP: u32 = 0o660; const PERM_EVERYBODY: u32 = 0o664; fn apply(mode: u32, mut tweak: u32, replace: bool) -> u32 { // From Git's `calc_shared_perm`: if the original file is not // user-writable, do not introduce any write bits via the shared // repository permission tweak. if mode & 0o200 == 0 { tweak &= !0o222; } // Also from `calc_shared_perm`: for executable files, mirror read bits // into execute bits so an explicit mode like 0640 becomes 0750 when // applied to a 0755 file. if mode & 0o100 != 0 { tweak |= (tweak & 0o444) >> 2; } // Named values like `group` and `all` add permissions on top of the // existing mode, while octal values replace the low permission bits. if replace { (mode & !0o777) | tweak } else { mode | tweak } } let value = value.trim().to_ascii_lowercase(); let (tweak, replace) = match value.as_str() { "" | "umask" | "false" | "no" | "off" | "0" => return None, "group" | "true" | "yes" | "on" | "1" => (PERM_GROUP, false), "all" | "world" | "everybody" | "2" => (PERM_EVERYBODY, false), // Parsed like Git's `git_config_perm`, which also accepts explicit // octal modes such as `0640`. _ => (u32::from_str_radix(&value, 8).ok()?, true), }; // `git_config_perm` rejects explicit modes that do not grant user read/write. if replace && tweak & 0o600 != 0o600 { return None; } Some(apply(mode, tweak, replace)) } /// Resolve the file mode implied by `core.sharedRepository` for a newly created file. pub(crate) async fn get_shared_repository_file_mode(mode: u32) -> Result { let output = git_cmd("get shared repository config")? .arg("config") .arg("--get") .arg("core.sharedRepository") .check(false) .output() .await?; if output.status.success() { let value = str::from_utf8(&output.stdout)?; Ok(shared_repository_file_mode(value, mode).unwrap_or(mode)) } else { Ok(mode) } } pub(crate) async fn get_lfs_files(paths: &[&Path]) -> Result, Error> { if paths.is_empty() { return Ok(FxHashSet::default()); } let mut child = git_cmd("git check-attr")? .arg("check-attr") .arg("filter") .arg("-z") .arg("--stdin") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .check(true) .spawn()?; let mut stdout = child.stdout.take().expect("failed to open stdout"); let mut stdin = child.stdin.take().expect("failed to open stdin"); let writer = async move { for path in paths { stdin.write_all(path.to_string_lossy().as_bytes()).await?; stdin.write_all(b"\0").await?; } stdin.shutdown().await?; Ok::<(), std::io::Error>(()) }; let reader = async move { let mut out = Vec::new(); stdout.read_to_end(&mut out).await?; Ok::<_, std::io::Error>(out) }; let (read_result, _write_result) = tokio::try_join!(biased; reader, writer)?; let status = child.wait().await?; if !status.success() { return Err(Error::Command(process::Error::Status { summary: "git check-attr".to_string(), error: StatusError { status, output: None, }, })); } let mut lfs_files = FxHashSet::default(); let read_result = String::from_utf8_lossy(&read_result); let mut it = read_result.split_terminator('\0'); loop { let (Some(file), Some(_attr), Some(value)) = (it.next(), it.next(), it.next()) else { break; }; if value == "lfs" { lfs_files.insert(PathBuf::from(file)); } } Ok(lfs_files) } /// Check if a git revision exists pub(crate) async fn rev_exists(rev: &str) -> Result { let output = git_cmd("git cat-file")? .arg("cat-file") // Exit with zero status if exists and is a valid object. .arg("-e") .arg(rev) .check(false) .output() .await?; Ok(output.status.success()) } /// Get commits that are ancestors of the given commit but not in the specified remote pub(crate) async fn get_ancestors_not_in_remote( local_sha: &str, remote_name: &str, ) -> Result, Error> { let output = git_cmd("get ancestors not in remote")? .arg("rev-list") .arg(local_sha) .arg("--topo-order") .arg("--reverse") .arg("--not") .arg(format!("--remotes={remote_name}")) .check(true) .output() .await?; Ok(str::from_utf8(&output.stdout)? .trim_ascii() .lines() .map(ToString::to_string) .collect()) } /// Get root commits (commits with no parents) for the given commit pub(crate) async fn get_root_commits(local_sha: &str) -> Result, Error> { let output = git_cmd("get root commits")? .arg("rev-list") .arg("--max-parents=0") .arg(local_sha) .check(true) .output() .await?; Ok(str::from_utf8(&output.stdout)? .trim_ascii() .lines() .map(ToString::to_string) .collect()) } /// Get the parent commit of the given commit pub(crate) async fn get_parent_commit(commit: &str) -> Result, Error> { let output = git_cmd("get parent commit")? .arg("rev-parse") .arg(format!("{commit}^")) .check(false) .output() .await?; if output.status.success() { Ok(Some( str::from_utf8(&output.stdout)?.trim_ascii().to_string(), )) } else { Ok(None) } } /// Return a list of absolute paths of all git submodules in the repository. #[instrument(level = "trace")] pub(crate) fn list_submodules(git_root: &Path) -> Result, Error> { if !git_root.join(".gitmodules").exists() { return Ok(vec![]); } let git = GIT.as_ref().map_err(|&e| Error::GitNotFound(e))?; let output = std::process::Command::new(git) .current_dir(git_root) .arg("config") .arg("--file") .arg(".gitmodules") .arg("--get-regexp") .arg(r"^submodule\..*\.path$") .output()?; Ok(String::from_utf8_lossy(&output.stdout) .trim_ascii() .lines() .filter_map(|line| line.split_whitespace().nth(1)) .map(|submodule| git_root.join(submodule)) .collect()) } #[cfg(test)] mod tests { use super::shared_repository_file_mode; #[test] fn shared_repository_group_mode_matches_git_behavior() { for value in ["group", "true", "yes", "on", "1"] { assert_eq!(shared_repository_file_mode(value, 0o755), Some(0o775)); } } #[test] fn shared_repository_everybody_mode_matches_git_behavior() { for value in ["all", "world", "everybody", "2"] { assert_eq!(shared_repository_file_mode(value, 0o755), Some(0o775)); } } #[test] fn shared_repository_octal_mode_matches_git_behavior() { assert_eq!(shared_repository_file_mode("0640", 0o644), Some(0o640)); assert_eq!(shared_repository_file_mode("0640", 0o755), Some(0o750)); } #[test] fn shared_repository_umask_or_invalid_values_do_not_override_mode() { for value in ["", "umask", "false", "no", "off", "0", "invalid", "0400"] { assert_eq!(shared_repository_file_mode(value, 0o755), None); } } } ================================================ FILE: crates/prek/src/hook.rs ================================================ use std::borrow::Cow; use std::ffi::OsStr; use std::fmt::{Display, Formatter}; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use anyhow::{Context, Result}; use prek_consts::PRE_COMMIT_HOOKS_YAML; use prek_identify::{TagSet, tags}; use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; use serde::{Deserialize, Serialize}; use tempfile::TempDir; use thiserror::Error; use tracing::trace; use crate::config::{ self, BuiltinHook, Config, FilePattern, HookOptions, Language, LocalHook, ManifestHook, MetaHook, PassFilenames, RemoteHook, Stages, read_manifest, }; use crate::languages::version::LanguageRequest; use crate::languages::{extract_metadata, resolve_command}; use crate::store::Store; use crate::workspace::Project; #[derive(Error, Debug)] pub(crate) enum Error { #[error(transparent)] Config(#[from] config::Error), #[error("Invalid hook `{hook}`")] Hook { hook: String, #[source] error: anyhow::Error, }, #[error("Failed to read manifest of `{repo}`")] Manifest { repo: String, #[source] error: config::Error, }, #[error("Failed to create directory for hook environment")] TmpDir(#[from] std::io::Error), } /// A hook specification that all hook types can be converted into. #[derive(Debug, Clone)] pub(crate) struct HookSpec { pub id: String, pub name: String, pub entry: String, pub language: Language, pub priority: Option, pub options: HookOptions, } impl HookSpec { pub(crate) fn apply_remote_hook_overrides(&mut self, config: &RemoteHook) { if let Some(name) = &config.name { self.name.clone_from(name); } if let Some(entry) = &config.entry { self.entry.clone_from(entry); } if let Some(language) = &config.language { self.language.clone_from(language); } if let Some(priority) = config.priority { self.priority = Some(priority); } self.options.update(&config.options); } pub(crate) fn apply_project_defaults(&mut self, config: &Config) { let language = self.language; if self.options.language_version.is_none() { self.options.language_version = config .default_language_version .as_ref() .and_then(|v| v.get(&language).cloned()); } if self.options.stages.as_ref().is_none_or(Stages::is_empty) { self.options.stages = Some(config.default_stages.clone().unwrap_or(Stages::All)); } } } impl From for HookSpec { fn from(hook: ManifestHook) -> Self { Self { id: hook.id, name: hook.name, entry: hook.entry, language: hook.language, priority: None, options: hook.options, } } } impl From for HookSpec { fn from(hook: LocalHook) -> Self { Self { id: hook.id, name: hook.name, entry: hook.entry, language: hook.language, priority: hook.priority, options: hook.options, } } } impl From for HookSpec { fn from(hook: MetaHook) -> Self { Self { id: hook.id, name: hook.name, entry: String::new(), language: Language::System, priority: hook.priority, options: hook.options, } } } impl From for HookSpec { fn from(hook: BuiltinHook) -> Self { Self { id: hook.id, name: hook.name, entry: hook.entry, language: Language::System, priority: hook.priority, options: hook.options, } } } #[derive(Debug, Clone)] pub(crate) enum Repo { Remote { /// Path to the cloned repo. path: PathBuf, url: String, rev: String, hooks: Vec, }, Local { hooks: Vec, }, Meta { hooks: Vec, }, Builtin { hooks: Vec, }, } impl Repo { /// Load the remote repo manifest from the path. pub(crate) fn remote(url: String, rev: String, path: PathBuf) -> Result { let manifest = read_manifest(&path.join(PRE_COMMIT_HOOKS_YAML)).map_err(|e| Error::Manifest { repo: url.clone(), error: e, })?; let hooks = manifest.hooks.into_iter().map(Into::into).collect(); Ok(Self::Remote { path, url, rev, hooks, }) } /// Construct a local repo from a list of hooks. pub(crate) fn local(hooks: Vec) -> Self { Self::Local { hooks: hooks.into_iter().map(Into::into).collect(), } } /// Construct a meta repo. pub(crate) fn meta(hooks: Vec) -> Self { Self::Meta { hooks: hooks.into_iter().map(Into::into).collect(), } } /// Construct a builtin repo. pub(crate) fn builtin(hooks: Vec) -> Self { Self::Builtin { hooks: hooks.into_iter().map(Into::into).collect(), } } /// Get the path to the cloned repo if it is a remote repo. pub(crate) fn path(&self) -> Option<&Path> { match self { Repo::Remote { path, .. } => Some(path), _ => None, } } /// Get a hook by id. pub(crate) fn get_hook(&self, id: &str) -> Option<&HookSpec> { let hooks = match self { Repo::Remote { hooks, .. } => hooks, Repo::Local { hooks } => hooks, Repo::Meta { hooks } => hooks, Repo::Builtin { hooks } => hooks, }; hooks.iter().find(|hook| hook.id == id) } } impl Display for Repo { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Repo::Remote { url, rev, .. } => write!(f, "{url}@{rev}"), Repo::Local { .. } => write!(f, "local"), Repo::Meta { .. } => write!(f, "meta"), Repo::Builtin { .. } => write!(f, "builtin"), } } } pub(crate) struct HookBuilder { project: Arc, repo: Arc, hook_spec: HookSpec, // The index of the hook in the project configuration. idx: usize, } impl HookBuilder { pub(crate) fn new( project: Arc, repo: Arc, hook_spec: HookSpec, idx: usize, ) -> Self { Self { project, repo, hook_spec, idx, } } /// Check the hook configuration. fn check(&self) -> Result<(), Error> { let language = self.hook_spec.language; let HookOptions { language_version, additional_dependencies, .. } = &self.hook_spec.options; let additional_dependencies = additional_dependencies .as_ref() .map_or(&[][..], |deps| deps.as_slice()); if !additional_dependencies.is_empty() { if !language.supports_install_env() { return Err(Error::Hook { hook: self.hook_spec.id.clone(), error: anyhow::anyhow!( "Hook specified `additional_dependencies: {}` but the language `{}` does not install an environment", additional_dependencies.join(", "), language, ), }); } if !language.supports_dependency() { return Err(Error::Hook { hook: self.hook_spec.id.clone(), error: anyhow::anyhow!( "Hook specified `additional_dependencies: {}` but the language `{}` does not support installing dependencies for now", additional_dependencies.join(", "), language, ), }); } } if !language.supports_language_version() { if let Some(language_version) = language_version && language_version != "default" { return Err(Error::Hook { hook: self.hook_spec.id.clone(), error: anyhow::anyhow!( "Hook specified `language_version: {language_version}` but the language `{language}` does not support toolchain installation for now", ), }); } } Ok(()) } /// Build the hook. pub(crate) async fn build(mut self) -> Result { self.hook_spec.apply_project_defaults(self.project.config()); self.check()?; let options = self.hook_spec.options; let language_version = options.language_version.unwrap_or_default(); let alias = options.alias.unwrap_or_default(); let args = options.args.unwrap_or_default(); let env = options.env.unwrap_or_default(); let types = options.types.unwrap_or(tags::TAG_SET_FILE); let types_or = options.types_or.unwrap_or_default(); let exclude_types = options.exclude_types.unwrap_or_default(); let always_run = options.always_run.unwrap_or(false); let fail_fast = options.fail_fast.unwrap_or(false); let pass_filenames = options.pass_filenames.unwrap_or(PassFilenames::All); let require_serial = options.require_serial.unwrap_or(false); let verbose = options.verbose.unwrap_or(false); let stages = options.stages.unwrap_or(Stages::All); let additional_dependencies = options .additional_dependencies .unwrap_or_default() .into_iter() .collect::>(); let language_request = LanguageRequest::parse(self.hook_spec.language, &language_version) .map_err(|e| Error::Hook { hook: self.hook_spec.id.clone(), error: anyhow::anyhow!(e), })?; let entry = Entry::new(self.hook_spec.id.clone(), self.hook_spec.entry); let priority = self .hook_spec .priority .unwrap_or(u32::try_from(self.idx).expect("idx too large")); let mut hook = Hook { dependencies: OnceLock::new(), project: self.project, repo: self.repo, idx: self.idx, id: self.hook_spec.id, name: self.hook_spec.name, language: self.hook_spec.language, priority, entry, stages, language_request, additional_dependencies, alias, types, types_or, exclude_types, args, env, always_run, fail_fast, pass_filenames, require_serial, verbose, files: options.files, exclude: options.exclude, description: options.description, log_file: options.log_file, minimum_prek_version: options.minimum_prek_version, }; if let Err(err) = extract_metadata(&mut hook).await { if err .downcast_ref::() .is_some_and(|e| e.kind() != std::io::ErrorKind::NotFound) { trace!("Failed to extract metadata from entry for hook `{hook}`: {err}"); } } Ok(hook) } } #[derive(Debug, Clone)] pub(crate) struct Entry { hook: String, entry: String, } impl Entry { pub(crate) fn new(hook: String, entry: String) -> Self { Self { hook, entry } } /// Split the entry and resolve the command by parsing its shebang. pub(crate) fn resolve(&self, env_path: Option<&OsStr>) -> Result, Error> { let split = self.split()?; Ok(resolve_command(split, env_path)) } /// Split the entry into a list of commands. pub(crate) fn split(&self) -> Result, Error> { let splits = shlex::split(&self.entry).ok_or_else(|| Error::Hook { hook: self.hook.clone(), error: anyhow::anyhow!("Failed to parse entry `{}` as commands", &self.entry), })?; if splits.is_empty() { return Err(Error::Hook { hook: self.hook.clone(), error: anyhow::anyhow!("Failed to parse entry: entry is empty"), }); } Ok(splits) } /// Get the original entry string. pub(crate) fn raw(&self) -> &str { &self.entry } } #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct Hook { project: Arc, repo: Arc, // Cached computed dependencies. dependencies: OnceLock>, /// The index of the hook defined in the configuration file. pub idx: usize, pub id: String, pub name: String, pub entry: Entry, pub language: Language, pub alias: String, pub files: Option, pub exclude: Option, pub types: TagSet, pub types_or: TagSet, pub exclude_types: TagSet, pub additional_dependencies: FxHashSet, pub args: Vec, pub env: FxHashMap, pub always_run: bool, pub fail_fast: bool, pub pass_filenames: PassFilenames, pub description: Option, pub language_request: LanguageRequest, pub log_file: Option, pub require_serial: bool, pub stages: Stages, pub verbose: bool, pub minimum_prek_version: Option, pub priority: u32, } impl Display for Hook { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { if f.alternate() { write!(f, "{}:{}", self.repo, self.id) } else { write!(f, "{}", self.id) } } } impl Hook { pub(crate) fn project(&self) -> &Project { &self.project } pub(crate) fn repo(&self) -> &Repo { &self.repo } /// Get the path to the repository that contains the hook. pub(crate) fn repo_path(&self) -> Option<&Path> { self.repo.path() } pub(crate) fn full_id(&self) -> String { let path = self.project.relative_path(); if path.as_os_str().is_empty() { format!(".:{}", self.id) } else { format!("{}:{}", path.display(), self.id) } } /// Get the path where the hook should be executed. pub(crate) fn work_dir(&self) -> &Path { self.project.path() } pub(crate) fn is_remote(&self) -> bool { matches!(&*self.repo, Repo::Remote { .. }) } /// Dependencies used to identify whether an existing hook environment can be reused. /// /// For remote hooks, the repo URL is included to avoid reusing an environment created /// from a different remote repository. pub(crate) fn env_key_dependencies(&self) -> &FxHashSet { if !self.is_remote() { return &self.additional_dependencies; } self.dependencies.get_or_init(|| { env_key_dependencies(&self.additional_dependencies, Some(&self.repo.to_string())) }) } /// Returns a lightweight view of the hook environment identity used for reusing installs. /// /// Returns `None` for languages that do not install an environment. pub(crate) fn env_key(&self) -> Option> { if !self.language.supports_install_env() { return None; } Some(HookEnvKeyRef { language: self.language, dependencies: self.env_key_dependencies(), language_request: &self.language_request, }) } /// Dependencies to pass to language dependency installers. /// /// For remote hooks, this includes the local path to the cloned repository so that /// installers can install the hook's package/project itself. pub(crate) fn install_dependencies(&self) -> Cow<'_, FxHashSet> { if let Some(repo_path) = self.repo_path() { let mut deps = self.additional_dependencies.clone(); deps.insert(repo_path.to_string_lossy().to_string()); Cow::Owned(deps) } else { Cow::Borrowed(&self.additional_dependencies) } } } #[derive(Debug, Clone)] pub(crate) struct HookEnvKey { pub(crate) language: Language, pub(crate) dependencies: FxHashSet, pub(crate) language_request: LanguageRequest, } /// Borrowed form of [`HookEnvKey`] for comparing a hook to an existing installation /// without allocating/cloning dependency sets. #[derive(Debug, Clone, Copy)] pub(crate) struct HookEnvKeyRef<'a> { pub(crate) language: Language, pub(crate) dependencies: &'a FxHashSet, pub(crate) language_request: &'a LanguageRequest, } /// Builds the dependency set used to identify a hook environment. /// /// For remote hooks, `remote_repo_dependency` is included so environments from different /// repositories are not reused accidentally. fn env_key_dependencies( additional_dependencies: &FxHashSet, remote_repo_dependency: Option<&str>, ) -> FxHashSet { let mut deps = FxHashSet::with_capacity_and_hasher( additional_dependencies.len() + usize::from(remote_repo_dependency.is_some()), FxBuildHasher, ); deps.extend(additional_dependencies.iter().cloned()); if let Some(dep) = remote_repo_dependency { deps.insert(dep.to_string()); } deps } /// Shared matching logic between a computed hook env key (owned or borrowed) and an installed /// environment described by [`InstallInfo`]. fn matches_install_info( language: Language, dependencies: &FxHashSet, language_request: &LanguageRequest, info: &InstallInfo, ) -> bool { info.language == language && info.dependencies == *dependencies && language_request.satisfied_by(info) } impl HookEnvKey { /// Compute the key used to match an installed hook environment. /// /// Returns `Ok(None)` if this hook does not install an environment. pub(crate) fn from_hook_spec( config: &Config, mut hook_spec: HookSpec, remote_repo_dependency: Option<&str>, ) -> Result> { let language = hook_spec.language; if !language.supports_install_env() { return Ok(None); } hook_spec.apply_project_defaults(config); hook_spec.options.language_version.get_or_insert_default(); hook_spec .options .additional_dependencies .get_or_insert_default(); let request = hook_spec.options.language_version.as_deref().unwrap_or(""); let language_request = LanguageRequest::parse(language, request).with_context(|| { format!( "Invalid language_version `{request}` for hook `{}`", hook_spec.id ) })?; let additional_dependencies: FxHashSet = hook_spec .options .additional_dependencies .as_ref() .map_or_else(FxHashSet::default, |deps| deps.iter().cloned().collect()); let dependencies = env_key_dependencies(&additional_dependencies, remote_repo_dependency); Ok(Some(Self { language, dependencies, language_request, })) } pub(crate) fn matches_install_info(&self, info: &InstallInfo) -> bool { matches_install_info( self.language, &self.dependencies, &self.language_request, info, ) } } impl HookEnvKeyRef<'_> { /// Returns true if this env key matches the given installed environment. pub(crate) fn matches_install_info(&self, info: &InstallInfo) -> bool { matches_install_info( self.language, self.dependencies, self.language_request, info, ) } } #[derive(Debug, Clone)] pub(crate) enum InstalledHook { Installed { hook: Arc, info: Arc, }, NoNeedInstall(Arc), } impl Deref for InstalledHook { type Target = Hook; fn deref(&self) -> &Self::Target { match self { InstalledHook::Installed { hook, .. } => hook, InstalledHook::NoNeedInstall(hook) => hook, } } } impl Display for InstalledHook { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { // TODO: add more information self.deref().fmt(f) } } pub(crate) const HOOK_MARKER: &str = ".prek-hook.json"; impl InstalledHook { /// Get the path to the environment where the hook is installed. pub(crate) fn env_path(&self) -> Option<&Path> { match self { InstalledHook::Installed { info, .. } => Some(&info.env_path), InstalledHook::NoNeedInstall(_) => None, } } /// Get the directory the toolchain is installed in. pub(crate) fn toolchain_dir(&self) -> Option<&Path> { match self { InstalledHook::Installed { info, .. } => info.toolchain.parent(), InstalledHook::NoNeedInstall(_) => None, } } /// Get the install info of the hook if it is installed. pub(crate) fn install_info(&self) -> Option<&InstallInfo> { match self { InstalledHook::Installed { info, .. } => Some(info), InstalledHook::NoNeedInstall(_) => None, } } /// Mark the hook as installed in the environment. pub(crate) async fn mark_as_installed(&self, _store: &Store) -> Result<()> { let Some(info) = self.install_info() else { return Ok(()); }; let content = serde_json::to_string_pretty(info).context("Failed to serialize install info")?; fs_err::tokio::write(info.env_path.join(HOOK_MARKER), content) .await .context("Failed to write install info")?; Ok(()) } } #[derive(Debug, Deserialize, Serialize)] pub(crate) struct InstallInfo { pub(crate) language: Language, pub(crate) language_version: semver::Version, pub(crate) dependencies: FxHashSet, pub(crate) env_path: PathBuf, pub(crate) toolchain: PathBuf, extra: FxHashMap, #[serde(skip, default)] temp_dir: Option, } impl Clone for InstallInfo { fn clone(&self) -> Self { Self { language: self.language, language_version: self.language_version.clone(), dependencies: self.dependencies.clone(), env_path: self.env_path.clone(), toolchain: self.toolchain.clone(), extra: self.extra.clone(), temp_dir: None, } } } impl InstallInfo { pub(crate) fn new( language: Language, dependencies: FxHashSet, hooks_dir: &Path, ) -> Result { let env_path = tempfile::Builder::new() .prefix(&format!("{language}-")) .rand_bytes(20) .tempdir_in(hooks_dir)?; Ok(Self { language, dependencies, env_path: env_path.path().to_path_buf(), language_version: semver::Version::new(0, 0, 0), toolchain: PathBuf::new(), extra: FxHashMap::default(), temp_dir: Some(env_path), }) } pub(crate) fn persist_env_path(&mut self) { if let Some(temp_dir) = self.temp_dir.take() { self.env_path = temp_dir.keep(); } } pub(crate) async fn from_env_path(path: &Path) -> Result { let content = fs_err::tokio::read_to_string(path.join(HOOK_MARKER)).await?; let info: InstallInfo = serde_json::from_str(&content)?; Ok(info) } pub(crate) async fn check_health(&self) -> Result<()> { self.language.check_health(self).await } pub(crate) fn with_language_version(&mut self, version: semver::Version) -> &mut Self { self.language_version = version; self } pub(crate) fn with_toolchain(&mut self, toolchain: PathBuf) -> &mut Self { self.toolchain = toolchain; self } pub(crate) fn with_extra(&mut self, key: &str, value: &str) -> &mut Self { self.extra.insert(key.to_string(), value.to_string()); self } pub(crate) fn get_extra(&self, key: &str) -> Option<&String> { self.extra.get(key) } pub(crate) fn matches(&self, hook: &Hook) -> bool { hook.env_key() .is_some_and(|key| key.matches_install_info(self)) } } #[cfg(test)] mod tests { use std::borrow::Cow; use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; use prek_consts::PRE_COMMIT_CONFIG_YAML; use prek_identify::tags; use rustc_hash::FxHashMap; use crate::config::{Config, HookOptions, Language, PassFilenames, RemoteHook, Stage, Stages}; use crate::hook::HookSpec; use crate::languages::version::LanguageRequest; use crate::workspace::Project; use super::{Hook, HookBuilder, Repo}; #[tokio::test] async fn hook_builder_build_fills_and_merges_attributes() -> Result<()> { let temp = tempfile::tempdir()?; let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML); // Ensure `combine()` can supply defaults for stages and language_version. fs_err::write( &config_path, indoc::indoc! {r" repos: [] default_language_version: python: python3.12 default_stages: [manual] "}, )?; let project = Arc::new(Project::from_config_file( Cow::Borrowed(&config_path), None, )?); let repo = Arc::new(Repo::Local { hooks: vec![] }); // Base hook spec (e.g. from a manifest): minimal options, one env var. let mut base_env = FxHashMap::default(); base_env.insert("BASE".to_string(), "1".to_string()); let mut hook_spec = HookSpec { id: "test-hook".to_string(), name: "original-name".to_string(), entry: "python3 -c 'print(1)'".to_string(), language: Language::Python, priority: None, options: HookOptions { env: Some(base_env), ..Default::default() }, }; // Project config overrides (e.g. from `.pre-commit-config.yaml`). let mut override_env = FxHashMap::default(); override_env.insert("OVERRIDE".to_string(), "2".to_string()); let hook_override = RemoteHook { id: "test-hook".to_string(), name: Some("override-name".to_string()), entry: Some("python3 -c 'print(2)'".to_string()), language: None, priority: Some(42), options: HookOptions { alias: Some("alias-1".to_string()), types: Some(tags::TAG_SET_TEXT), args: Some(vec!["--flag".to_string()]), env: Some(override_env), always_run: Some(true), pass_filenames: Some(PassFilenames::None), verbose: Some(true), description: Some("desc".to_string()), ..Default::default() }, }; hook_spec.apply_remote_hook_overrides(&hook_override); hook_spec.apply_project_defaults(project.config()); let builder = HookBuilder::new(project.clone(), repo, hook_spec, 7); let hook = builder.build().await?; insta::assert_debug_snapshot!(hook, @r#" Hook { project: Project { relative_path: "", idx: 0, config: Config { repos: [], default_install_hook_types: None, default_language_version: Some( { Python: "python3.12", }, ), default_stages: Some( Some( { Manual, }, ), ), files: None, exclude: None, fail_fast: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, }, repos: [], .. }, repo: Local { hooks: [], }, dependencies: OnceLock( , ), idx: 7, id: "test-hook", name: "override-name", entry: Entry { hook: "test-hook", entry: "python3 -c 'print(2)'", }, language: Python, alias: "alias-1", files: None, exclude: None, types: [ "text", ], types_or: [], exclude_types: [], additional_dependencies: {}, args: [ "--flag", ], env: { "BASE": "1", "OVERRIDE": "2", }, always_run: true, fail_fast: false, pass_filenames: None, description: Some( "desc", ), language_request: Python( MajorMinor( 3, 12, ), ), log_file: None, require_serial: false, stages: Some( { Manual, }, ), verbose: true, minimum_prek_version: None, priority: 42, } "#); Ok(()) } #[tokio::test] async fn hook_builder_empty_hook_stages_inherit_default_stages() -> Result<()> { let temp = tempfile::tempdir()?; let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML); fs_err::write(&config_path, "repos: []\ndefault_stages: [manual]\n")?; let project = Arc::new(Project::from_config_file( Cow::Borrowed(&config_path), None, )?); let repo = Arc::new(Repo::Local { hooks: vec![] }); let hook_spec = HookSpec { id: "test-hook".to_string(), name: "test-hook".to_string(), entry: "python3 -c 'print(1)'".to_string(), language: Language::Python, priority: None, options: HookOptions { stages: Some(Stages::Some(std::collections::BTreeSet::new())), ..Default::default() }, }; let hook = HookBuilder::new(project, repo, hook_spec, 0) .build() .await?; assert_eq!( hook.stages, Stages::Some([Stage::Manual].into_iter().collect()) ); Ok(()) } #[test] fn hook_spec_apply_project_defaults_sets_explicit_all_when_default_stages_missing() { let config: Config = serde_saphyr::from_str("repos: []\n").expect("config should parse"); let mut hook_spec = HookSpec { id: "test-hook".to_string(), name: "test-hook".to_string(), entry: "python3 -c 'print(1)'".to_string(), language: Language::Python, priority: None, options: HookOptions::default(), }; hook_spec.apply_project_defaults(&config); assert_eq!(hook_spec.options.stages, Some(Stages::All)); } #[tokio::test] async fn hook_builder_preserves_explicit_empty_default_stages() -> Result<()> { let temp = tempfile::tempdir()?; let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML); fs_err::write(&config_path, "repos: []\ndefault_stages: []\n")?; let project = Arc::new(Project::from_config_file( Cow::Borrowed(&config_path), None, )?); let repo = Arc::new(Repo::Local { hooks: vec![] }); let hook_spec = HookSpec { id: "test-hook".to_string(), name: "test-hook".to_string(), entry: "python3 -c 'print(1)'".to_string(), language: Language::Python, priority: None, options: HookOptions::default(), }; let hook = HookBuilder::new(project, repo, hook_spec, 0) .build() .await?; assert_eq!(hook.stages, Stages::Some(std::collections::BTreeSet::new())); Ok(()) } #[tokio::test] async fn hook_builder_defaults_to_all_when_stages_and_default_stages_missing() -> Result<()> { let temp = tempfile::tempdir()?; let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML); fs_err::write(&config_path, "repos: []\n")?; let project = Arc::new(Project::from_config_file( Cow::Borrowed(&config_path), None, )?); let repo = Arc::new(Repo::Local { hooks: vec![] }); let hook_spec = HookSpec { id: "test-hook".to_string(), name: "test-hook".to_string(), entry: "python3 -c 'print(1)'".to_string(), language: Language::Python, priority: None, options: HookOptions::default(), }; let hook = HookBuilder::new(project, repo, hook_spec, 0) .build() .await?; assert_eq!(hook.stages, Stages::All); Ok(()) } #[tokio::test] async fn hook_builder_empty_hook_stages_default_to_all_when_default_stages_missing() -> Result<()> { let temp = tempfile::tempdir()?; let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML); fs_err::write(&config_path, "repos: []\n")?; let project = Arc::new(Project::from_config_file( Cow::Borrowed(&config_path), None, )?); let repo = Arc::new(Repo::Local { hooks: vec![] }); let hook_spec = HookSpec { id: "test-hook".to_string(), name: "test-hook".to_string(), entry: "python3 -c 'print(1)'".to_string(), language: Language::Python, priority: None, options: HookOptions { stages: Some(Stages::Some(std::collections::BTreeSet::new())), ..Default::default() }, }; let hook = HookBuilder::new(project, repo, hook_spec, 0) .build() .await?; assert_eq!(hook.stages, Stages::All); Ok(()) } /// Set up a temporary directory with a minimal `.pre-commit-config.yaml` /// and a `remote-repo` subdirectory. fn setup_python_hook_test() -> Result<(tempfile::TempDir, Arc)> { let temp = tempfile::tempdir()?; let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML); fs_err::write(&config_path, "repos: []\n")?; let project = Arc::new(Project::from_config_file( Cow::Borrowed(&config_path), None, )?); let repo_path = temp.path().join("remote-repo"); fs_err::create_dir_all(&repo_path)?; Ok((temp, project)) } /// Build a hook from the given repo path and options via `HookBuilder`. async fn build_python_hook( project: Arc, repo_path: PathBuf, language_version: Option<&str>, ) -> Result { let repo = Arc::new(Repo::Remote { path: repo_path, url: "https://example.invalid/hooks".to_string(), rev: "v0.1.0".to_string(), hooks: vec![], }); let hook_spec = HookSpec { id: "test-hook".to_string(), name: "test-hook".to_string(), entry: "./hook.py".to_string(), language: Language::Python, priority: None, options: HookOptions { language_version: language_version.map(str::to_string), ..Default::default() }, }; Ok(HookBuilder::new(project, repo, hook_spec, 0) .build() .await?) } static PEP723_SCRIPT: &str = indoc::indoc! {r#" # /// script # requires-python = ">=3.11" # /// print("hello") "#}; #[tokio::test] async fn hook_builder_python_pep723_overrides_user_and_pyproject() -> Result<()> { let (temp, project) = setup_python_hook_test()?; let repo_path = temp.path().join("remote-repo"); fs_err::write( repo_path.join("pyproject.toml"), "[project]\nrequires-python = \">=3.8\"\n", )?; fs_err::write(repo_path.join("hook.py"), PEP723_SCRIPT)?; let hook = build_python_hook(project, repo_path, Some("3.9")).await?; assert_eq!( hook.language_request, LanguageRequest::parse(Language::Python, ">=3.11")? ); Ok(()) } #[tokio::test] async fn hook_builder_python_user_language_version_overrides_pyproject() -> Result<()> { let (temp, project) = setup_python_hook_test()?; let repo_path = temp.path().join("remote-repo"); fs_err::write( repo_path.join("pyproject.toml"), "[project]\nrequires-python = \">=3.11\"\n", )?; fs_err::write(repo_path.join("hook.py"), "print(\"hello\")\n")?; let hook = build_python_hook(project, repo_path, Some("3.9")).await?; assert_eq!( hook.language_request, LanguageRequest::parse(Language::Python, "3.9")? ); Ok(()) } #[tokio::test] async fn hook_builder_python_pep723_overrides_pyproject_without_user_version() -> Result<()> { let (temp, project) = setup_python_hook_test()?; let repo_path = temp.path().join("remote-repo"); fs_err::write( repo_path.join("pyproject.toml"), "[project]\nrequires-python = \">=3.8\"\n", )?; fs_err::write(repo_path.join("hook.py"), PEP723_SCRIPT)?; let hook = build_python_hook(project, repo_path, None).await?; assert_eq!( hook.language_request, LanguageRequest::parse(Language::Python, ">=3.11")? ); Ok(()) } #[tokio::test] async fn hook_builder_python_defaults_to_any_without_version_sources() -> Result<()> { let (temp, project) = setup_python_hook_test()?; let repo_path = temp.path().join("remote-repo"); fs_err::write(repo_path.join("hook.py"), "print(\"hello\")\n")?; let hook = build_python_hook(project, repo_path, None).await?; assert!(hook.language_request.is_any()); Ok(()) } #[tokio::test] async fn hook_builder_python_pyproject_provides_version_when_no_other_source() -> Result<()> { let (temp, project) = setup_python_hook_test()?; let repo_path = temp.path().join("remote-repo"); fs_err::write( repo_path.join("pyproject.toml"), "[project]\nrequires-python = \">=3.10\"\n", )?; fs_err::write(repo_path.join("hook.py"), "print(\"hello\")\n")?; let hook = build_python_hook(project, repo_path, None).await?; assert_eq!( hook.language_request, LanguageRequest::parse(Language::Python, ">=3.10")? ); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/builtin_hooks/check_json5.rs ================================================ use std::path::Path; use crate::hook::Hook; use crate::hooks::pre_commit_hooks::check_json::JsonValue; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; pub(crate) async fn check_json5( hook: &Hook, filenames: &[&Path], ) -> anyhow::Result<(i32, Vec)> { run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { check_file(hook.project().relative_path(), filename) }) .await } async fn check_file(file_base: &Path, filename: &Path) -> anyhow::Result<(i32, Vec)> { let file_path = file_base.join(filename); let content = fs_err::tokio::read_to_string(file_path).await?; if content.is_empty() { return Ok((0, Vec::new())); } match json5::from_str::(&content) { Ok(_) => Ok((0, Vec::new())), Err(e) => { let error_message = format!("{}: Failed to json5 decode ({})\n", filename.display(), e); Ok((1, error_message.into_bytes())) } } } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; use tempfile::tempdir; async fn create_test_file( dir: &tempfile::TempDir, name: &str, content: &[u8], ) -> anyhow::Result { let file_path = dir.path().join(name); fs_err::tokio::write(&file_path, content).await?; Ok(file_path) } #[tokio::test] async fn test_valid_json5() -> anyhow::Result<()> { let dir = tempdir()?; let content = indoc::indoc! {r#" { // comments unquoted: "and you can quote me on that", singleQuotes: 'I can use "double quotes" here', lineBreaks: "Look, Mom! \ No \\n's!", hexadecimal: 0xdecaf, leadingDecimalPoint: 0.8675309, andTrailing: 8675309, positiveSign: +1, trailingComma: "in objects", andIn: ["arrays"], backwardsCompatible: "with JSON", } "#}; let file_path = create_test_file(&dir, "valid.json5", content.as_bytes()).await?; let (code, output) = check_file(dir.path(), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_duplicate_keys() -> anyhow::Result<()> { // JSON5 warns duplicate names are unpredictable; implementations may error or accept. // Our JsonValue custom deserializer rejects duplicates. let dir = tempdir()?; let content = indoc::indoc! {r#" { key: "value1", key: "value2", key: "value3", } "#}; let file_path = create_test_file(&dir, "duplicate.json5", content.as_bytes()).await?; let (code, output) = check_file(dir.path(), &file_path).await?; assert_eq!(code, 1); assert!(String::from_utf8_lossy(&output).contains("duplicate key")); Ok(()) } #[tokio::test] async fn test_invalid_json5() -> anyhow::Result<()> { let dir = tempdir()?; let file_path = create_test_file(&dir, "invalid.json5", b"{ key: 'value' ").await?; let (code, output) = check_file(dir.path(), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/builtin_hooks/mod.rs ================================================ use std::path::Path; use std::str::FromStr; use anyhow::Result; use prek_identify::tags; use crate::cli::reporter::HookRunReporter; use crate::config::{BuiltinHook, HookOptions, PassFilenames, Stage}; use crate::hook::Hook; use crate::hooks::pre_commit_hooks; use crate::store::Store; mod check_json5; #[derive( Debug, Copy, Clone, PartialEq, Eq, strum::AsRefStr, strum::Display, strum::EnumIter, strum::EnumString, )] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", schemars(rename_all = "kebab-case"))] #[strum(serialize_all = "kebab-case")] pub(crate) enum BuiltinHooks { CheckAddedLargeFiles, CheckCaseConflict, CheckExecutablesHaveShebangs, CheckJson, CheckJson5, CheckMergeConflict, CheckSymlinks, CheckToml, CheckXml, CheckYaml, DetectPrivateKey, EndOfFileFixer, FixByteOrderMarker, MixedLineEnding, NoCommitToBranch, TrailingWhitespace, } impl BuiltinHooks { pub(crate) async fn run( self, _store: &Store, hook: &Hook, filenames: &[&Path], reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let result = match self { Self::CheckAddedLargeFiles => { pre_commit_hooks::check_added_large_files(hook, filenames).await } Self::CheckCaseConflict => pre_commit_hooks::check_case_conflict(hook, filenames).await, Self::CheckExecutablesHaveShebangs => { pre_commit_hooks::check_executables_have_shebangs(hook, filenames).await } Self::CheckJson => pre_commit_hooks::check_json(hook, filenames).await, Self::CheckJson5 => check_json5::check_json5(hook, filenames).await, Self::CheckMergeConflict => { pre_commit_hooks::check_merge_conflict(hook, filenames).await } Self::CheckSymlinks => pre_commit_hooks::check_symlinks(hook, filenames).await, Self::CheckToml => pre_commit_hooks::check_toml(hook, filenames).await, Self::CheckXml => pre_commit_hooks::check_xml(hook, filenames).await, Self::CheckYaml => pre_commit_hooks::check_yaml(hook, filenames).await, Self::DetectPrivateKey => pre_commit_hooks::detect_private_key(hook, filenames).await, Self::EndOfFileFixer => pre_commit_hooks::fix_end_of_file(hook, filenames).await, Self::FixByteOrderMarker => { pre_commit_hooks::fix_byte_order_marker(hook, filenames).await } Self::MixedLineEnding => pre_commit_hooks::mixed_line_ending(hook, filenames).await, Self::NoCommitToBranch => pre_commit_hooks::no_commit_to_branch(hook).await, Self::TrailingWhitespace => { pre_commit_hooks::fix_trailing_whitespace(hook, filenames).await } }; reporter.on_run_complete(progress); result } } impl BuiltinHook { pub(crate) fn from_id(id: &str) -> Result { let hook_id = BuiltinHooks::from_str(id).map_err(|_| ())?; Ok(match hook_id { BuiltinHooks::CheckAddedLargeFiles => BuiltinHook { id: "check-added-large-files".to_string(), name: "check for added large files".to_string(), entry: "check-added-large-files".to_string(), priority: None, options: HookOptions { description: Some("prevents giant files from being committed.".to_string()), stages: Some([Stage::PreCommit, Stage::PrePush, Stage::Manual].into()), ..Default::default() }, }, BuiltinHooks::CheckCaseConflict => BuiltinHook { id: "check-case-conflict".to_string(), name: "check for case conflicts".to_string(), entry: "check-case-conflict".to_string(), priority: None, options: HookOptions { description: Some( "checks for files that would conflict in case-insensitive filesystems" .to_string(), ), ..Default::default() }, }, BuiltinHooks::CheckExecutablesHaveShebangs => BuiltinHook { id: "check-executables-have-shebangs".to_string(), name: "check that executables have shebangs".to_string(), entry: "check-executables-have-shebangs".to_string(), priority: None, options: HookOptions { description: Some( "ensures that (non-binary) executables have a shebang.".to_string(), ), types: Some(tags::TAG_SET_EXECUTABLE_TEXT), stages: Some([Stage::PreCommit, Stage::PrePush, Stage::Manual].into()), ..Default::default() }, }, BuiltinHooks::CheckJson => BuiltinHook { id: "check-json".to_string(), name: "check json".to_string(), entry: "check-json".to_string(), priority: None, options: HookOptions { description: Some("checks json files for parseable syntax.".to_string()), types: Some(tags::TAG_SET_JSON), ..Default::default() }, }, BuiltinHooks::CheckJson5 => BuiltinHook { id: "check-json5".to_string(), name: "check json5".to_string(), entry: "check-json5".to_string(), priority: None, options: HookOptions { description: Some("checks json5 files for parseable syntax.".to_string()), types: Some(tags::TAG_SET_JSON5), ..Default::default() }, }, BuiltinHooks::CheckMergeConflict => BuiltinHook { id: "check-merge-conflict".to_string(), name: "check for merge conflicts".to_string(), entry: "check-merge-conflict".to_string(), priority: None, options: HookOptions { description: Some( "checks for files that contain merge conflict strings.".to_string(), ), types: Some(tags::TAG_SET_TEXT), ..Default::default() }, }, BuiltinHooks::CheckSymlinks => BuiltinHook { id: "check-symlinks".to_string(), name: "check for broken symlinks".to_string(), entry: "check-symlinks".to_string(), priority: None, options: HookOptions { description: Some( "checks for symlinks which do not point to anything.".to_string(), ), types: Some(tags::TAG_SET_SYMLINK), ..Default::default() }, }, BuiltinHooks::CheckToml => BuiltinHook { id: "check-toml".to_string(), name: "check toml".to_string(), entry: "check-toml".to_string(), priority: None, options: HookOptions { description: Some("checks toml files for parseable syntax.".to_string()), types: Some(tags::TAG_SET_TOML), ..Default::default() }, }, BuiltinHooks::CheckXml => BuiltinHook { id: "check-xml".to_string(), name: "check xml".to_string(), entry: "check-xml".to_string(), priority: None, options: HookOptions { description: Some("checks xml files for parseable syntax.".to_string()), types: Some(tags::TAG_SET_XML), ..Default::default() }, }, BuiltinHooks::CheckYaml => BuiltinHook { id: "check-yaml".to_string(), name: "check yaml".to_string(), entry: "check-yaml".to_string(), priority: None, options: HookOptions { description: Some("checks yaml files for parseable syntax.".to_string()), types: Some(tags::TAG_SET_YAML), ..Default::default() }, }, BuiltinHooks::DetectPrivateKey => BuiltinHook { id: "detect-private-key".to_string(), name: "detect private key".to_string(), entry: "detect-private-key".to_string(), priority: None, options: HookOptions { description: Some("detects the presence of private keys.".to_string()), types: Some(tags::TAG_SET_TEXT), ..Default::default() }, }, BuiltinHooks::EndOfFileFixer => BuiltinHook { id: "end-of-file-fixer".to_string(), name: "fix end of files".to_string(), entry: "end-of-file-fixer".to_string(), priority: None, options: HookOptions { description: Some( "ensures that a file is either empty, or ends with one newline." .to_string(), ), types: Some(tags::TAG_SET_TEXT), stages: Some([Stage::PreCommit, Stage::PrePush, Stage::Manual].into()), ..Default::default() }, }, BuiltinHooks::FixByteOrderMarker => BuiltinHook { id: "fix-byte-order-marker".to_string(), name: "fix utf-8 byte order marker".to_string(), entry: "fix-byte-order-marker".to_string(), priority: None, options: HookOptions { description: Some("removes utf-8 byte order marker.".to_string()), types: Some(tags::TAG_SET_TEXT), ..Default::default() }, }, BuiltinHooks::MixedLineEnding => BuiltinHook { id: "mixed-line-ending".to_string(), name: "mixed line ending".to_string(), entry: "mixed-line-ending".to_string(), priority: None, options: HookOptions { description: Some("replaces or checks mixed line ending.".to_string()), types: Some(tags::TAG_SET_TEXT), ..Default::default() }, }, BuiltinHooks::NoCommitToBranch => BuiltinHook { id: "no-commit-to-branch".to_string(), name: "don't commit to branch".to_string(), entry: "no-commit-to-branch".to_string(), priority: None, options: HookOptions { pass_filenames: Some(PassFilenames::None), always_run: Some(true), ..Default::default() }, }, BuiltinHooks::TrailingWhitespace => BuiltinHook { id: "trailing-whitespace".to_string(), name: "trim trailing whitespace".to_string(), entry: "trailing-whitespace-fixer".to_string(), priority: None, options: HookOptions { description: Some("trims trailing whitespace.".to_string()), types: Some(tags::TAG_SET_TEXT), stages: Some([Stage::PreCommit, Stage::PrePush, Stage::Manual].into()), ..Default::default() }, }, }) } } ================================================ FILE: crates/prek/src/hooks/meta_hooks.rs ================================================ use std::io::Write; use std::path::Path; use std::str::FromStr; use anyhow::{Context, Result}; use itertools::Itertools; use prek_consts::CONFIG_FILENAMES; use crate::cli::reporter::HookRunReporter; use crate::cli::run::{CollectOptions, FileFilter, collect_files}; use crate::config::{self, FilePattern, HookOptions, Language, MetaHook}; use crate::hook::Hook; use crate::store::Store; use crate::workspace::Project; // For builtin hooks (meta hooks and builtin pre-commit-hooks), they are not run // in the project root like other hooks. Instead, they run in the workspace root. // But the input filenames are all relative to the project root. So when accessing these files, // we need to adjust the paths by prepending the project relative path. // When matching files (files or exclude), we need to match against the filenames // relative to the project root. #[derive(Debug, Copy, Clone, PartialEq, Eq, strum::AsRefStr, strum::Display, strum::EnumString)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", schemars(rename_all = "kebab-case"))] #[strum(serialize_all = "kebab-case")] pub(crate) enum MetaHooks { CheckHooksApply, CheckUselessExcludes, Identity, } impl MetaHooks { pub(crate) async fn run( self, store: &Store, hook: &Hook, filenames: &[&Path], reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let result = match self { Self::CheckHooksApply => check_hooks_apply(store, hook, filenames).await, Self::CheckUselessExcludes => check_useless_excludes(hook, filenames).await, Self::Identity => Ok(identity(hook, filenames)), }; reporter.on_run_complete(progress); result } } impl MetaHook { pub(crate) fn from_id(id: &str) -> Result { let hook_id = MetaHooks::from_str(id).map_err(|_| ())?; let config_file_glob = FilePattern::new_glob(CONFIG_FILENAMES.iter().map(ToString::to_string).collect()) .unwrap(); Ok(match hook_id { MetaHooks::CheckHooksApply => MetaHook { id: "check-hooks-apply".to_string(), name: "Check hooks apply".to_string(), priority: None, options: HookOptions { files: Some(config_file_glob.clone()), ..Default::default() }, }, MetaHooks::CheckUselessExcludes => MetaHook { id: "check-useless-excludes".to_string(), name: "Check useless excludes".to_string(), priority: None, options: HookOptions { files: Some(config_file_glob), ..Default::default() }, }, MetaHooks::Identity => MetaHook { id: "identity".to_string(), name: "identity".to_string(), priority: None, options: HookOptions { verbose: Some(true), ..Default::default() }, }, }) } } /// Ensures that the configured hooks apply to at least one file in the repository. pub(crate) async fn check_hooks_apply( store: &Store, hook: &Hook, filenames: &[&Path], ) -> Result<(i32, Vec)> { let relative_path = hook.project().relative_path(); // Collect all files in the project let input = collect_files(hook.work_dir(), CollectOptions::all_files()).await?; // Prepend the project relative path to each input file let input: Vec<_> = input.into_iter().map(|f| relative_path.join(f)).collect(); let mut code = 0; let mut output = Vec::new(); for filename in filenames { let path = relative_path.join(filename); let mut project = Project::from_config_file(path.into(), None)?; project.with_relative_path(relative_path.to_path_buf()); let project_hooks = project .init_hooks(store, None) .await .context("Failed to init hooks")?; let filter = FileFilter::for_project(input.iter(), &project, None); for project_hook in project_hooks { if project_hook.always_run || matches!(project_hook.language, Language::Fail) { continue; } let filenames = filter.for_hook(&project_hook); if filenames.is_empty() { code = 1; writeln!( &mut output, "{} does not apply to this repository", project_hook.id )?; } } } Ok((code, output)) } // Returns true if the exclude pattern matches any files matching the include pattern. fn excludes_any( files: &[impl AsRef], include: Option<&FilePattern>, exclude: Option<&FilePattern>, ) -> bool { if exclude.is_none() { return true; } files.iter().any(|f| { let Some(f) = f.as_ref().to_str() else { return false; // Skip files that cannot be converted to a string }; if let Some(pattern) = &include { if !pattern.is_match(f) { return false; } } if let Some(pattern) = &exclude { if !pattern.is_match(f) { return false; } } true }) } /// Ensures that exclude directives apply to any file in the repository. pub(crate) async fn check_useless_excludes( hook: &Hook, filenames: &[&Path], ) -> Result<(i32, Vec)> { let relative_path = hook.project().relative_path(); // `collect_files` returns paths relative to the hook's project root. // The meta hook itself runs from the workspace root, so we build both: // - `input_project`: for matching `files`/`exclude` patterns (project-relative) // - `input_workspace`: for `FileFilter` (workspace-relative) let input_project = collect_files(hook.work_dir(), CollectOptions::all_files()).await?; let input_workspace: Vec<_> = input_project .iter() .map(|f| relative_path.join(f)) .collect(); let mut code = 0; let mut output = Vec::new(); for filename in filenames { let path = relative_path.join(filename); let mut project = Project::from_config_file(path.into(), None)?; project.with_relative_path(relative_path.to_path_buf()); let config = project.config(); if !excludes_any(&input_project, None, config.exclude.as_ref()) { code = 1; let display = config .exclude .as_ref() .map(ToString::to_string) .unwrap_or_default(); writeln!( &mut output, "The global exclude pattern `{display}` does not match any files" )?; } let filter = FileFilter::for_project(input_workspace.iter(), &project, None); for repo in &config.repos { let hooks_iter: Box> = match repo { config::Repo::Remote(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))), config::Repo::Local(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))), config::Repo::Meta(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))), config::Repo::Builtin(r) => Box::new(r.hooks.iter().map(|h| (&h.id, &h.options))), }; for (hook_id, opts) in hooks_iter { let filtered_files = filter.by_type( opts.types.as_ref(), opts.types_or.as_ref(), opts.exclude_types.as_ref(), ); // `filtered_files` is workspace-relative (it includes the project prefix). // Match patterns against paths relative to the project root. let filtered_files_relative: Vec<&Path> = if relative_path.as_os_str().is_empty() { filtered_files } else { filtered_files .into_iter() .filter_map(|f| f.strip_prefix(relative_path).ok()) .collect() }; if !excludes_any( &filtered_files_relative, opts.files.as_ref(), opts.exclude.as_ref(), ) { code = 1; let display = opts .exclude .as_ref() .map(ToString::to_string) .unwrap_or_default(); writeln!( &mut output, "The exclude pattern `{display}` for `{hook_id}` does not match any files" )?; } } } } Ok((code, output)) } /// Prints all arguments passed to the hook. Useful for debugging. pub fn identity(_hook: &Hook, filenames: &[&Path]) -> (i32, Vec) { ( 0, filenames .iter() .map(|f| f.to_string_lossy()) .join("\n") .into_bytes(), ) } #[cfg(test)] mod tests { use super::*; use prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_CONFIG_YML, PREK_TOML}; fn regex_pattern(pattern: &str) -> FilePattern { FilePattern::new_regex(pattern).unwrap() } #[test] fn test_excludes_any() { let files = vec![ Path::new("file1.txt"), Path::new("file2.txt"), Path::new("file3.txt"), ]; let include = regex_pattern(r"file.*"); let exclude = regex_pattern(r"file2\.txt"); assert!(excludes_any(&files, Some(&include), Some(&exclude))); let include = regex_pattern(r"file.*"); let exclude = regex_pattern(r"file4\.txt"); assert!(!excludes_any(&files, Some(&include), Some(&exclude))); assert!(excludes_any(&files, None, None)); let files = vec![Path::new("html/file1.html"), Path::new("html/file2.html")]; let exclude = regex_pattern(r"^html/"); assert!(excludes_any(&files, None, Some(&exclude))); } #[test] fn meta_hook_patterns_cover_config_files() { let apply = MetaHook::from_id("check-hooks-apply").expect("known meta hook"); let apply_files = apply.options.files.as_ref().expect("files should be set"); assert!(apply_files.is_match(PRE_COMMIT_CONFIG_YAML)); assert!(apply_files.is_match(PRE_COMMIT_CONFIG_YML)); assert!(apply_files.is_match(PREK_TOML)); let useless = MetaHook::from_id("check-useless-excludes").expect("known meta hook"); let useless_files = useless.options.files.as_ref().expect("files should be set"); assert!(useless_files.is_match(PRE_COMMIT_CONFIG_YAML)); assert!(useless_files.is_match(PRE_COMMIT_CONFIG_YML)); assert!(useless_files.is_match(PREK_TOML)); let identity = MetaHook::from_id("identity").expect("known meta hook"); assert!(identity.options.files.is_none()); assert_eq!(identity.options.verbose, Some(true)); } } ================================================ FILE: crates/prek/src/hooks/mod.rs ================================================ use std::future::Future; use std::path::Path; use std::str::FromStr; use std::sync::LazyLock; use prek_consts::env_vars::EnvVars; use crate::cli::reporter::HookRunReporter; use crate::hook::{Hook, Repo}; pub(crate) use crate::hooks::builtin_hooks::BuiltinHooks; pub(crate) use crate::hooks::meta_hooks::MetaHooks; use crate::hooks::pre_commit_hooks::{PreCommitHooks, is_pre_commit_hooks}; use crate::store::Store; mod builtin_hooks; mod meta_hooks; mod pre_commit_hooks; static NO_FAST_PATH: LazyLock = LazyLock::new(|| EnvVars::is_set(EnvVars::PREK_NO_FAST_PATH)); /// Returns true if the hook has a builtin Rust implementation. pub fn check_fast_path(hook: &Hook) -> bool { if *NO_FAST_PATH { return false; } match hook.repo() { Repo::Remote { url, .. } if is_pre_commit_hooks(url) => { let Ok(implemented) = PreCommitHooks::from_str(hook.id.as_str()) else { return false; }; implemented.check_supported(hook) } _ => false, } } pub async fn run_fast_path( _store: &Store, hook: &Hook, filenames: &[&Path], reporter: &HookRunReporter, ) -> anyhow::Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let result = match hook.repo() { Repo::Remote { url, .. } if is_pre_commit_hooks(url) => { PreCommitHooks::from_str(hook.id.as_str()) .unwrap() .run(hook, filenames) .await } _ => unreachable!(), }; reporter.on_run_complete(progress); result } pub(crate) async fn run_concurrent_file_checks<'a, I, F, Fut>( filenames: I, concurrency: usize, check: F, ) -> anyhow::Result<(i32, Vec)> where I: IntoIterator, F: Fn(&'a Path) -> Fut, Fut: Future)>>, { use futures::StreamExt; let mut tasks = futures::stream::iter(filenames) .map(check) .buffered(concurrency); let mut code = 0; let mut output = Vec::new(); while let Some(result) = tasks.next().await { let (c, o) = result?; code |= c; output.extend(o); } Ok((code, output)) } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/check_added_large_files.rs ================================================ use std::path::{Path, PathBuf}; use clap::Parser; use rustc_hash::FxHashSet; use crate::git::{get_added_files, get_lfs_files}; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; enum FileFilter { NoFilter, Files(FxHashSet), } impl FileFilter { fn contains(&self, path: &Path) -> bool { match self { FileFilter::NoFilter => true, FileFilter::Files(files) => files.contains(path), } } } #[derive(Parser)] #[command(disable_help_subcommand = true)] #[command(disable_version_flag = true)] #[command(disable_help_flag = true)] struct Args { #[arg(long)] enforce_all: bool, #[arg(long = "maxkb", default_value = "500")] max_kb: u64, } pub(crate) async fn check_added_large_files( hook: &Hook, filenames: &[&Path], ) -> anyhow::Result<(i32, Vec)> { let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; let filter = if args.enforce_all { FileFilter::NoFilter } else { let add_files = get_added_files(hook.work_dir()) .await? .into_iter() .collect::>(); FileFilter::Files(add_files) }; let lfs_files = get_lfs_files(filenames).await?; let filenames = filenames .iter() .copied() .filter(|f| filter.contains(f)) .filter(|f| !lfs_files.contains(*f)); run_concurrent_file_checks(filenames, *CONCURRENCY, |filename| async move { let file_path = hook.project().relative_path().join(filename); let size = fs_err::tokio::metadata(file_path).await?.len() / 1024; if size > args.max_kb { anyhow::Ok(( 1, format!( "{} ({size} KB) exceeds {} KB\n", filename.display(), args.max_kb ) .into_bytes(), )) } else { anyhow::Ok((0, Vec::new())) } }) .await } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/check_case_conflict.rs ================================================ use std::collections::hash_map::Entry; use std::path::Path; use anyhow::Result; use rustc_hash::FxHashMap; use rustc_hash::FxHashSet; use crate::git; use crate::hook::Hook; pub(crate) async fn check_case_conflict( hook: &Hook, filenames: &[&Path], ) -> Result<(i32, Vec)> { let work_dir = hook.work_dir(); // Get all files in the repo. let repo_files = git::ls_files(work_dir, Path::new(".")).await?; let mut repo_files_with_dirs: FxHashSet<&Path> = FxHashSet::default(); for path in &repo_files { insert_path_and_parents(&mut repo_files_with_dirs, path); } // Get relevant files (filenames + added files) and include their parent directories. let added = git::get_added_files(work_dir).await?; let mut relevant_files_with_dirs: FxHashSet<&Path> = FxHashSet::default(); for filename in filenames { insert_path_and_parents(&mut relevant_files_with_dirs, filename); } for path in &added { insert_path_and_parents(&mut relevant_files_with_dirs, path); } // Remove relevant files from repo files (avoid self-conflicts). for file in &relevant_files_with_dirs { repo_files_with_dirs.remove(file); } // Compute conflicts: // 1) relevant vs repo (case-insensitive intersection) // 2) relevant vs relevant (case-insensitive duplicates) let mut repo_lower: FxHashSet = FxHashSet::default(); repo_lower.reserve(repo_files_with_dirs.len()); for path in &repo_files_with_dirs { repo_lower.insert(lower_key(path)); } let mut conflicts: FxHashSet = FxHashSet::default(); let mut relevant_lower_counts: FxHashMap = FxHashMap::default(); relevant_lower_counts.reserve(relevant_files_with_dirs.len()); for path in &relevant_files_with_dirs { let lower = lower_key(path); if repo_lower.contains(&lower) { conflicts.insert(lower.clone()); } match relevant_lower_counts.entry(lower) { Entry::Vacant(entry) => { entry.insert(1); } Entry::Occupied(mut entry) => { let count = entry.get_mut(); *count = count.saturating_add(1); if *count == 2 { // Only mark the conflict on the *first* duplicate to avoid repeated // cloning/inserting for the 3rd+ occurrences of the same lowercase key. conflicts.insert(entry.key().clone()); } } } } let mut output = Vec::new(); if conflicts.is_empty() { return Ok((0, output)); } // The sets are disjoint at this point (relevant removed from repo), so we can just chain. let mut conflicting_files: Vec<_> = repo_files_with_dirs .iter() .chain(relevant_files_with_dirs.iter()) .filter(|path| conflicts.contains(&lower_key(path))) .collect(); conflicting_files.sort(); for filename in conflicting_files { let line = format!( "Case-insensitivity conflict found: {}\n", filename.display() ); output.extend(line.into_bytes()); } Ok((1, output)) } fn insert_path_and_parents<'p>(set: &mut FxHashSet<&'p Path>, file: &'p Path) { set.insert(file); let mut current = file; while let Some(parent) = current.parent() { if parent.as_os_str().is_empty() { break; } set.insert(parent); current = parent; } } fn lower_key(path: &Path) -> String { path.to_string_lossy().to_lowercase() } #[cfg(test)] mod tests { use super::*; #[test] fn test_insert_path_and_parents() { let mut set: FxHashSet<&Path> = FxHashSet::default(); insert_path_and_parents(&mut set, Path::new("foo/bar/baz.txt")); assert!(set.contains(Path::new("foo/bar/baz.txt"))); assert!(set.contains(Path::new("foo/bar"))); assert!(set.contains(Path::new("foo"))); assert_eq!(set.len(), 3); let mut set: FxHashSet<&Path> = FxHashSet::default(); insert_path_and_parents(&mut set, Path::new("single.txt")); assert!(set.contains(Path::new("single.txt"))); assert_eq!(set.len(), 1); } #[test] fn test_insert_path_and_parents_nested() { let mut set: FxHashSet<&Path> = FxHashSet::default(); insert_path_and_parents(&mut set, Path::new("a/b/c/d/e/f.txt")); for expected in [ "a/b/c/d/e/f.txt", "a/b/c/d/e", "a/b/c/d", "a/b/c", "a/b", "a", ] { assert!(set.contains(Path::new(expected))); } } #[test] fn test_insert_path_and_parents_no_slash() { let mut set: FxHashSet<&Path> = FxHashSet::default(); insert_path_and_parents(&mut set, Path::new("file.txt")); assert_eq!(set.len(), 1); } #[test] fn test_lower_key() { assert_eq!(lower_key(Path::new("Foo.txt")), "foo.txt"); assert_eq!(lower_key(Path::new("BAR.txt")), "bar.txt"); assert_eq!(lower_key(Path::new("baz.TXT")), "baz.txt"); } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/check_executables_have_shebangs.rs ================================================ use std::path::Path; use futures::StreamExt; use owo_colors::OwoColorize; use rustc_hash::FxHashSet; use tokio::io::AsyncReadExt; use crate::git; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; pub(crate) async fn check_executables_have_shebangs( hook: &Hook, filenames: &[&Path], ) -> Result<(i32, Vec), anyhow::Error> { let stdout = git::git_cmd("get file file mode")? .arg("config") .arg("core.fileMode") .check(true) .output() .await? .stdout; let tracks_executable_bit = std::str::from_utf8(&stdout)?.trim() != "false"; let file_base = hook.project().relative_path(); let (code, output) = if tracks_executable_bit { // core.fileMode=true means the platform honors the executable bit, so trust the FS metadata. // The `executables-have-shebangs` hook already restricts inputs to executable text files (`types: [text, executable]`). os_check_shebangs(file_base, filenames).await? } else { // If on win32 use git to check executable bit git_check_shebangs(file_base, filenames).await? }; Ok((code, output)) } async fn os_check_shebangs( file_base: &Path, paths: &[&Path], ) -> Result<(i32, Vec), anyhow::Error> { run_concurrent_file_checks(paths.iter().copied(), *CONCURRENCY, |file| async move { let file_path = file_base.join(file); let has_shebang = file_has_shebang(&file_path).await?; if has_shebang { anyhow::Ok((0, Vec::new())) } else { let msg = print_shebang_warning(file); Ok((1, msg.into_bytes())) } }) .await } fn print_shebang_warning(path: &Path) -> String { let path_str = path.display(); format!( "{}\n\ {}\n\ {}\n\ {}\n", format!( "{} marked executable but has no (or invalid) shebang!", path_str.yellow() ) .bold(), format!(" If it isn't supposed to be executable, try: 'chmod -x {path_str}'").dimmed(), format!(" If on Windows, you may also need to: 'git add --chmod=-x {path_str}'").dimmed(), " If it is supposed to be executable, double-check its shebang.".dimmed(), ) } async fn git_check_shebangs( file_base: &Path, filenames: &[&Path], ) -> Result<(i32, Vec), anyhow::Error> { let filenames: FxHashSet<_> = filenames.iter().collect(); let output = git::git_cmd("git ls-files")? .arg("ls-files") // Show staged contents' mode bits, object name and stage number in the output. .arg("--stage") .arg("-z") .arg("--") .arg(if file_base.as_os_str().is_empty() { Path::new(".") } else { file_base }) .check(true) .output() .await?; let entries = output.stdout.split(|&b| b == b'\0').filter_map(|entry| { let entry = str::from_utf8(entry).ok()?; if entry.is_empty() { return None; } let mut parts = entry.split('\t'); let metadata = parts.next()?; let file_name = parts.next()?; let file_name = Path::new(file_name); if !filenames.contains(&file_name) { return None; } let mode_str = metadata.split_whitespace().next()?; let mode_bits = u32::from_str_radix(mode_str, 8).ok()?; let is_executable = (mode_bits & 0o111) != 0; Some((file_name, is_executable)) }); let mut tasks = futures::stream::iter(entries) .map(async |(file_name, is_executable)| { if is_executable { let has_shebang = file_has_shebang(file_name).await?; if has_shebang { anyhow::Ok((0, Vec::new())) } else { let stripped = file_name.strip_prefix(file_base).unwrap_or(file_name); let msg = print_shebang_warning(stripped); Ok((1, msg.into_bytes())) } } else { Ok((0, Vec::new())) } }) .buffered(*CONCURRENCY); let mut code = 0; let mut output = Vec::new(); while let Some(result) = tasks.next().await { let (c, o) = result?; code |= c; output.extend(o); } Ok((code, output)) } /// Check first 2 bytes for shebang (#!) async fn file_has_shebang(path: &Path) -> Result { let mut file = fs_err::tokio::File::open(path).await?; let mut buf = [0u8; 2]; let n = file.read(&mut buf).await?; Ok(n >= 2 && buf[0] == b'#' && buf[1] == b'!') } #[cfg(test)] mod tests { use super::*; use tempfile::NamedTempFile; #[tokio::test] async fn test_file_with_shebang() -> Result<(), anyhow::Error> { let file = NamedTempFile::new()?; tokio::fs::write(file.path(), b"#!/bin/bash\necho Hello World\n").await?; assert!(file_has_shebang(file.path()).await?); Ok(()) } #[tokio::test] async fn test_file_without_shebang() -> Result<(), anyhow::Error> { let file = NamedTempFile::new()?; tokio::fs::write(file.path(), b"echo Hello World\n").await?; assert!(!file_has_shebang(file.path()).await?); Ok(()) } #[tokio::test] async fn test_empty_file() -> Result<(), anyhow::Error> { let file = NamedTempFile::new()?; tokio::fs::write(file.path(), b"").await?; assert!(!file_has_shebang(file.path()).await?); Ok(()) } #[tokio::test] async fn test_file_with_partial_shebang() -> Result<(), anyhow::Error> { let file = NamedTempFile::new()?; tokio::fs::write(file.path(), b"#\n").await?; assert!(!file_has_shebang(file.path()).await?); Ok(()) } #[tokio::test] async fn test_file_with_shebang_and_spaces() -> Result<(), anyhow::Error> { let file = NamedTempFile::new()?; tokio::fs::write(file.path(), b"#! /bin/bash\necho Test\n").await?; assert!(file_has_shebang(file.path()).await?); Ok(()) } #[tokio::test] async fn test_file_with_non_shebang_start() -> Result<(), anyhow::Error> { let file = NamedTempFile::new()?; tokio::fs::write(file.path(), b"##!/bin/bash\n").await?; assert!(!file_has_shebang(file.path()).await?); Ok(()) } #[tokio::test] async fn test_os_check_shebangs_with_shebang() -> Result<(), anyhow::Error> { let file = NamedTempFile::new()?; tokio::fs::write(file.path(), b"#!/bin/bash\necho ok\n").await?; let files = vec![file.path()]; let (code, output) = os_check_shebangs(Path::new(""), &files).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_os_check_shebangs_without_shebang() -> Result<(), anyhow::Error> { let file = NamedTempFile::new()?; tokio::fs::write(file.path(), b"echo ok\n").await?; let files = vec![file.path()]; let (code, output) = os_check_shebangs(Path::new(""), &files).await?; assert_eq!(code, 1); assert!( String::from_utf8_lossy(&output) .contains("marked executable but has no (or invalid) shebang!") ); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/check_json.rs ================================================ use std::path::Path; use anyhow::Result; use rustc_hash::FxHashMap; use serde::{Deserialize, Deserializer}; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; #[derive(Debug)] pub(crate) enum JsonValue { Object(FxHashMap), Array(Vec), String(String), Number(serde_json::Number), Bool(bool), Null, } pub(crate) async fn check_json(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { check_file(hook.project().relative_path(), filename) }) .await } async fn check_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec)> { let file_path = file_base.join(filename); let content = fs_err::tokio::read(file_path).await?; if content.is_empty() { return Ok((0, Vec::new())); } let mut deserializer = serde_json::Deserializer::from_slice(&content); deserializer.disable_recursion_limit(); let deserializer = serde_stacker::Deserializer::new(&mut deserializer); // Try to parse with duplicate key detection match JsonValue::deserialize(deserializer) { Ok(json) => { carefully_drop_nested_json(json); Ok((0, Vec::new())) } Err(e) => { let error_message = format!("{}: Failed to json decode ({e})\n", filename.display()); Ok((1, error_message.into_bytes())) } } } // For deeply nested JSON structures, `Drop` can cause stack overflow. fn carefully_drop_nested_json(value: JsonValue) { let mut stack = vec![value]; let mut map = FxHashMap::default(); while let Some(value) = stack.pop() { match value { JsonValue::Array(array) => stack.extend(array), JsonValue::Object(object) => map.extend(object), _ => {} } } } impl<'de> Deserialize<'de> for JsonValue { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { use serde::de::{self, MapAccess, SeqAccess, Visitor}; use std::fmt; struct JsonValueVisitor; impl<'de> Visitor<'de> for JsonValueVisitor { type Value = JsonValue; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("a JSON value") } fn visit_bool(self, v: bool) -> Result { Ok(JsonValue::Bool(v)) } fn visit_i64(self, v: i64) -> Result { Ok(JsonValue::Number(v.into())) } fn visit_u64(self, v: u64) -> Result { Ok(JsonValue::Number(v.into())) } fn visit_f64(self, v: f64) -> Result { Ok(JsonValue::Number(serde_json::Number::from_f64(v).unwrap())) } fn visit_str(self, v: &str) -> Result { Ok(JsonValue::String(v.to_string())) } fn visit_string(self, v: String) -> Result { Ok(JsonValue::String(v)) } fn visit_unit(self) -> Result { Ok(JsonValue::Null) } fn visit_seq(self, mut seq: A) -> Result where A: SeqAccess<'de>, { let mut vec = Vec::new(); while let Some(element) = seq.next_element()? { vec.push(element); } Ok(JsonValue::Array(vec)) } fn visit_map(self, mut map: A) -> Result where A: MapAccess<'de>, { let mut object = FxHashMap::default(); while let Some(key) = map.next_key::()? { if object.contains_key(&key) { return Err(de::Error::custom(format!("duplicate key `{key}`"))); } let value = map.next_value()?; object.insert(key, value); } Ok(JsonValue::Object(object)) } } deserializer.deserialize_any(JsonValueVisitor) } } #[cfg(test)] mod tests { use super::*; use std::path::{Path, PathBuf}; use tempfile::tempdir; async fn create_test_file( dir: &tempfile::TempDir, name: &str, content: &[u8], ) -> Result { let file_path = dir.path().join(name); fs_err::tokio::write(&file_path, content).await?; Ok(file_path) } #[tokio::test] async fn test_valid_json() -> Result<()> { let dir = tempdir()?; let content = br#"{"key1": "value1", "key2": "value2"}"#; let file_path = create_test_file(&dir, "valid.json", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_invalid_json() -> Result<()> { let dir = tempdir()?; let content = br#"{"key1": "value1", "key2": "value2""#; let file_path = create_test_file(&dir, "invalid.json", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } #[tokio::test] async fn test_duplicate_keys() -> Result<()> { let dir = tempdir()?; let content = br#"{"key1": "value1", "key1": "value2"}"#; let file_path = create_test_file(&dir, "duplicate.json", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } #[tokio::test] async fn test_empty_json() -> Result<()> { let dir = tempdir()?; let content = b""; let file_path = create_test_file(&dir, "empty.json", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_valid_json_array() -> Result<()> { let dir = tempdir()?; let content = br#"[{"key1": "value1"}, {"key2": "value2"}]"#; let file_path = create_test_file(&dir, "valid_array.json", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_duplicate_keys_in_nested_object() -> Result<()> { let dir = tempdir()?; let content = br#"{"key1": "value1", "key2": {"nested_key": 1, "nested_key": 2}}"#; let file_path = create_test_file(&dir, "nested_duplicate.json", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } #[tokio::test] async fn test_recursion_limit() -> Result<()> { let dir = tempdir()?; let mut json = String::new(); for _ in 0..10000 { json = format!("[{json}]"); } let file_path = create_test_file(&dir, "deeply_nested.json", json.as_bytes()).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/check_merge_conflict.rs ================================================ use std::path::Path; use anyhow::Result; use clap::Parser; use tokio::io::AsyncBufReadExt; use crate::git::get_git_dir; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; const CONFLICT_PATTERNS: &[&[u8]] = &[ b"<<<<<<< ", b"======= ", b"=======\r\n", b"=======\n", b">>>>>>> ", ]; #[derive(Parser)] #[command(disable_help_subcommand = true)] #[command(disable_version_flag = true)] #[command(disable_help_flag = true)] struct Args { #[arg(long)] assume_in_merge: bool, } pub(crate) async fn check_merge_conflict( hook: &Hook, filenames: &[&Path], ) -> Result<(i32, Vec)> { let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; // Check if we're in a merge state or assuming merge if !args.assume_in_merge && !is_in_merge().await? { return Ok((0, Vec::new())); } run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { check_file(hook.project().relative_path(), filename) }) .await } async fn is_in_merge() -> Result { // Change directory temporarily or ensure we're in the right directory let git_dir = get_git_dir().await?; // Check if MERGE_MSG exists let merge_msg_exists = git_dir.join("MERGE_MSG").exists(); if !merge_msg_exists { return Ok(false); } // Check if any of the merge state files exist Ok(git_dir.join("MERGE_HEAD").exists() || git_dir.join("rebase-apply").exists() || git_dir.join("rebase-merge").exists()) } async fn check_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec)> { let file_path = file_base.join(filename); let file = fs_err::tokio::File::open(&file_path).await?; let mut reader = tokio::io::BufReader::new(file); let mut code = 0; let mut output = Vec::new(); let mut line = Vec::new(); let mut line_number = 1; while reader.read_until(b'\n', &mut line).await? != 0 { // Check all patterns for pattern in CONFLICT_PATTERNS { if line.starts_with(pattern) { // Don't trim the pattern - display it as-is (minus any line endings) let pattern_display = if pattern.ends_with(b"\r\n") { &pattern[..pattern.len() - 2] } else if pattern.ends_with(b"\n") { &pattern[..pattern.len() - 1] } else { pattern }; let pattern_str = str::from_utf8(pattern_display) .expect("conflict pattern should be valid UTF-8"); let error_message = format!( "{}:{line_number}: Merge conflict string {pattern_str:?} found\n", filename.display(), ); output.extend(error_message.into_bytes()); code = 1; break; // Only report one pattern per line } } line.clear(); line_number += 1; } Ok((code, output)) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; use tempfile::tempdir; async fn create_test_file( dir: &tempfile::TempDir, name: &str, content: &[u8], ) -> Result { let file_path = dir.path().join(name); fs_err::tokio::write(&file_path, content).await?; Ok(file_path) } #[tokio::test] async fn test_no_conflict_markers() -> Result<()> { let dir = tempdir()?; let content = b"This is a normal file\nWith no conflict markers\n"; let file_path = create_test_file(&dir, "clean.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_conflict_marker_start() -> Result<()> { let dir = tempdir()?; let content = b"Some content\n<<<<<<< HEAD\nConflicting line\n"; let file_path = create_test_file(&dir, "conflict.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("<<<<<<< ")); assert!(output_str.contains("conflict.txt:2")); Ok(()) } #[tokio::test] async fn test_conflict_marker_middle() -> Result<()> { let dir = tempdir()?; let content = b"Some content\n======= \nConflicting line\n"; let file_path = create_test_file(&dir, "conflict.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("======= ")); Ok(()) } #[tokio::test] async fn test_conflict_marker_end() -> Result<()> { let dir = tempdir()?; let content = b"Some content\n>>>>>>> branch\nMore content\n"; let file_path = create_test_file(&dir, "conflict.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains(">>>>>>> ")); Ok(()) } #[tokio::test] async fn test_full_conflict_block() -> Result<()> { let dir = tempdir()?; let content = b"Before conflict\n<<<<<<< HEAD\nOur changes\n=======\nTheir changes\n>>>>>>> branch\nAfter conflict\n"; let file_path = create_test_file(&dir, "conflict.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); let output_str = String::from_utf8_lossy(&output); // Should find all three markers assert!(output_str.contains("<<<<<<< ")); assert!(output_str.contains("=======")); assert!(output_str.contains(">>>>>>> ")); Ok(()) } #[tokio::test] async fn test_conflict_marker_not_at_start() -> Result<()> { let dir = tempdir()?; let content = b"Some content <<<<<<< HEAD\n"; let file_path = create_test_file(&dir, "no_conflict.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; // Should not detect conflict since marker is not at line start assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_conflict_marker_crlf() -> Result<()> { let dir = tempdir()?; let content = b"Some content\r\n=======\r\nConflicting line\r\n"; let file_path = create_test_file(&dir, "conflict_crlf.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } #[tokio::test] async fn test_conflict_marker_lf() -> Result<()> { let dir = tempdir()?; let content = b"Some content\n=======\nConflicting line\n"; let file_path = create_test_file(&dir, "conflict_lf.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } #[tokio::test] async fn test_empty_file() -> Result<()> { let dir = tempdir()?; let content = b""; let file_path = create_test_file(&dir, "empty.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_multiple_conflicts() -> Result<()> { let dir = tempdir()?; let content = b"<<<<<<< HEAD\nFirst\n=======\nSecond\n>>>>>>> branch\nMiddle\n<<<<<<< HEAD\nThird\n=======\nFourth\n>>>>>>> other\n"; let file_path = create_test_file(&dir, "multiple.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); let output_str = String::from_utf8_lossy(&output); // Should find all markers from both conflicts (one per line with marker) let marker_count = output_str.matches("Merge conflict string").count(); assert_eq!(marker_count, 6); // 3 markers per conflict * 2 conflicts Ok(()) } #[tokio::test] async fn test_binary_file_with_conflict() -> Result<()> { let dir = tempdir()?; let mut content = vec![0xFF, 0xFE, 0xFD]; content.extend_from_slice(b"\n<<<<<<< HEAD\n"); let file_path = create_test_file(&dir, "binary.bin", &content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/check_symlinks.rs ================================================ use std::path::Path; use anyhow::Result; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; pub(crate) async fn check_symlinks(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { check_file(hook.project().relative_path(), filename) }) .await } #[allow(clippy::unused_async)] async fn check_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec)> { let path = file_base.join(filename); // Check if it's a symlink and if it's broken if path.is_symlink() && !path.exists() { let error_message = format!("{}: Broken symlink\n", filename.display()); return Ok((1, error_message.into_bytes())); } Ok((0, Vec::new())) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; use tempfile::tempdir; async fn create_test_file( dir: &tempfile::TempDir, name: &str, content: &[u8], ) -> Result { let file_path = dir.path().join(name); fs_err::tokio::write(&file_path, content).await?; Ok(file_path) } #[tokio::test] async fn test_regular_file() -> Result<()> { let dir = tempdir()?; let content = b"regular file content"; let file_path = create_test_file(&dir, "regular.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] #[cfg(unix)] async fn test_valid_symlink_unix() -> Result<()> { let dir = tempdir()?; let target = create_test_file(&dir, "target.txt", b"content").await?; let link_path = dir.path().join("link.txt"); tokio::fs::symlink(&target, &link_path).await?; let (code, output) = check_file(Path::new(""), &link_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] #[cfg(unix)] async fn test_broken_symlink_unix() -> Result<()> { let dir = tempdir()?; let link_path = dir.path().join("broken_link.txt"); let nonexistent = dir.path().join("nonexistent.txt"); tokio::fs::symlink(&nonexistent, &link_path).await?; let (code, output) = check_file(Path::new(""), &link_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("Broken symlink")); Ok(()) } #[tokio::test] #[cfg(windows)] async fn test_valid_symlink_windows() -> Result<()> { let dir = tempdir()?; let target = create_test_file(&dir, "target.txt", b"content").await?; let link_path = dir.path().join("link.txt"); // Windows requires different APIs for file vs directory symlinks if tokio::fs::symlink_file(&target, &link_path).await.is_err() { // Skipping test: insufficient permissions for symlink creation on Windows return Ok(()); } let (code, output) = check_file(Path::new(""), &link_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] #[cfg(windows)] async fn test_broken_symlink_windows() -> Result<()> { let dir = tempdir()?; let link_path = dir.path().join("broken_link.txt"); let nonexistent = dir.path().join("nonexistent.txt"); // On Windows, symlink creation might require admin privileges // If this fails in CI, the test will be skipped if tokio::fs::symlink_file(&nonexistent, &link_path) .await .is_err() { // Skipping test: insufficient permissions for symlink creation on Windows return Ok(()); } let (code, output) = check_file(Path::new(""), &link_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("Broken symlink")); Ok(()) } #[tokio::test] #[cfg(target_os = "macos")] async fn test_valid_symlink_macos() -> Result<()> { let dir = tempdir()?; let target = create_test_file(&dir, "target.txt", b"content").await?; let link_path = dir.path().join("link.txt"); tokio::fs::symlink(&target, &link_path).await?; let (code, output) = check_file(Path::new(""), &link_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] #[cfg(target_os = "macos")] async fn test_broken_symlink_macos() -> Result<()> { let dir = tempdir()?; let link_path = dir.path().join("broken_link.txt"); let nonexistent = dir.path().join("nonexistent.txt"); tokio::fs::symlink(&nonexistent, &link_path).await?; let (code, output) = check_file(Path::new(""), &link_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("Broken symlink")); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/check_toml.rs ================================================ use std::path::Path; use anyhow::Result; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; pub(crate) async fn check_toml(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { check_file(hook.project().relative_path(), filename) }) .await } async fn check_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec)> { let content = fs_err::tokio::read(file_base.join(filename)).await?; if content.is_empty() { return Ok((0, Vec::new())); } // Use string content for borrowed parsing let content_str = match std::str::from_utf8(&content) { Ok(s) => s, Err(e) => { let error_message = format!("{}: Failed to decode UTF-8 ({e})\n", filename.display()); return Ok((1, error_message.into_bytes())); } }; // Use DeTable::parse_recoverable to report all parse errors at once let (_parsed, errors) = toml::de::DeTable::parse_recoverable(content_str); if errors.is_empty() { Ok((0, Vec::new())) } else { let mut error_messages = Vec::new(); for error in errors { error_messages.push(format!( "{}: Failed to toml decode ({error})", filename.display() )); } let combined_errors = error_messages.join("\n") + "\n"; Ok((1, combined_errors.into_bytes())) } } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; use tempfile::tempdir; async fn create_test_file( dir: &tempfile::TempDir, name: &str, content: &[u8], ) -> Result { let file_path = dir.path().join(name); fs_err::tokio::write(&file_path, content).await?; Ok(file_path) } #[tokio::test] async fn test_valid_toml() -> Result<()> { let dir = tempdir()?; let content = br#"key1 = "value1" key2 = "value2" "#; let file_path = create_test_file(&dir, "valid.toml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_invalid_toml() -> Result<()> { let dir = tempdir()?; let content = br#"key1 = "value1 key2 = "value2" "#; let file_path = create_test_file(&dir, "invalid.toml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } #[tokio::test] async fn test_duplicate_keys() -> Result<()> { let dir = tempdir()?; let content = br#"key1 = "value1" key1 = "value2" "#; let file_path = create_test_file(&dir, "duplicate.toml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } #[tokio::test] async fn test_empty_toml() -> Result<()> { let dir = tempdir()?; let content = b""; let file_path = create_test_file(&dir, "empty.toml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_multiple_errors_reported() -> Result<()> { let dir = tempdir()?; // TOML with multiple syntax errors let content = br#"key1 = "unclosed string key2 = "value2" key3 = invalid_value_without_quotes [section key4 = "another unclosed string "#; let file_path = create_test_file(&dir, "multiple_errors.toml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); let output_str = String::from_utf8_lossy(&output); // Should contain multiple error messages (one for each error found) let error_count = output_str.matches("Failed to toml decode").count(); assert!(error_count == 3, "Expected three errors, got: {output_str}"); Ok(()) } #[tokio::test] async fn test_invalid_utf8() -> Result<()> { let dir = tempdir()?; // Create content with invalid UTF-8 bytes let content = b"key1 = \"\xff\xfe\xfd\"\nkey2 = \"valid\""; let file_path = create_test_file(&dir, "invalid_utf8.toml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("Failed to decode UTF-8")); assert!(output_str.contains("invalid_utf8.toml")); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/check_xml.rs ================================================ use std::path::Path; use anyhow::Result; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; pub(crate) async fn check_xml(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { check_file(hook.project().relative_path(), filename) }) .await } async fn check_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec)> { let content = fs_err::tokio::read(file_base.join(filename)).await?; // Empty XML is invalid - should have at least one element if content.is_empty() { let error_message = format!( "{}: Failed to xml parse (no element found)\n", filename.display() ); return Ok((1, error_message.into_bytes())); } let mut reader = quick_xml::Reader::from_reader(&content[..]); reader.config_mut().check_end_names = true; reader.config_mut().expand_empty_elements = true; let mut buf = Vec::new(); let mut root_count = 0; let mut depth = 0; loop { match reader.read_event_into(&mut buf) { Ok(quick_xml::events::Event::Eof) => break, Ok(quick_xml::events::Event::Start(_)) => { if depth == 0 { root_count += 1; if root_count > 1 { let error_message = format!( "{}: Failed to xml parse (junk after document element)\n", filename.display() ); return Ok((1, error_message.into_bytes())); } } depth += 1; } Ok(quick_xml::events::Event::End(_)) => { depth -= 1; } Err(e) => { let error_message = format!("{}: Failed to xml parse ({e})\n", filename.display()); return Ok((1, error_message.into_bytes())); } Ok(_) => {} } buf.clear(); } Ok((0, Vec::new())) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; use tempfile::tempdir; async fn create_test_file( dir: &tempfile::TempDir, name: &str, content: &[u8], ) -> Result { let file_path = dir.path().join(name); fs_err::tokio::write(&file_path, content).await?; Ok(file_path) } #[tokio::test] async fn test_valid_xml() -> Result<()> { let dir = tempdir()?; let content = br#" value "#; let file_path = create_test_file(&dir, "valid.xml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_invalid_xml_unclosed_tag() -> Result<()> { let dir = tempdir()?; let content = br#" value "#; let file_path = create_test_file(&dir, "invalid.xml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("Failed to xml parse")); Ok(()) } #[tokio::test] async fn test_invalid_xml_mismatched_tags() -> Result<()> { let dir = tempdir()?; let content = br#" value "#; let file_path = create_test_file(&dir, "mismatched.xml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } #[tokio::test] async fn test_invalid_xml_syntax_error() -> Result<()> { let dir = tempdir()?; let content = br#" Result<()> { let dir = tempdir()?; let content = b""; let file_path = create_test_file(&dir, "empty.xml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); // Changed from 0 to 1 assert!(!output.is_empty()); // Changed from is_empty() to !is_empty() let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("no element found")); Ok(()) } #[tokio::test] async fn test_valid_xml_with_attributes() -> Result<()> { let dir = tempdir()?; let content = br#" value another value "#; let file_path = create_test_file(&dir, "attributes.xml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_valid_xml_with_cdata() -> Result<()> { let dir = tempdir()?; let content = br#" characters & symbols]]> "#; let file_path = create_test_file(&dir, "cdata.xml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_valid_xml_with_comments() -> Result<()> { let dir = tempdir()?; let content = br#" value "#; let file_path = create_test_file(&dir, "comments.xml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_xml_with_doctype() -> Result<()> { let dir = tempdir()?; let content = br#" value "#; let file_path = create_test_file(&dir, "doctype.xml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_invalid_xml_no_root() -> Result<()> { let dir = tempdir()?; let content = br#" value value"#; let file_path = create_test_file(&dir, "no_root.xml", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/check_yaml.rs ================================================ use std::path::Path; use anyhow::Result; use clap::Parser; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; #[derive(Parser)] #[command(disable_help_subcommand = true)] #[command(disable_version_flag = true)] #[command(disable_help_flag = true)] struct Args { #[arg(long, short = 'm', alias = "multi")] allow_multiple_documents: bool, // `--unsafe` flag is not supported yet. // #[arg(long)] // r#unsafe: bool, } pub(crate) async fn check_yaml(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { check_file( hook.project().relative_path(), filename, args.allow_multiple_documents, ) }) .await } async fn check_file( file_base: &Path, filename: &Path, allow_multi_docs: bool, ) -> Result<(i32, Vec)> { let content = fs_err::tokio::read(file_base.join(filename)).await?; if content.is_empty() { return Ok((0, Vec::new())); } let options = serde_saphyr::Options { budget: Some(serde_saphyr::Budget { // `check-yaml` is a syntax/structure validator, not a service parsing // untrusted YAML at runtime. Keep the absolute caps, but allow // high-reuse anchors that are common in compose-style files. enforce_alias_anchor_ratio: false, ..Default::default() }), ignore_binary_tag_for_string: true, ..Default::default() }; if allow_multi_docs { if let Err(e) = serde_saphyr::from_slice_multiple_with_options::( &content, options.clone(), ) { let error_message = format!("{}: Failed to yaml decode ({e})\n", filename.display()); return Ok((1, error_message.into_bytes())); } Ok((0, Vec::new())) } else { match serde_saphyr::from_slice_with_options::(&content, options) { Ok(_) => Ok((0, Vec::new())), Err(e) => { let err = e.render_with_formatter(&serde_saphyr::UserMessageFormatter); let error_message = format!("{}: Failed to yaml decode ({err})\n", filename.display()); Ok((1, error_message.into_bytes())) } } } } #[cfg(test)] mod tests { use super::*; use std::fmt::Write; use std::path::PathBuf; use tempfile::tempdir; async fn create_test_file( dir: &tempfile::TempDir, name: &str, content: &[u8], ) -> Result { let file_path = dir.path().join(name); fs_err::tokio::write(&file_path, content).await?; Ok(file_path) } #[tokio::test] async fn test_valid_yaml() -> Result<()> { let dir = tempdir()?; let content = br"key1: value1 key2: value2 "; let file_path = create_test_file(&dir, "valid.yaml", content).await?; let (code, output) = check_file(Path::new(""), &file_path, false).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_invalid_yaml() -> Result<()> { let dir = tempdir()?; let content = br"key1: value1 key2: value2: another_value "; let file_path = create_test_file(&dir, "invalid.yaml", content).await?; let (code, output) = check_file(Path::new(""), &file_path, false).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } #[tokio::test] async fn test_duplicate_keys() -> Result<()> { let dir = tempdir()?; let content = br"key1: value1 key1: value2 "; let file_path = create_test_file(&dir, "duplicate.yaml", content).await?; let (code, output) = check_file(Path::new(""), &file_path, false).await?; assert_eq!(code, 1); assert!(!output.is_empty()); Ok(()) } #[tokio::test] async fn test_empty_yaml() -> Result<()> { let dir = tempdir()?; let content = b""; let file_path = create_test_file(&dir, "empty.yaml", content).await?; let (code, output) = check_file(Path::new(""), &file_path, false).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_multiple_documents() -> Result<()> { let dir = tempdir()?; let content = b"\ --- key1: value1 --- key2: value2 "; let file_path = create_test_file(&dir, "multi.yaml", content).await?; let (code, output) = check_file(Path::new(""), &file_path, false).await?; assert_eq!(code, 1); assert!(!output.is_empty()); let (code, output) = check_file(Path::new(""), &file_path, true).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_yaml_with_binary_scalar() -> Result<()> { let dir = tempdir()?; let content = b"\ response: body: string: !!binary | H4sIAAAAAAAAA4xTPW/bMBDd9SsON9uFJaeJ4y0oujRIEXQpisiQaOokM6VIgjzFSQ3/94KSYzmt A2TRwPfBd/eoXQKAqsIloNwIlq3T0y/rF6JfbXYT2m3rvan+NLfXt/zj2/f5NsVJVNj1I0l+VX2S tnWaWFkzwNKTYIqu6dXlPL28mmeLHmhtRTrKGsfTCzvNZtnFNE2n2ewg3FglKeASHhIAgF3/jRFN Rc+4hNnk9aSlEERDuDySANBbHU9QhKACC8M4GUFpDZPpU5dl+Risyc0uNwA5smJNOS4hxxu4Jx8c SVZPBNbA12enhRFxugC2hjurSXZaeLj3VCkZAbiLg4UcJ4Of6HhjfYiODzn+JK3FVjATEIPQOa4O vMqqyDGd1rnZ56Ysy9PEnuouCH1gnADCGMtDpHjF6oDsj9vRtnHersM/UqyVUWFTeBLBmriJwNZh j+4TgFXfQvdmsei8bR0XbH9Tf91iPtjhWPsIzq8PIFsWejxPs2xyxq6oiIXS4aRGlEJuqBqlY+ei q5Q9AZKTof9Pc857GFyZ5iP2IyAlOaaqcMfGz9E8xb/iPdpxyX1gDOSflKSCFflYREW16PTwYDG8 BKa2qJVpyDuvhldbu0LOFtnicypnC0z2yV8AAAD//wMALvIkjL4DAAA= "; let file_path = create_test_file(&dir, "binary.yaml", content).await?; let (code, output) = check_file(Path::new(""), &file_path, false).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_yaml_with_many_aliases_and_few_anchors() -> Result<()> { let dir = tempdir()?; let mut content = indoc::formatdoc! {" defaults: &defaults image: alpine services: "}; for index in 0..158 { let _ = write!(content, " svc{index}:\n <<: *defaults\n"); } let file_path = create_test_file(&dir, "many-aliases.yaml", content.as_bytes()).await?; let (code, output) = check_file(Path::new(""), &file_path, false).await?; assert_eq!(code, 0, "{}", String::from_utf8_lossy(&output)); assert!(output.is_empty()); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/detect_private_key.rs ================================================ use std::path::Path; use std::sync::LazyLock; use aho_corasick::AhoCorasick; use anyhow::Result; use tokio::io::AsyncReadExt; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; const BLACKLIST: &[&[u8]] = &[ b"BEGIN RSA PRIVATE KEY", b"BEGIN DSA PRIVATE KEY", b"BEGIN EC PRIVATE KEY", b"BEGIN OPENSSH PRIVATE KEY", b"BEGIN PRIVATE KEY", b"PuTTY-User-Key-File-2", b"BEGIN SSH2 ENCRYPTED PRIVATE KEY", b"BEGIN PGP PRIVATE KEY BLOCK", b"BEGIN ENCRYPTED PRIVATE KEY", b"BEGIN OpenVPN Static key V1", ]; const BUFFER_SIZE: usize = 8192; // Keep at most the longest marker minus one byte so split matches can span two reads. const CARRY_CAPACITY: usize = { let mut max_len = 0; let mut idx = 0; while idx < BLACKLIST.len() { let len = BLACKLIST[idx].len(); if len > max_len { max_len = len; } idx += 1; } max_len.saturating_sub(1) }; static PRIVATE_KEY_MATCHER: LazyLock = LazyLock::new(|| { AhoCorasick::new(BLACKLIST).expect("private key blacklist patterns should be valid") }); pub(crate) async fn detect_private_key(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { check_file(hook.project().relative_path(), filename) }) .await } /// Scan the file in chunks while preserving a small tail between reads. /// /// For example, if one read ends with `BEGIN RSA PRIV` and the next read starts /// with `ATE KEY`, we keep the tail of the first read, prepend it to the second /// read, and search the combined window so `BEGIN RSA PRIVATE KEY` is still found. async fn check_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec)> { let mut file = fs_err::tokio::File::open(file_base.join(filename)).await?; let mut buf = vec![0u8; BUFFER_SIZE + CARRY_CAPACITY]; let mut carry_len = 0; loop { let bytes_read = file.read(&mut buf[carry_len..]).await?; if bytes_read == 0 { break; } let search_len = carry_len + bytes_read; let search_buf = &buf[..search_len]; if PRIVATE_KEY_MATCHER.find(search_buf).is_some() { let error_message = format!("Private key found: {}\n", filename.display()); return Ok((1, error_message.into_bytes())); } // Move the tail of this chunk to the front of the buffer so a key marker // split across this read and the next read is still seen. carry_len = CARRY_CAPACITY.min(search_len); if carry_len > 0 { buf.copy_within(search_len - carry_len..search_len, 0); } } Ok((0, Vec::new())) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; use tempfile::tempdir; async fn create_test_file( dir: &tempfile::TempDir, name: &str, content: &[u8], ) -> Result { let file_path = dir.path().join(name); fs_err::tokio::write(&file_path, content).await?; Ok(file_path) } #[tokio::test] async fn test_no_private_key() -> Result<()> { let dir = tempdir()?; let content = b"This is just a regular file\nwith some content\n"; let file_path = create_test_file(&dir, "clean.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_rsa_private_key() -> Result<()> { let dir = tempdir()?; let content = b"-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----\n"; let file_path = create_test_file(&dir, "id_rsa", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("Private key found")); assert!(output_str.contains("id_rsa")); Ok(()) } #[tokio::test] async fn test_key_in_middle_of_file() -> Result<()> { let dir = tempdir()?; let content = b"Some documentation\n\nHere is a key:\n-----BEGIN RSA PRIVATE KEY-----\ndata\n"; let file_path = create_test_file(&dir, "doc.txt", content).await?; let (code, _output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); Ok(()) } #[tokio::test] async fn test_false_positive_similar_text() -> Result<()> { let dir = tempdir()?; let content = b"This file talks about BEGIN_RSA_PRIVATE_KEY but doesn't contain one\n"; let file_path = create_test_file(&dir, "false_positive.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_empty_file() -> Result<()> { let dir = tempdir()?; let content = b""; let file_path = create_test_file(&dir, "empty.txt", content).await?; let (code, output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_binary_file_with_key() -> Result<()> { let dir = tempdir()?; let mut content = vec![0xFF, 0xFE, 0x00]; content.extend_from_slice(b"BEGIN RSA PRIVATE KEY"); let file_path = create_test_file(&dir, "binary.dat", &content).await?; let (code, _output) = check_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/fix_byte_order_marker.rs ================================================ use std::path::Path; use anyhow::Result; use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt, SeekFrom}; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; const UTF8_BOM: &[u8] = b"\xef\xbb\xbf"; const BUFFER_SIZE: usize = 8192; // 8KB buffer for streaming pub(crate) async fn fix_byte_order_marker( hook: &Hook, filenames: &[&Path], ) -> Result<(i32, Vec)> { run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { fix_file(hook.project().relative_path(), filename) }) .await } async fn fix_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec)> { let file_path = file_base.join(filename); let mut file = fs_err::tokio::OpenOptions::new() .read(true) .write(true) .open(&file_path) .await?; let file_len = file.seek(SeekFrom::End(0)).await?; if file_len < UTF8_BOM.len() as u64 { return Ok((0, Vec::new())); } let mut bom_buffer = [0u8; 3]; file.seek(SeekFrom::Start(0)).await?; file.read_exact(&mut bom_buffer).await?; if bom_buffer != UTF8_BOM { return Ok((0, Vec::new())); } if file_len == UTF8_BOM.len() as u64 { file.set_len(0).await?; } else { // Shift the payload in place so large files do not need a full second buffer. shift_file_left(&mut file, UTF8_BOM.len() as u64).await?; } Ok(( 1, format!("{}: removed byte-order marker\n", filename.display()).into_bytes(), )) } async fn shift_file_left(file: &mut fs_err::tokio::File, offset: u64) -> Result<()> { let file_len = file.seek(SeekFrom::End(0)).await?; let mut buf = vec![0u8; BUFFER_SIZE]; let mut read_pos = offset; let mut write_pos = 0; while read_pos < file_len { // Read after the BOM and rewrite earlier in the same file. let remaining = usize::try_from(file_len - read_pos)?; let chunk_len = BUFFER_SIZE.min(remaining); file.seek(SeekFrom::Start(read_pos)).await?; file.read_exact(&mut buf[..chunk_len]).await?; file.seek(SeekFrom::Start(write_pos)).await?; file.write_all(&buf[..chunk_len]).await?; read_pos += chunk_len as u64; write_pos += chunk_len as u64; } file.set_len(file_len - offset).await?; Ok(()) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; use tempfile::tempdir; async fn create_test_file( dir: &tempfile::TempDir, name: &str, content: &[u8], ) -> Result { let file_path = dir.path().join(name); fs_err::tokio::write(&file_path, content).await?; Ok(file_path) } #[tokio::test] async fn test_file_with_bom() -> Result<()> { let dir = tempdir()?; let content = b"\xef\xbb\xbfHello, World!"; let file_path = create_test_file(&dir, "with_bom.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("removed byte-order marker")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b"Hello, World!"); Ok(()) } #[tokio::test] async fn test_file_without_bom() -> Result<()> { let dir = tempdir()?; let content = b"Hello, World!"; let file_path = create_test_file(&dir, "without_bom.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, content); Ok(()) } #[tokio::test] async fn test_empty_file() -> Result<()> { let dir = tempdir()?; let content = b""; let file_path = create_test_file(&dir, "empty.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, content); Ok(()) } #[tokio::test] async fn test_file_shorter_than_bom() -> Result<()> { let dir = tempdir()?; let content = b"Hi"; let file_path = create_test_file(&dir, "short.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, content); Ok(()) } #[tokio::test] async fn test_file_with_partial_bom() -> Result<()> { let dir = tempdir()?; let content = b"\xef\xbbHello"; // Only first 2 bytes of BOM let file_path = create_test_file(&dir, "partial_bom.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 0); assert!(output.is_empty()); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, content); Ok(()) } #[tokio::test] async fn test_bom_only_file() -> Result<()> { let dir = tempdir()?; let content = b"\xef\xbb\xbf"; let file_path = create_test_file(&dir, "bom_only.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("removed byte-order marker")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b""); Ok(()) } #[tokio::test] async fn test_utf8_content_with_bom() -> Result<()> { let dir = tempdir()?; let content = b"\xef\xbb\xbf\xe4\xb8\xad\xe6\x96\x87"; // BOM + Chinese characters "中文" let file_path = create_test_file(&dir, "utf8_with_bom.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("removed byte-order marker")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b"\xe4\xb8\xad\xe6\x96\x87"); // Just the Chinese characters // Verify we can still read it as valid UTF-8 let text = String::from_utf8(new_content)?; assert_eq!(text, "中文"); Ok(()) } #[tokio::test] async fn test_large_file_streaming() -> Result<()> { let dir = tempdir()?; // Create a large file (>64KB) with BOM let mut content = Vec::with_capacity(100_000); content.extend_from_slice(b"\xef\xbb\xbf"); content.extend(b"x".repeat(100_000)); let file_path = create_test_file(&dir, "large_with_bom.txt", &content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 1); let output_str = String::from_utf8_lossy(&output); assert!(output_str.contains("removed byte-order marker")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content.len(), 100_000); assert!(new_content.iter().all(|&b| b == b'x')); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/fix_end_of_file.rs ================================================ use std::path::Path; use anyhow::Result; use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeek, AsyncSeekExt, AsyncWriteExt, SeekFrom}; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; pub(crate) async fn fix_end_of_file(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { fix_file(hook.project().relative_path(), filename) }) .await } async fn fix_file(file_base: &Path, filename: &Path) -> Result<(i32, Vec)> { let file_path = file_base.join(filename); let mut file = fs_err::tokio::OpenOptions::new() .read(true) .write(true) .open(file_path) .await?; // If the file is empty, do nothing. let file_size = file.metadata().await?.len(); if file_size == 0 { return Ok((0, Vec::new())); } match find_last_non_ending(&mut file).await? { (None, _) => { // File contains only line endings, so we can just set it to empty. file.set_len(0).await?; file.flush().await?; file.shutdown().await?; Ok((1, format!("Fixing {}\n", filename.display()).into_bytes())) } (Some(pos), None) => { // File has some content, but no line ending at the end. file.seek(SeekFrom::Start(pos + 1)).await?; file.write_all(b"\n").await?; file.flush().await?; file.shutdown().await?; Ok((1, format!("Fixing {}\n", filename.display()).into_bytes())) } (Some(pos), Some(line_ending)) => { // File has some content and at least one line ending. let new_size = pos + 1 + line_ending.len() as u64; if file_size == new_size { // File already has the correct line ending. return Ok((0, Vec::new())); } file.set_len(new_size).await?; Ok((1, format!("Fixing {}\n", filename.display()).into_bytes())) } } } fn determine_line_ending(first: u8, second: u8) -> Option<&'static str> { if first == b'\r' && second == b'\n' { Some("\r\n") } else if first == b'\n' { Some("\n") } else if first == b'\r' { Some("\r") } else { None } } /// Searches for the last non-line-ending character in the file. /// Returns the position of the last non-line-ending character and the line ending type. async fn find_last_non_ending(reader: &mut T) -> Result<(Option, Option<&str>)> where T: AsyncRead + AsyncSeek + Unpin, { const MAX_SCAN_SIZE: usize = 4 * 1024; // 4KB let data_len = reader.seek(SeekFrom::End(0)).await?; if data_len == 0 { return Ok((None, None)); } let mut read_len = 0; let mut next_char = 0; let mut buf = vec![0u8; MAX_SCAN_SIZE]; let mut line_ending = None; while read_len < data_len { let block_size = MAX_SCAN_SIZE.min(usize::try_from(data_len - read_len)?); // SAFETY: block_size is guaranteed to be less than or equal to MAX_SCAN_SIZE reader .seek(SeekFrom::Current(-i64::try_from(block_size).unwrap())) .await?; reader.read_exact(&mut buf[..block_size]).await?; read_len += block_size as u64; let mut pos = block_size; while pos > 0 { pos -= 1; if matches!(buf[pos], b'\n' | b'\r') { line_ending = if pos + 1 == block_size { determine_line_ending(buf[pos], next_char) } else { determine_line_ending(buf[pos], buf[pos + 1]) }; } else { return Ok((Some(data_len - read_len + pos as u64), line_ending)); } } next_char = buf[0]; } Ok((None, line_ending)) } #[cfg(test)] mod tests { use super::*; use anyhow::Ok; use bstr::ByteSlice; use std::path::{Path, PathBuf}; use tempfile::tempdir; async fn create_test_file( dir: &tempfile::TempDir, name: &str, content: &[u8], ) -> Result { let file_path = dir.path().join(name); fs_err::tokio::write(&file_path, content).await?; Ok(file_path) } #[tokio::test] async fn test_no_line_ending_1() -> Result<()> { let dir = tempdir()?; // For files without line endings, just append "\n" at the end, no matter // what line endings are previously used. // This is consistent with the behavior of `pre-commit`. let content = b"line1\nline2\nline3"; let file_path = create_test_file(&dir, "unix_no_eof.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 1, "Should fix the file"); assert!(output.as_bytes().contains_str("Fixing")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b"line1\nline2\nline3\n"); let content = b"line1\r\nline2\nline3\r\nline4"; let file_path = create_test_file(&dir, "mixed.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 1, "Should fix the file"); assert!(output.as_bytes().contains_str("Fixing")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b"line1\r\nline2\nline3\r\nline4\n"); let content = b"line1\r\nline2\r\nline3"; let file_path = create_test_file(&dir, "windows_no_eof.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 1, "Should fix the file"); assert!(output.as_bytes().contains_str("Fixing")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b"line1\r\nline2\r\nline3\n"); Ok(()) } #[tokio::test] async fn test_already_has_correct_windows_ending() -> Result<()> { let dir = tempdir()?; let content = b"line1\r\nline2\r\nline3\r\n"; let file_path = create_test_file(&dir, "windows_with_eof.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 0, "Should not change the file"); assert!(output.is_empty()); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, content); Ok(()) } #[tokio::test] async fn test_already_has_correct_unix_ending() -> Result<()> { let dir = tempdir()?; let content = b"line1\nline2\nline3\n"; let file_path = create_test_file(&dir, "unix_with_eof.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 0, "Should not change the file"); assert!(output.is_empty()); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, content); Ok(()) } #[tokio::test] async fn test_empty_file() -> Result<()> { let dir = tempdir()?; let content = b""; let file_path = create_test_file(&dir, "empty.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 0, "Should not change empty file"); assert!(output.is_empty()); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b""); Ok(()) } #[tokio::test] async fn test_excess_newlines_removal() -> Result<()> { let dir = tempdir()?; let content = b"line1\nline2\n\n\n\n"; let file_path = create_test_file(&dir, "excess_newlines.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 1, "Should fix the file"); assert!(output.as_bytes().contains_str("Fixing")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b"line1\nline2\n"); Ok(()) } #[tokio::test] async fn test_excess_crlf_removal() -> Result<()> { let dir = tempdir()?; let content = b"line1\r\nline2\r\n\r\n\r\n"; let file_path = create_test_file(&dir, "excess_crlf.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 1, "Should fix the file"); assert!(output.as_bytes().contains_str("Fixing")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b"line1\r\nline2\r\n"); Ok(()) } #[tokio::test] async fn test_all_newlines_make_empty() -> Result<()> { let dir = tempdir()?; let content = b"\n\n\n\n"; let file_path = create_test_file(&dir, "only_newlines.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path).await?; assert_eq!(code, 1, "Should fix the file"); assert!(output.as_bytes().contains_str("Fixing")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b""); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/fix_trailing_whitespace.rs ================================================ use std::ops::Deref; use std::path::Path; use std::str::FromStr; use anyhow::Result; use bstr::ByteSlice; use clap::Parser; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; const MARKDOWN_LINE_BREAK: &[u8] = b" "; #[derive(Clone)] struct Chars(Vec); impl FromStr for Chars { type Err = String; fn from_str(s: &str) -> Result { Ok(Chars(s.chars().collect())) } } impl Deref for Chars { type Target = Vec; fn deref(&self) -> &Self::Target { &self.0 } } #[derive(Parser)] #[command(disable_help_subcommand = true)] #[command(disable_version_flag = true)] #[command(disable_help_flag = true)] struct Args { #[arg(long)] markdown_linebreak_ext: Vec, // `clap` cannot parse `--chars= \t` into vec correctly. // so, we use Chars to achieve it. #[arg(long)] chars: Option, } impl Args { fn markdown_exts(&self) -> Result> { let markdown_exts = self .markdown_linebreak_ext .iter() .flat_map(|ext| ext.split(',')) .map(|ext| format!(".{}", ext.trim_start_matches('.')).to_ascii_lowercase()) .collect::>(); // Validate extensions don't contain path separators for ext in &markdown_exts { if ext[1..] .chars() .any(|c| matches!(c, '.' | '/' | '\\' | ':')) { anyhow::bail!("bad `--markdown-linebreak-ext` argument '{ext}' (has . / \\ :)"); } } Ok(markdown_exts) } fn force_markdown(&self) -> bool { self.markdown_linebreak_ext.iter().any(|ext| ext == "*") } } pub(crate) async fn fix_trailing_whitespace( hook: &Hook, filenames: &[&Path], ) -> Result<(i32, Vec)> { let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; let force_markdown = args.force_markdown(); let markdown_exts = args.markdown_exts()?; let chars = if let Some(chars) = args.chars { chars.deref().to_owned() } else { Vec::new() }; run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { fix_file( hook.project().relative_path(), filename, &chars, force_markdown, &markdown_exts, ) }) .await } async fn fix_file( file_base: &Path, filename: &Path, chars: &[char], force_markdown: bool, markdown_exts: &[String], ) -> Result<(i32, Vec)> { let is_markdown = force_markdown || { Path::new(filename) .extension() .and_then(|e| e.to_str()) .map(|e| format!(".{}", e.to_ascii_lowercase())) .is_some_and(|e| markdown_exts.contains(&e)) }; let file_path = file_base.join(filename); let content = fs_err::tokio::read(&file_path).await?; let mut output = Vec::with_capacity(content.len()); let mut modified = false; for line in content.split_inclusive(|&b| b == b'\n') { let line_ending = detect_line_ending(line); let mut trimmed = &line[..line.len() - line_ending.len()]; let markdown_end = needs_markdown_break(is_markdown, trimmed); if markdown_end { trimmed = &trimmed[..trimmed.len() - MARKDOWN_LINE_BREAK.len()]; } if chars.is_empty() { trimmed = trimmed.trim_ascii_end(); } else { trimmed = trimmed.trim_end_with(|c| chars.contains(&c)); } output.extend_from_slice(trimmed); if markdown_end { output.extend_from_slice(MARKDOWN_LINE_BREAK); modified |= trimmed.len() + MARKDOWN_LINE_BREAK.len() + line_ending.len() != line.len(); } else { modified |= trimmed.len() + line_ending.len() != line.len(); } output.extend_from_slice(line_ending); } if modified { fs_err::tokio::write(&file_path, &output).await?; Ok((1, format!("Fixing {}\n", filename.display()).into_bytes())) } else { Ok((0, Vec::new())) } } fn detect_line_ending(line: &[u8]) -> &[u8] { if line.ends_with(b"\r\n") { b"\r\n" } else if line.ends_with(b"\n") { b"\n" } else if line.ends_with(b"\r") { b"\r" } else { b"" } } fn needs_markdown_break(is_markdown: bool, trimmed: &[u8]) -> bool { is_markdown && !trimmed.chars().all(|b| b.is_ascii_whitespace()) && trimmed.ends_with(MARKDOWN_LINE_BREAK) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; use tempfile::TempDir; async fn create_test_file(dir: &TempDir, name: &str, content: &[u8]) -> Result { let file_path = dir.path().join(name); fs_err::tokio::write(&file_path, content).await?; Ok(file_path) } #[tokio::test] async fn test_trim_non_markdown_trims_spaces() -> Result<()> { let dir = TempDir::new()?; let file_path = create_test_file(&dir, "file.txt", b"keep this line\ntrim trailing \n").await?; let chars = vec![' ', '\t']; let md_exts = vec![".md".to_string()]; let (code, msg) = fix_file(Path::new(""), &file_path, &chars, false, &md_exts).await?; // modified assert_eq!(code, 1); let msg_str = String::from_utf8_lossy(&msg); assert!(msg_str.contains("file.txt")); // file content updated: trailing spaces removed let content = fs_err::tokio::read_to_string(&file_path).await?; let expected = "keep this line\ntrim trailing\n"; assert_eq!(content, expected); Ok(()) } #[tokio::test] async fn test_markdown_preserve_two_spaces_and_reduce_extra() -> Result<()> { let dir = TempDir::new()?; let file_path = create_test_file( &dir, "doc.md", b"line_keep_two \nline_reduce_three \nother_line\n", ) .await?; let chars = vec![' ', '\t']; let md_exts = vec![".md".to_string()]; let (code, _msg) = fix_file(Path::new(""), &file_path, &chars, false, &md_exts).await?; // second line changed 3 -> 2 spaces, so modified assert_eq!(code, 1); let content = fs_err::tokio::read_to_string(&file_path).await?; let expected = "line_keep_two \nline_reduce_three \nother_line\n"; assert_eq!(content, expected); Ok(()) } #[tokio::test] async fn test_force_markdown_obeys_markdown_rules() -> Result<()> { let dir = TempDir::new()?; // .txt normally not markdown, but we force markdown=true let file_path = create_test_file( &dir, "forced.txt", b"keep_two_spaces \nthree_spaces_line \n", ) .await?; let chars = vec![' ', '\t']; let md_exts: Vec = vec![]; // irrelevant because force_markdown = true let (code, _msg) = fix_file(Path::new(""), &file_path, &chars, true, &md_exts).await?; // modified because one line had 3 spaces -> reduced to 2 assert_eq!(code, 1); let content = fs_err::tokio::read_to_string(&file_path).await?; let expected = "keep_two_spaces \nthree_spaces_line \n"; assert_eq!(content, expected); Ok(()) } #[tokio::test] async fn test_no_changes_returns_zero_and_no_write() -> Result<()> { let dir = TempDir::new()?; let path = create_test_file(&dir, "ok.txt", b"already_trimmed\nline_two\n").await?; let chars = vec![' ', '\t']; let md_exts = vec![".md".to_string()]; // file already trimmed -> no changes let (code, msg) = fix_file(Path::new(""), &path, &chars, false, &md_exts).await?; assert_eq!(code, 0); assert!(msg.is_empty()); let content = fs_err::tokio::read_to_string(&path).await?; assert_eq!(content, "already_trimmed\nline_two\n"); Ok(()) } #[tokio::test] async fn test_empty_file_no_change() -> Result<()> { let dir = TempDir::new()?; let path = create_test_file(&dir, "empty.txt", b"").await?; let chars = vec![' ', '\t']; let md_exts = vec![]; let (code, msg) = fix_file(Path::new(""), &path, &chars, false, &md_exts).await?; assert_eq!(code, 0); assert!(msg.is_empty()); let content = fs_err::tokio::read_to_string(&path).await?; assert_eq!(content, ""); Ok(()) } #[tokio::test] async fn test_only_whitespace_lines_are_handled_not_markdown_end() -> Result<()> { let dir = TempDir::new()?; // lines are only whitespace; markdown_end_flag should NOT trigger let path = create_test_file(&dir, "ws.txt", b" \n\t\n \n").await?; let chars = vec![' ', '\t']; let md_exts = vec![".md".to_string()]; let (code, _msg) = fix_file(Path::new(""), &path, &chars, false, &md_exts).await?; // trimming whitespace-only lines will change them to empty lines -> modified true assert_eq!(code, 1); let content = fs_err::tokio::read_to_string(&path).await?; // Expect empty lines (newline preserved per implementation) assert_eq!(content, "\n\n\n"); Ok(()) } #[tokio::test] async fn test_chars_empty_uses_trim_ascii_end() -> Result<()> { let dir = TempDir::new()?; // trailing ascii spaces should be removed by trim_ascii_end when chars is empty let path = create_test_file(&dir, "ascii.txt", b"foo \nbar \t\n").await?; let chars = vec![]; // will hit trim_ascii_end() let md_exts = vec![]; let (code, _msg) = fix_file(Path::new(""), &path, &chars, false, &md_exts).await?; assert_eq!(code, 1); let content = fs_err::tokio::read_to_string(&path).await?; let expected = "foo\nbar\n"; assert_eq!(content, expected); Ok(()) } #[tokio::test] async fn test_crlf_lines_handling() -> Result<()> { let dir = TempDir::new()?; // CRLF content (use \r\n). Ensure trimming still works. let path = create_test_file(&dir, "crlf.txt", b"one \r\ntwo \r\n").await?; let chars = vec![' ', '\t']; let md_exts = vec![".txt".to_string()]; // treat as markdown for this test let (code, _msg) = fix_file(Path::new(""), &path, &chars, false, &md_exts).await?; assert_eq!(code, 1); // read file and check logical lines presence (line endings may be normalized by lines()) let content = fs_err::tokio::read_to_string(&path).await?; assert!(content.contains("one")); assert!(content.contains("two")); Ok(()) } #[tokio::test] async fn test_no_newline_at_eof() -> Result<()> { let dir = TempDir::new()?; // no trailing newline on last line let path = create_test_file(&dir, "no_nl.txt", b"lastline ").await?; let chars = vec![' ', '\t']; let md_exts = vec![]; let (code, _msg) = fix_file(Path::new(""), &path, &chars, false, &md_exts).await?; assert_eq!(code, 1); let content = fs_err::tokio::read_to_string(&path).await?; // Expect trailing spaces removed assert_eq!(content, "lastline"); Ok(()) } #[tokio::test] async fn test_unicode_trim_char() -> Result<()> { let dir = TempDir::new()?; // use a unicode char '。' and ideographic space ' ' to trim let path = create_test_file(&dir, "uni.txt", "hello。 \n".as_bytes()).await?; let chars = vec!['。', ' ']; let md_exts = vec![]; let (code, _msg) = fix_file(Path::new(""), &path, &chars, false, &md_exts).await?; assert_eq!(code, 1); let content = fs_err::tokio::read_to_string(&path).await?; assert_eq!(content, "hello\n"); Ok(()) } #[tokio::test] async fn test_extension_case_insensitive_matching() -> Result<()> { let dir = TempDir::new()?; // capital extension .MD should match .md in markdown_exts let path = create_test_file(&dir, "Doc.MD", b"hi \n").await?; let chars = vec![' ', '\t']; let md_exts = vec![".md".to_string()]; let (code, _msg) = fix_file(Path::new(""), &path, &chars, false, &md_exts).await?; assert_eq!(code, 1); let content = fs_err::tokio::read_to_string(&path).await?; // markdown rules: trailing >2 -> reduce to two spaces assert!(content.contains("hi")); Ok(()) } #[tokio::test] async fn test_mixed_lines_modified_flag_true_if_any_changed() -> Result<()> { let dir = TempDir::new()?; let path = create_test_file(&dir, "mix.txt", b"ok\nneedtrim \nalso_ok\n").await?; let chars = vec![' ', '\t']; let md_exts = vec![]; let (code, _msg) = fix_file(Path::new(""), &path, &chars, false, &md_exts).await?; assert_eq!(code, 1); let content = fs_err::tokio::read_to_string(&path).await?; let expected = "ok\nneedtrim\nalso_ok\n"; assert_eq!(content, expected); Ok(()) } #[tokio::test] async fn test_no_change_no_newline_at_eof() -> Result<()> { let dir = TempDir::new()?; let path = create_test_file(&dir, "ok_no_nl.txt", b"foo\nbar").await?; let chars = vec![' ', '\t']; let md_exts = vec![]; let (code, msg) = fix_file(Path::new(""), &path, &chars, false, &md_exts).await?; assert_eq!(code, 0); assert!(msg.is_empty()); let content = fs_err::tokio::read_to_string(&path).await?; assert_eq!(content, "foo\nbar"); Ok(()) } #[tokio::test] async fn test_markdown_wildcard_ext_and_eof_whitespace_removed() -> Result<()> { let dir = TempDir::new()?; let content = b"foo \nbar \nbaz \n\t\n\n "; let path = create_test_file(&dir, "wild.md", content).await?; let chars = vec![' ', '\t']; let md_exts = vec!["*".to_string()]; let (code, _msg) = fix_file(Path::new(""), &path, &chars, true, &md_exts).await?; assert_eq!(code, 1); let expected = "foo \nbar\nbaz \n\n\n"; let new_content = fs_err::tokio::read_to_string(&path).await?; assert_eq!(new_content, expected); Ok(()) } #[tokio::test] async fn test_markdown_with_custom_charset() -> Result<()> { let dir = TempDir::new()?; let path = create_test_file(&dir, "custom_charset.md", b"\ta \t \n").await?; let chars = vec![' ']; let md_exts = vec!["*".to_string()]; let (code, _msg) = fix_file(Path::new(""), &path, &chars, true, &md_exts).await?; assert_eq!(code, 1); let expected = "\ta \t \n"; let content = fs_err::tokio::read_to_string(&path).await?; assert_eq!(content, expected); Ok(()) } #[tokio::test] async fn test_eol_trim() -> Result<()> { let dir = TempDir::new()?; let path = create_test_file(&dir, "trim_eol.md", b"a\nb\r\r\r\n").await?; let chars = vec!['x']; let md_exts = vec![]; let (code, _msg) = fix_file(Path::new(""), &path, &chars, true, &md_exts).await?; assert_eq!(code, 0); let expected = "a\nb\r\r\r\n"; let content = fs_err::tokio::read_to_string(&path).await?; assert_eq!(content, expected); Ok(()) } #[tokio::test] async fn test_markdown_trim() -> Result<()> { let dir = TempDir::new()?; let path = create_test_file(&dir, "trim_markdown.md", b"axxx \n").await?; let chars = vec!['x']; let md_exts = vec!["md".to_string()]; let (code, _msg) = fix_file(Path::new(""), &path, &chars, true, &md_exts).await?; assert_eq!(code, 1); let expected = "a \n"; let content = fs_err::tokio::read_to_string(&path).await?; assert_eq!(content, expected); Ok(()) } #[tokio::test] async fn test_invalid_utf8_file_is_handled() -> Result<()> { let dir = TempDir::new()?; // This is valid ASCII followed by invalid UTF-8 (0xFF) let content = b"valid line\ninvalid utf8 here:\xff\n"; let path = create_test_file(&dir, "invalid_utf8.txt", content).await?; let chars = vec![' ', '\t']; let md_exts: Vec = vec![]; let (code, _msg) = fix_file(Path::new(""), &path, &chars, false, &md_exts).await?; assert_eq!(code, 0); let new_content = fs_err::tokio::read(&path).await?; // The invalid byte should still be present, but trailing whitespace should be trimmed assert!(new_content.starts_with(b"valid line\ninvalid utf8 here:\xff\n")); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/mixed_line_ending.rs ================================================ use std::path::Path; use anyhow::Result; use bstr::ByteSlice; use clap::{Parser, ValueEnum}; use rustc_hash::FxHashMap; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; use crate::run::CONCURRENCY; const CRLF: &[u8] = b"\r\n"; const LF: &[u8] = b"\n"; const CR: &[u8] = b"\r"; const ALL_ENDINGS: [&[u8]; 3] = [CR, CRLF, LF]; #[derive(Parser)] #[command(disable_help_subcommand = true)] #[command(disable_version_flag = true)] #[command(disable_help_flag = true)] struct Args { /// Fix mixed line endings by converting to the most common line ending /// or a specified line ending. #[clap(long, short, value_enum, default_value_t = FixMode::Auto)] fix: FixMode, } #[derive(Copy, Clone, Debug, Default, ValueEnum)] #[allow(clippy::upper_case_acronyms)] enum FixMode { /// Automatically determine the most common line ending and use it #[default] Auto, /// Don't fix, just report if mixed line endings are found No, /// Convert all line endings to LF LF, /// Convert all line endings to CRLF CRLF, /// Convert all line endings to CR CR, } pub(crate) async fn mixed_line_ending(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { fix_file(hook.project().relative_path(), filename, args.fix) }) .await } // Process a single file for mixed line endings async fn fix_file(file_base: &Path, filename: &Path, fix_mode: FixMode) -> Result<(i32, Vec)> { let file_path = file_base.join(filename); let contents = fs_err::tokio::read(&file_path).await?; // Skip empty files or binary files if contents.is_empty() || contents.find_byte(0).is_some() { return Ok((0, Vec::new())); } let counts = count_line_endings(&contents); let has_mixed_endings = counts.len() > 1; match fix_mode { FixMode::No => { if has_mixed_endings { Ok(( 1, format!("{}: mixed line endings\n", filename.display()).into_bytes(), )) } else { Ok((0, Vec::new())) } } FixMode::Auto => { if !has_mixed_endings { return Ok((0, Vec::new())); } let target_ending = find_most_common_ending(&counts); apply_line_ending(&file_path, &contents, target_ending).await?; Ok((1, format!("Fixing {}\n", filename.display()).into_bytes())) } _ => { let target_ending = match fix_mode { FixMode::LF => LF, FixMode::CRLF => CRLF, FixMode::CR => CR, _ => unreachable!(), }; let needs_fixing = counts.keys().any(|&ending| ending != target_ending); if needs_fixing { apply_line_ending(&file_path, &contents, target_ending).await?; Ok((1, format!("Fixing {}\n", filename.display()).into_bytes())) } else { Ok((0, Vec::new())) } } } } fn count_line_endings(contents: &[u8]) -> FxHashMap<&'static [u8], usize> { let mut counts = FxHashMap::default(); for line in split_lines_with_endings(contents) { let ending = if line.ends_with(CRLF) { CRLF } else if line.ends_with(CR) { CR } else if line.ends_with(LF) { LF } else { continue; // Line without ending }; *counts.entry(ending).or_insert(0) += 1; } counts } fn find_most_common_ending(counts: &FxHashMap<&'static [u8], usize>) -> &'static [u8] { ALL_ENDINGS .iter() .max_by_key(|&&ending| counts.get(ending).unwrap_or(&0)) .copied() .unwrap_or(LF) } async fn apply_line_ending(filename: &Path, contents: &[u8], ending: &[u8]) -> Result<()> { let lines = split_lines_with_endings(contents); let mut new_contents = Vec::with_capacity(contents.len()); for line in lines { let line_without_ending = strip_line_ending(line); new_contents.extend_from_slice(line_without_ending); new_contents.extend_from_slice(ending); } fs_err::tokio::write(filename, &new_contents).await?; Ok(()) } fn strip_line_ending(line: &[u8]) -> &[u8] { if line.ends_with(CRLF) { &line[..line.len() - 2] } else if line.ends_with(LF) || line.ends_with(CR) { &line[..line.len() - 1] } else { line } } fn split_lines_with_endings(contents: &[u8]) -> Vec<&[u8]> { if contents.is_empty() { return Vec::new(); } let mut lines = Vec::new(); let mut last_end = 0; let mut i = 0; while i < contents.len() { match contents[i] { b'\n' => { lines.push(&contents[last_end..=i]); last_end = i + 1; i += 1; } b'\r' => { if i + 1 < contents.len() && contents[i + 1] == b'\n' { // CRLF lines.push(&contents[last_end..=i + 1]); last_end = i + 2; i += 2; } else { // CR lines.push(&contents[last_end..=i]); last_end = i + 1; i += 1; } } _ => i += 1, } } // Add remaining content if any if last_end < contents.len() { lines.push(&contents[last_end..]); } lines } #[cfg(test)] mod tests { use super::*; use bstr::ByteSlice; use std::path::{Path, PathBuf}; use tempfile::tempdir; async fn create_test_file( dir: &tempfile::TempDir, name: &str, content: &[u8], ) -> Result { let file_path = dir.path().join(name); fs_err::tokio::write(&file_path, content).await?; Ok(file_path) } #[tokio::test] async fn test_auto_fix_crlf_wins() -> Result<()> { let dir = tempdir()?; let content = b"line1\nline2\r\nline3\r\n"; // 1 LF, 2 CRLF let file_path = create_test_file(&dir, "mixed_crlf.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path, FixMode::Auto).await?; assert_eq!(code, 1); assert!(output.as_bytes().contains_str("Fixing")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b"line1\r\nline2\r\nline3\r\n"); Ok(()) } #[tokio::test] async fn test_auto_fix_lf_wins() -> Result<()> { let dir = tempdir()?; let content = b"line1\nline2\nline3\r\n"; // 2 LF, 1 CRLF let file_path = create_test_file(&dir, "mixed_lf.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path, FixMode::Auto).await?; assert_eq!(code, 1); assert!(output.as_bytes().contains_str("Fixing")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b"line1\nline2\nline3\n"); Ok(()) } #[tokio::test] async fn test_auto_fix_tie_prefers_lf() -> Result<()> { let dir = tempdir()?; let content = b"line1\nline2\r\n"; // 1 LF, 1 CRLF let file_path = create_test_file(&dir, "mixed_tie.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path, FixMode::Auto).await?; assert_eq!(code, 1); assert!(output.as_bytes().contains_str("Fixing")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b"line1\nline2\n"); Ok(()) } #[tokio::test] async fn test_fix_no() -> Result<()> { let dir = tempdir()?; let content = b"line1\nline2\r\n"; let file_path = create_test_file(&dir, "mixed_no.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path, FixMode::No).await?; assert_eq!(code, 1); assert!(output.as_bytes().contains_str("mixed line endings")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, content); // File should not be changed Ok(()) } #[tokio::test] async fn test_no_line_endings() -> Result<()> { let dir = tempdir()?; let content = b"some content"; let file_path = create_test_file(&dir, "no_endings.txt", content).await?; let (code, output) = fix_file(Path::new(""), &file_path, FixMode::Auto).await?; assert_eq!(code, 0); assert!(output.is_empty()); Ok(()) } #[tokio::test] async fn test_fix_with_cr_endings() -> Result<()> { let dir = tempdir()?; // A file with a mix of all three line ending types let content = b"line1\rline2\nline3\r\n"; let file_path = create_test_file(&dir, "all_mixed.txt", content).await?; // Test auto fix (should prefer LF as it's a 3-way tie) let (code, output) = fix_file(Path::new(""), &file_path, FixMode::Auto).await?; assert_eq!(code, 1); assert!(output.as_bytes().contains_str("Fixing")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b"line1\nline2\nline3\n"); // Restore content and test fix to CRLF fs_err::tokio::write(&file_path, content).await?; let (code, output) = fix_file(Path::new(""), &file_path, FixMode::CRLF).await?; assert_eq!(code, 1); assert!(output.as_bytes().contains_str("Fixing")); let new_content = fs_err::tokio::read(&file_path).await?; assert_eq!(new_content, b"line1\r\nline2\r\nline3\r\n"); Ok(()) } } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/mod.rs ================================================ use std::path::Path; use anyhow::Result; use tracing::debug; use crate::hook::Hook; mod check_added_large_files; mod check_case_conflict; mod check_executables_have_shebangs; pub(crate) mod check_json; mod check_merge_conflict; mod check_symlinks; mod check_toml; mod check_xml; mod check_yaml; mod detect_private_key; mod fix_byte_order_marker; mod fix_end_of_file; mod fix_trailing_whitespace; mod mixed_line_ending; mod no_commit_to_branch; pub(crate) use check_added_large_files::check_added_large_files; pub(crate) use check_case_conflict::check_case_conflict; pub(crate) use check_executables_have_shebangs::check_executables_have_shebangs; pub(crate) use check_json::check_json; pub(crate) use check_merge_conflict::check_merge_conflict; pub(crate) use check_symlinks::check_symlinks; pub(crate) use check_toml::check_toml; pub(crate) use check_xml::check_xml; pub(crate) use check_yaml::check_yaml; pub(crate) use detect_private_key::detect_private_key; pub(crate) use fix_byte_order_marker::fix_byte_order_marker; pub(crate) use fix_end_of_file::fix_end_of_file; pub(crate) use fix_trailing_whitespace::fix_trailing_whitespace; pub(crate) use mixed_line_ending::mixed_line_ending; pub(crate) use no_commit_to_branch::no_commit_to_branch; /// Hooks from `https://github.com/pre-commit/pre-commit-hooks`. #[derive(strum::EnumString)] #[strum(serialize_all = "kebab-case")] pub(crate) enum PreCommitHooks { CheckAddedLargeFiles, CheckCaseConflict, CheckExecutablesHaveShebangs, EndOfFileFixer, FixByteOrderMarker, CheckJson, CheckSymlinks, CheckMergeConflict, CheckToml, CheckXml, CheckYaml, MixedLineEnding, DetectPrivateKey, NoCommitToBranch, TrailingWhitespace, } impl PreCommitHooks { pub(crate) fn check_supported(&self, hook: &Hook) -> bool { match self { // `check-yaml` does not support `--unsafe` flag yet. Self::CheckYaml => !hook.args.iter().any(|s| s.starts_with("--unsafe")), _ => true, } } pub(crate) async fn run(self, hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { debug!("Running hook `{}` in fast path", hook.id); match self { Self::CheckAddedLargeFiles => check_added_large_files(hook, filenames).await, Self::CheckCaseConflict => check_case_conflict(hook, filenames).await, Self::CheckExecutablesHaveShebangs => { check_executables_have_shebangs(hook, filenames).await } Self::EndOfFileFixer => fix_end_of_file(hook, filenames).await, Self::FixByteOrderMarker => fix_byte_order_marker(hook, filenames).await, Self::CheckJson => check_json(hook, filenames).await, Self::CheckSymlinks => check_symlinks(hook, filenames).await, Self::CheckMergeConflict => check_merge_conflict(hook, filenames).await, Self::CheckToml => check_toml(hook, filenames).await, Self::CheckYaml => check_yaml(hook, filenames).await, Self::CheckXml => check_xml(hook, filenames).await, Self::MixedLineEnding => mixed_line_ending(hook, filenames).await, Self::DetectPrivateKey => detect_private_key(hook, filenames).await, Self::NoCommitToBranch => no_commit_to_branch(hook).await, Self::TrailingWhitespace => fix_trailing_whitespace(hook, filenames).await, } } } // TODO: compare rev pub(crate) fn is_pre_commit_hooks(url: &str) -> bool { url == "https://github.com/pre-commit/pre-commit-hooks" } ================================================ FILE: crates/prek/src/hooks/pre_commit_hooks/no_commit_to_branch.rs ================================================ use clap::Parser; use fancy_regex::Regex; use crate::git::git_cmd; use crate::hook::Hook; use anyhow::{Context, Result}; #[derive(Parser)] #[command(disable_help_subcommand = true)] #[command(disable_version_flag = true)] #[command(disable_help_flag = true)] struct Args { #[arg(short, long = "branch", default_values = &["main", "master"])] branches: Vec, #[arg(short, long = "pattern")] patterns: Vec, } impl Args { fn check_protected(&self, branch: &str) -> Result { if self.branches.iter().any(|b| b == branch) { return Ok(true); } if self.patterns.is_empty() { return Ok(false); } let patterns = self .patterns .iter() .map(|p| Regex::new(p)) .collect::, _>>() .context("Failed to compile regex patterns")?; Ok(patterns .iter() .any(|pattern| pattern.is_match(branch).unwrap_or(false))) } } pub(crate) async fn no_commit_to_branch(hook: &Hook) -> Result<(i32, Vec)> { let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; let output = git_cmd("get current branch")? .arg("symbolic-ref") .arg("HEAD") .check(false) .output() .await?; if !output.status.success() { return Ok((0, Vec::new())); } let ref_name = String::from_utf8_lossy(&output.stdout); // stdout must start with "refs/heads/" let branch = ref_name.trim().trim_start_matches("refs/heads/"); if args.check_protected(branch)? { let err_msg = format!("You are not allowed to commit to branch '{branch}'\n"); Ok((1, err_msg.into_bytes())) } else { Ok((0, Vec::new())) } } ================================================ FILE: crates/prek/src/http.rs ================================================ use std::path::{Path, PathBuf}; use std::sync::LazyLock; use anyhow::{Context, Result}; use futures::TryStreamExt; use prek_consts::env_vars::EnvVars; use reqwest::Certificate; use tokio_util::compat::FuturesAsyncReadCompatExt; use tracing::debug; use crate::archive::ArchiveExtension; use crate::fs::Simplified; use crate::store::Store; use crate::{archive, warn_user}; pub(crate) async fn download_and_extract( url: &str, filename: &str, store: &Store, callback: impl AsyncFn(&Path) -> Result<()>, ) -> Result<()> { download_and_extract_with(url, filename, store, |req| req, callback).await } /// Like [`download_and_extract`], but accepts a `customize_request` closure /// that can modify the [`reqwest::RequestBuilder`] before it is sent (e.g. to /// add authentication headers). pub(crate) async fn download_and_extract_with( url: &str, filename: &str, store: &Store, customize_request: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder, callback: impl AsyncFn(&Path) -> Result<()>, ) -> Result<()> { let response = customize_request(REQWEST_CLIENT.get(url)) .send() .await .with_context(|| format!("Failed to download file from {url}"))?; if !response.status().is_success() { anyhow::bail!( "Failed to download file from {}: {}", url, response.status() ); } let tarball = response .bytes_stream() .map_err(std::io::Error::other) .into_async_read() .compat(); let scratch_dir = store.scratch_path(); let temp_dir = tempfile::tempdir_in(&scratch_dir)?; debug!(url = %url, temp_dir = ?temp_dir.path(), "Downloading"); let ext = ArchiveExtension::from_path(filename)?; archive::unpack(tarball, ext, temp_dir.path()).await?; let extracted = match archive::strip_component(temp_dir.path()) { Ok(top_level) => top_level, Err(archive::Error::NonSingularArchive(_)) => temp_dir.path().to_path_buf(), Err(err) => return Err(err.into()), }; callback(&extracted).await?; drop(temp_dir); Ok(()) } pub(crate) static REQWEST_CLIENT: LazyLock = LazyLock::new(|| { let native_tls = EnvVars::var_as_bool(EnvVars::PREK_NATIVE_TLS).unwrap_or(false); let cert_file = EnvVars::var_os(EnvVars::SSL_CERT_FILE).map(PathBuf::from); let cert_dirs: Vec<_> = if let Some(cert_dirs) = EnvVars::var_os(EnvVars::SSL_CERT_DIR) { std::env::split_paths(&cert_dirs).collect() } else { vec![] }; let certs = load_certs_from_paths(cert_file.as_deref(), &cert_dirs); create_reqwest_client(native_tls, certs) }); fn load_pem_certs_from_file(path: &Path) -> Result> { let cert_data = fs_err::read(path)?; let certs = Certificate::from_pem_bundle(&cert_data) .or_else(|_| Certificate::from_pem(&cert_data).map(|cert| vec![cert]))?; Ok(certs) } /// Load certificate from certificate directory. fn load_pem_certs_from_dir(dir: &Path) -> Result> { let mut certs = Vec::new(); for entry in fs_err::read_dir(dir)?.flatten() { let path = entry.path(); // `openssl rehash` used to create this directory uses symlinks. So, // make sure we resolve them. let metadata = match fs_err::metadata(&path) { Ok(metadata) => metadata, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // Dangling symlink continue; } Err(_) => { continue; } }; if metadata.is_file() { if let Ok(mut loaded) = load_pem_certs_from_file(&path) { certs.append(&mut loaded); } } } Ok(certs) } fn load_certs_from_paths(file: Option<&Path>, dirs: &[impl AsRef]) -> Vec { let mut certs = Vec::new(); if let Some(file) = file { match load_pem_certs_from_file(file) { Ok(mut loaded) => certs.append(&mut loaded), Err(e) => { warn_user!( "Failed to load certificates from {}: {e}", file.simplified_display().cyan(), ); } } } for dir in dirs { match load_pem_certs_from_dir(dir.as_ref()) { Ok(mut loaded) => certs.append(&mut loaded), Err(e) => { warn_user!( "Failed to load certificates from {}: {}", dir.as_ref().simplified_display().cyan(), e ); } } } certs } fn create_reqwest_client(native_tls: bool, custom_certs: Vec) -> reqwest::Client { let builder = reqwest::ClientBuilder::new().user_agent(format!("prek/{}", crate::version::version())); let builder = if native_tls { debug!("Using native TLS for reqwest client"); // Use rustls with rustls-platform-verifier which uses the platform's native certificate facilities. builder.tls_backend_rustls().tls_certs_merge(custom_certs) } else { let root_certs = webpki_root_certs::TLS_SERVER_ROOT_CERTS .iter() .filter_map(|cert_der| Certificate::from_der(cert_der).ok()); // Merge custom certificates on top of webpki-root-certs builder .tls_backend_rustls() .tls_certs_only(custom_certs) .tls_certs_merge(root_certs) }; builder.build().expect("Failed to build reqwest client") } #[cfg(test)] mod tests { use anyhow::Result; use std::path::Path; const TEST_CERT_PEM: &str = "-----BEGIN CERTIFICATE----- MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5 MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g Um9vdCBDQSAzMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg Q0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZBf8ANm+gBG1bG8lKl ui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjrZt6j QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSr ttvXBp43rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkr BqWTrBqYaGFy+uGh0PsceGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteM YyRIHN8wfdVoOw== -----END CERTIFICATE-----\n"; fn write_cert(path: &Path) { fs_err::write(path, TEST_CERT_PEM).expect("failed to write test certificate"); } #[test] fn test_load_pem_certs_from_file() -> Result<()> { let temp_dir = tempfile::tempdir()?; let cert_path = temp_dir.path().join("cert.pem"); write_cert(&cert_path); let certs = super::load_pem_certs_from_file(&cert_path)?; assert_eq!(certs.len(), 1); Ok(()) } #[test] fn test_load_pem_certs_from_dir_skips_invalid_files() -> Result<()> { let temp_dir = tempfile::tempdir()?; let cert_dir = temp_dir.path().join("certs"); fs_err::create_dir(&cert_dir)?; write_cert(&cert_dir.join("valid.pem")); fs_err::write(cert_dir.join("invalid.pem"), "not a certificate")?; let certs = super::load_pem_certs_from_dir(&cert_dir)?; assert_eq!(certs.len(), 1); Ok(()) } #[test] fn test_load_certs_from_paths_combines_sources() -> Result<()> { let temp_dir = tempfile::tempdir()?; let cert_file = temp_dir.path().join("cert-file.pem"); write_cert(&cert_file); let cert_dir = temp_dir.path().join("cert-dir"); fs_err::create_dir(&cert_dir)?; write_cert(&cert_dir.join("cert-in-dir.pem")); fs_err::write(cert_dir.join("garbage.txt"), "invalid")?; let certs = super::load_certs_from_paths(Some(&cert_file), &[&cert_dir]); assert_eq!(certs.len(), 2); Ok(()) } #[tokio::test] async fn test_native_tls() { let client = super::create_reqwest_client(true, vec![]); let resp = client.get("https://github.com").send().await; assert!(resp.is_ok(), "Failed to send request with native TLS"); } } ================================================ FILE: crates/prek/src/install_source.rs ================================================ use std::ffi::OsStr; use std::path::{Component, Path, PathBuf}; /// Represents how prek was installed on the system. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum InstallSource { Homebrew, Mise, UvTool, Pipx, Asdf, StandaloneInstaller, } impl InstallSource { /// Detect the install source from a given path. fn from_path(path: &Path) -> Option { // Resolve symlinks so e.g. ~/.local/bin/prek -> .../uv/tools/prek/bin/prek is detected. let canonical = path.canonicalize().unwrap_or_else(|_| PathBuf::from(path)); let components: Vec<_> = canonical.components().map(Component::as_os_str).collect(); /// Check whether `components` contains a contiguous subsequence matching `pattern`. fn contains_sequence(components: &[&OsStr], pattern: &[&OsStr]) -> bool { components.windows(pattern.len()).any(|w| w == pattern) } let prek = OsStr::new("prek"); // Homebrew: .../Cellar/prek/... if contains_sequence(&components, &[OsStr::new("Cellar"), prek]) { return Some(Self::Homebrew); } // uv tool: .../uv/tools/prek/... if contains_sequence(&components, &[OsStr::new("uv"), OsStr::new("tools"), prek]) { return Some(Self::UvTool); } // pipx: .../pipx/venvs/prek/... if contains_sequence( &components, &[OsStr::new("pipx"), OsStr::new("venvs"), prek], ) { return Some(Self::Pipx); } // asdf: .../.asdf/installs/prek/... if contains_sequence( &components, &[OsStr::new(".asdf"), OsStr::new("installs"), prek], ) { return Some(Self::Asdf); } // mise: .../mise/installs/prek/... if contains_sequence( &components, &[OsStr::new("mise"), OsStr::new("installs"), prek], ) { return Some(Self::Mise); } None } #[cfg(feature = "self-update")] fn is_standalone_installer() -> anyhow::Result { use axoupdater::AxoUpdater; let mut updater = AxoUpdater::new_for("prek"); let updater = updater.load_receipt()?; Ok(updater.check_receipt_is_for_this_executable()?) } /// Detect the install source from the current executable path. pub(crate) fn detect() -> Option { #[cfg(feature = "self-update")] match Self::is_standalone_installer() { Ok(true) => return Some(Self::StandaloneInstaller), Ok(false) => {} Err(e) => tracing::warn!("Failed to check for standalone installer: {e}"), } Self::from_path(&std::env::current_exe().ok()?) } /// Returns a human-readable description of the install source. pub(crate) fn description(self) -> &'static str { match self { Self::Homebrew => "Homebrew", Self::Mise => "mise", Self::UvTool => "uv tool", Self::Pipx => "pipx", Self::Asdf => "asdf", Self::StandaloneInstaller => "the standalone installer", } } /// Returns the command to update prek for this install source. pub(crate) fn update_instructions(self) -> &'static str { match self { Self::Homebrew => "brew update && brew upgrade prek", Self::Mise => "mise upgrade prek", Self::UvTool => "uv tool upgrade prek", Self::Pipx => "pipx upgrade prek", Self::Asdf => "asdf install prek latest", Self::StandaloneInstaller => "prek self update", } } } #[cfg(test)] mod tests { use super::*; #[test] fn detects_homebrew_cellar_arm() { assert_eq!( InstallSource::from_path(Path::new("/opt/homebrew/Cellar/prek/0.3.1/bin/prek")), Some(InstallSource::Homebrew) ); } #[test] fn detects_homebrew_cellar_intel() { assert_eq!( InstallSource::from_path(Path::new("/usr/local/Cellar/prek/0.3.1/bin/prek")), Some(InstallSource::Homebrew) ); } #[test] fn returns_none_for_unknown_unix_path() { assert_eq!( InstallSource::from_path(Path::new("/usr/local/bin/prek")), None ); } #[test] fn detects_mise_installs() { assert_eq!( InstallSource::from_path(Path::new( "/Users/jo/.local/share/mise/installs/prek/0.3.1/bin/prek" )), Some(InstallSource::Mise) ); } #[test] fn does_not_match_other_mise_tool() { assert_eq!( InstallSource::from_path(Path::new( "/Users/jo/.local/share/mise/installs/ruby/3.4.6/bin/ruby" )), None ); } #[test] fn does_not_match_other_cellar_formula() { assert_eq!( InstallSource::from_path(Path::new("/opt/homebrew/Cellar/other/0.1.0/bin/prek")), None ); } #[test] fn detects_uv_tool_macos() { assert_eq!( InstallSource::from_path(Path::new("/Users/user/.local/share/uv/tools/prek/bin/prek")), Some(InstallSource::UvTool) ); } #[test] fn detects_uv_tool_linux() { assert_eq!( InstallSource::from_path(Path::new("/home/user/.local/share/uv/tools/prek/bin/prek")), Some(InstallSource::UvTool) ); } #[test] fn detects_uv_tool_custom_xdg() { assert_eq!( InstallSource::from_path(Path::new("/opt/data/uv/tools/prek/bin/prek")), Some(InstallSource::UvTool) ); } #[test] fn does_not_match_other_uv_tool() { assert_eq!( InstallSource::from_path(Path::new("/home/user/.local/share/uv/tools/ruff/bin/ruff")), None ); } #[test] fn detects_pipx_macos() { assert_eq!( InstallSource::from_path(Path::new("/Users/user/.local/pipx/venvs/prek/bin/prek")), Some(InstallSource::Pipx) ); } #[test] fn detects_pipx_linux() { assert_eq!( InstallSource::from_path(Path::new( "/home/user/.local/share/pipx/venvs/prek/bin/prek" )), Some(InstallSource::Pipx) ); } #[test] fn does_not_match_other_pipx_package() { assert_eq!( InstallSource::from_path(Path::new("/home/user/.local/pipx/venvs/black/bin/black")), None ); } #[test] fn detects_asdf() { assert_eq!( InstallSource::from_path(Path::new("/home/user/.asdf/installs/prek/0.3.1/bin/prek")), Some(InstallSource::Asdf) ); } #[test] fn does_not_match_other_asdf_plugin() { assert_eq!( InstallSource::from_path(Path::new( "/home/user/.asdf/installs/python/3.12.0/bin/python" )), None ); } #[test] #[cfg(windows)] fn returns_none_for_unknown_windows_path() { assert_eq!( InstallSource::from_path(Path::new(r"C:\Program Files\prek\prek.exe")), None ); } } ================================================ FILE: crates/prek/src/languages/bun/bun.rs ================================================ use std::path::Path; use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::InstalledHook; use crate::hook::{Hook, InstallInfo}; use crate::languages::LanguageImpl; use crate::languages::bun::BunRequest; use crate::languages::bun::installer::{BunInstaller, BunResult, bin_dir, lib_dir}; use crate::languages::version::LanguageRequest; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::{Store, ToolBucket}; #[derive(Debug, Copy, Clone)] pub(crate) struct Bun; impl LanguageImpl for Bun { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result { let progress = reporter.on_install_start(&hook); // 1. Install bun // 1) Find from `$PREK_HOME/tools/bun` // 2) Find from system // 3) Download from remote // 2. Create env // 3. Install dependencies // 1. Install bun let bun_dir = store.tools_path(ToolBucket::Bun); let installer = BunInstaller::new(bun_dir); let (bun_request, allows_download) = match &hook.language_request { LanguageRequest::Any { system_only } => (&BunRequest::Any, !system_only), LanguageRequest::Bun(bun_request) => (bun_request, true), _ => unreachable!(), }; let bun = installer .install(store, bun_request, allows_download) .await .context("Failed to install bun")?; let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; info.with_toolchain(bun.bun().to_path_buf()); // BunVersion implements Deref, so we clone the inner version info.with_language_version((**bun.version()).clone()); // 2. Create env let bin_dir = bin_dir(&info.env_path); let lib_dir = lib_dir(&info.env_path); fs_err::tokio::create_dir_all(&bin_dir).await?; fs_err::tokio::create_dir_all(&lib_dir).await?; // 3. Install dependencies let deps = hook.install_dependencies(); if deps.is_empty() { debug!("No dependencies to install"); } else { // `bun` needs to be in PATH for shebang scripts that use `/usr/bin/env bun` let bun_bin = bun.bun().parent().expect("Bun binary must have parent"); let new_path = prepend_paths(&[&bin_dir, bun_bin]).context("Failed to join PATH")?; // Use BUN_INSTALL to set where global packages are installed // This makes `bun install -g` install to our hook environment Cmd::new(bun.bun(), "bun install") .arg("install") .arg("-g") .args(&*deps) .env(EnvVars::PATH, new_path) .env(EnvVars::BUN_INSTALL, &info.env_path) .check(true) .output() .await?; } info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, info: &InstallInfo) -> Result<()> { let bun = BunResult::from_executable(info.toolchain.clone()) .fill_version() .await .context("Failed to query bun version")?; if **bun.version() != info.language_version { anyhow::bail!( "Bun version mismatch: expected {}, found {}", info.language_version, bun.version() ); } Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let env_dir = hook.env_path().expect("Bun must have env path"); let bun_bin = hook.toolchain_dir().expect("Bun binary must have parent"); let new_path = prepend_paths(&[&bin_dir(env_dir), bun_bin]).context("Failed to join PATH")?; let entry = hook.entry.resolve(Some(&new_path))?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "bun hook") .current_dir(hook.work_dir()) .args(&entry[1..]) .env(EnvVars::PATH, &new_path) .env(EnvVars::BUN_INSTALL, env_dir) .envs(&hook.env) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); // Collect results let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } ================================================ FILE: crates/prek/src/languages/bun/installer.rs ================================================ use std::env::consts::EXE_EXTENSION; use std::fmt::Display; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::LazyLock; use anyhow::{Context, Result}; use itertools::Itertools; use prek_consts::env_vars::EnvVars; use target_lexicon::{Architecture, HOST, OperatingSystem}; use tracing::{debug, trace, warn}; use crate::fs::LockedFile; use crate::git; use crate::http::download_and_extract; use crate::languages::bun::BunRequest; use crate::languages::bun::version::BunVersion; use crate::process::Cmd; use crate::store::Store; #[derive(Debug)] pub(crate) struct BunResult { bun: PathBuf, version: BunVersion, } impl Display for BunResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}@{}", self.bun.display(), self.version)?; Ok(()) } } /// Override the Bun binary name for testing. static BUN_BINARY_NAME: LazyLock = LazyLock::new(|| { if let Ok(name) = EnvVars::var(EnvVars::PREK_INTERNAL__BUN_BINARY_NAME) { name } else { "bun".to_string() } }); impl BunResult { pub(crate) fn from_executable(bun: PathBuf) -> Self { Self { bun, version: BunVersion::default(), } } pub(crate) fn from_dir(dir: &Path) -> Self { let bun = bin_dir(dir).join("bun").with_extension(EXE_EXTENSION); Self::from_executable(bun) } pub(crate) fn with_version(mut self, version: BunVersion) -> Self { self.version = version; self } pub(crate) async fn fill_version(mut self) -> Result { let output = Cmd::new(&self.bun, "bun --version") .arg("--version") .check(true) .output() .await?; let output_str = String::from_utf8_lossy(&output.stdout); let version: BunVersion = output_str .trim() .parse() .context("Failed to parse bun version")?; self.version = version; Ok(self) } pub(crate) fn bun(&self) -> &Path { &self.bun } pub(crate) fn version(&self) -> &BunVersion { &self.version } } pub(crate) struct BunInstaller { root: PathBuf, } impl BunInstaller { pub(crate) fn new(root: PathBuf) -> Self { Self { root } } /// Install a version of Bun. pub(crate) async fn install( &self, store: &Store, request: &BunRequest, allows_download: bool, ) -> Result { fs_err::tokio::create_dir_all(&self.root).await?; let _lock = LockedFile::acquire(self.root.join(".lock"), "bun").await?; if let Ok(bun_result) = self.find_installed(request) { trace!(%bun_result, "Found installed bun"); return Ok(bun_result); } // Find all bun executables in PATH and check their versions if let Some(bun_result) = self.find_system_bun(request).await? { trace!(%bun_result, "Using system bun"); return Ok(bun_result); } if !allows_download { anyhow::bail!("No suitable system Bun version found and downloads are disabled"); } let resolved_version = self.resolve_version(request).await?; trace!(version = %resolved_version, "Downloading bun"); self.download(store, &resolved_version).await } /// Get the installed version of Bun. fn find_installed(&self, req: &BunRequest) -> Result { let mut installed = fs_err::read_dir(&self.root) .ok() .into_iter() .flatten() .filter_map(|entry| match entry { Ok(entry) => Some(entry), Err(err) => { warn!(?err, "Failed to read entry"); None } }) .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir())) .filter_map(|entry| { let dir_name = entry.file_name(); let version = BunVersion::from_str(&dir_name.to_string_lossy()).ok()?; Some((version, entry.path())) }) .sorted_unstable_by(|(a, _), (b, _)| a.cmp(b)) .rev(); installed .find_map(|(v, path)| { if req.matches(&v, Some(&path)) { Some(BunResult::from_dir(&path).with_version(v)) } else { None } }) .context("No installed bun version matches the request") } async fn resolve_version(&self, req: &BunRequest) -> Result { // Latest versions come first, so we can find the latest matching version. let versions = self .list_remote_versions() .await .context("Failed to list remote versions")?; let version = versions .into_iter() .find(|version| req.matches(version, None)) .context("Version not found on remote")?; Ok(version) } /// List all versions of Bun available on GitHub releases. async fn list_remote_versions(&self) -> Result> { let output = git::git_cmd("list bun tags")? .arg("ls-remote") .arg("--tags") .arg("https://github.com/oven-sh/bun") .output() .await? .stdout; let output_str = str::from_utf8(&output)?; let versions: Vec = output_str .lines() .filter_map(|line| { let reference = line.split('\t').nth(1)?; if reference.ends_with("^{}") { return None; } let tag = reference.strip_prefix("refs/tags/")?; // Tags are in format "bun-v1.1.0". let tag = tag.strip_prefix("bun-v")?; BunVersion::from_str(tag).ok() }) .sorted_unstable_by(|a, b| b.cmp(a)) .collect(); Ok(versions) } /// Install a specific version of Bun. async fn download(&self, store: &Store, version: &BunVersion) -> Result { let arch = match HOST.architecture { Architecture::X86_64 => "x64", Architecture::Aarch64(_) => "aarch64", _ => anyhow::bail!("Unsupported architecture"), }; let os = match HOST.operating_system { OperatingSystem::Darwin(_) => "darwin", OperatingSystem::Linux => "linux", OperatingSystem::Windows => "windows", _ => anyhow::bail!("Unsupported OS"), }; let filename = format!("bun-{os}-{arch}.zip"); let url = format!("https://github.com/oven-sh/bun/releases/download/bun-v{version}/{filename}"); let target = self.root.join(version.to_string()); download_and_extract(&url, &filename, store, async |extracted| { if target.exists() { debug!(target = %target.display(), "Removing existing bun"); fs_err::tokio::remove_dir_all(&target).await?; } // The ZIP extracts to bun-{os}-{arch}/bun, we need to move the contents // to {version}/bin/bun let extracted_binary = extracted.join("bun").with_extension(EXE_EXTENSION); let target_bin_dir = bin_dir(&target); fs_err::tokio::create_dir_all(&target_bin_dir).await?; let target_binary = target_bin_dir.join("bun").with_extension(EXE_EXTENSION); debug!(?extracted_binary, target = %target_binary.display(), "Moving bun to target"); fs_err::tokio::rename(&extracted_binary, &target_binary).await?; anyhow::Ok(()) }) .await .context("Failed to download and extract bun")?; Ok(BunResult::from_dir(&target).with_version(version.clone())) } /// Find a suitable system Bun installation that matches the request. async fn find_system_bun(&self, bun_request: &BunRequest) -> Result> { let bun_paths = match which::which_all(&*BUN_BINARY_NAME) { Ok(paths) => paths, Err(e) => { debug!("No bun executables found in PATH: {}", e); return Ok(None); } }; // Check each bun executable for a matching version, stop early if found for bun_path in bun_paths { match BunResult::from_executable(bun_path).fill_version().await { Ok(bun_result) => { // Check if this version matches the request if bun_request.matches(&bun_result.version, Some(&bun_result.bun)) { trace!( %bun_result, "Found a matching system bun" ); return Ok(Some(bun_result)); } trace!( %bun_result, "System bun does not match requested version" ); } Err(e) => { warn!(?e, "Failed to get version for system bun"); } } } debug!(?bun_request, "No system bun matches the requested version"); Ok(None) } } pub(crate) fn bin_dir(prefix: &Path) -> PathBuf { // Bun installs global packages to $BUN_INSTALL/bin/ on all platforms prefix.join("bin") } pub(crate) fn lib_dir(prefix: &Path) -> PathBuf { if cfg!(windows) { prefix.join("node_modules") } else { prefix.join("lib").join("node_modules") } } ================================================ FILE: crates/prek/src/languages/bun/mod.rs ================================================ #[allow(clippy::module_inception)] mod bun; mod installer; mod version; pub(crate) use bun::Bun; pub(crate) use version::BunRequest; ================================================ FILE: crates/prek/src/languages/bun/version.rs ================================================ use std::fmt::Display; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::str::FromStr; use serde::Deserialize; use crate::hook::InstallInfo; use crate::languages::version::{Error, try_into_u64_slice}; #[derive(Debug, Clone, Deserialize)] pub(crate) struct BunVersion(semver::Version); impl Default for BunVersion { fn default() -> Self { BunVersion(semver::Version::new(0, 0, 0)) } } impl Deref for BunVersion { type Target = semver::Version; fn deref(&self) -> &Self::Target { &self.0 } } impl Display for BunVersion { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } impl FromStr for BunVersion { type Err = semver::Error; fn from_str(s: &str) -> Result { let s = s.strip_prefix('v').unwrap_or(s).trim(); semver::Version::parse(s).map(BunVersion) } } /// `language_version` field of bun can be one of the following: /// - `default`: Find system installed bun, or download the latest version. /// - `system`: Find system installed bun, or error if not found. /// - `bun` or `bun@latest`: Same as `default`. /// - `x.y` or `bun@x.y`: Install the latest version with the same major and minor version. /// - `x.y.z` or `bun@x.y.z`: Install the specific version. /// - `^x.y.z`: Install the latest version that satisfies the semver requirement. /// Or any other semver compatible version requirement. /// - `local/path/to/bun`: Use bun executable at the specified path. #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) enum BunRequest { Any, Major(u64), MajorMinor(u64, u64), MajorMinorPatch(u64, u64, u64), Path(PathBuf), Range(semver::VersionReq), } impl FromStr for BunRequest { type Err = Error; fn from_str(s: &str) -> Result { if s.is_empty() { return Ok(BunRequest::Any); } // Handle "bun" or "bun@version" format if let Some(version_part) = s.strip_prefix("bun@") { if version_part.eq_ignore_ascii_case("latest") { return Ok(BunRequest::Any); } return Self::parse_version_numbers(version_part, s); } if s == "bun" { return Ok(BunRequest::Any); } Self::parse_version_numbers(s, s) .or_else(|_| { semver::VersionReq::parse(s) .map(BunRequest::Range) .map_err(|_| Error::InvalidVersion(s.to_string())) }) .or_else(|_| { let path = PathBuf::from(s); if path.exists() { Ok(BunRequest::Path(path)) } else { Err(Error::InvalidVersion(s.to_string())) } }) } } impl BunRequest { pub(crate) fn is_any(&self) -> bool { matches!(self, BunRequest::Any) } fn parse_version_numbers( version_str: &str, original_request: &str, ) -> Result { let parts = try_into_u64_slice(version_str) .map_err(|_| Error::InvalidVersion(original_request.to_string()))?; match parts.as_slice() { [major] => Ok(BunRequest::Major(*major)), [major, minor] => Ok(BunRequest::MajorMinor(*major, *minor)), [major, minor, patch] => Ok(BunRequest::MajorMinorPatch(*major, *minor, *patch)), _ => Err(Error::InvalidVersion(original_request.to_string())), } } pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { let version = &install_info.language_version; self.matches( &BunVersion(version.clone()), Some(install_info.toolchain.as_ref()), ) } pub(crate) fn matches(&self, version: &BunVersion, toolchain: Option<&Path>) -> bool { match self { Self::Any => true, Self::Major(major) => version.major == *major, Self::MajorMinor(major, minor) => version.major == *major && version.minor == *minor, Self::MajorMinorPatch(major, minor, patch) => { version.major == *major && version.minor == *minor && version.patch == *patch } // FIXME: consider resolving symlinks and normalizing paths before comparison Self::Path(path) => toolchain.is_some_and(|toolchain_path| toolchain_path == path), Self::Range(req) => req.matches(version), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_bun_version_from_str() { let v: BunVersion = "1.1.0".parse().unwrap(); assert_eq!(v.major, 1); assert_eq!(v.minor, 1); assert_eq!(v.patch, 0); let v: BunVersion = "v1.2.3".parse().unwrap(); assert_eq!(v.major, 1); assert_eq!(v.minor, 2); assert_eq!(v.patch, 3); } #[test] fn test_bun_request_from_str() { assert_eq!(BunRequest::from_str("bun").unwrap(), BunRequest::Any); assert_eq!(BunRequest::from_str("bun@latest").unwrap(), BunRequest::Any); assert_eq!(BunRequest::from_str("").unwrap(), BunRequest::Any); assert_eq!(BunRequest::from_str("1").unwrap(), BunRequest::Major(1)); assert_eq!(BunRequest::from_str("bun@1").unwrap(), BunRequest::Major(1)); assert_eq!( BunRequest::from_str("1.1").unwrap(), BunRequest::MajorMinor(1, 1) ); assert_eq!( BunRequest::from_str("bun@1.1").unwrap(), BunRequest::MajorMinor(1, 1) ); assert_eq!( BunRequest::from_str("1.1.0").unwrap(), BunRequest::MajorMinorPatch(1, 1, 0) ); assert_eq!( BunRequest::from_str("bun@1.1.0").unwrap(), BunRequest::MajorMinorPatch(1, 1, 0) ); } #[test] fn test_bun_request_range() { let req = BunRequest::from_str(">=1.0").unwrap(); assert!(matches!(req, BunRequest::Range(_))); let req = BunRequest::from_str(">=1.0, <2.0").unwrap(); assert!(matches!(req, BunRequest::Range(_))); } #[test] fn test_bun_request_invalid() { assert!(BunRequest::from_str("1.1.0.1").is_err()); assert!(BunRequest::from_str("1.1a").is_err()); assert!(BunRequest::from_str("invalid").is_err()); } #[test] fn test_bun_request_matches() { let version = BunVersion(semver::Version::new(1, 1, 4)); assert!(BunRequest::Any.matches(&version, None)); assert!(BunRequest::Major(1).matches(&version, None)); assert!(!BunRequest::Major(2).matches(&version, None)); assert!(BunRequest::MajorMinor(1, 1).matches(&version, None)); assert!(!BunRequest::MajorMinor(1, 2).matches(&version, None)); assert!(BunRequest::MajorMinorPatch(1, 1, 4).matches(&version, None)); assert!(!BunRequest::MajorMinorPatch(1, 1, 5).matches(&version, None)); } } ================================================ FILE: crates/prek/src/languages/deno/deno.rs ================================================ use std::path::Path; use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::languages::deno::DenoRequest; use crate::languages::deno::installer::{DenoInstaller, DenoResult, bin_dir}; use crate::languages::version::LanguageRequest; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::{CacheBucket, Store, ToolBucket}; fn is_valid_install_name(name: &str) -> bool { let mut chars = name.chars(); matches!(chars.next(), Some(c) if c.is_ascii_alphanumeric()) && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') } /// Parse a Deno `additional_dependencies` item. /// /// Deno support treats every additional dependency as an executable install target for /// `deno install --global`. That makes the contract explicit and avoids guessing whether /// a string should be handled as `deno add` or `deno install`. /// /// The optional `:name` suffix is interpreted as `deno install --name `, but only /// when the left side clearly looks like an install target that may legitimately contain /// colons itself: /// - specifiers such as `npm:semver@7` /// - URLs such as `https://...` /// - local paths such as `./cli.ts` /// /// Plain command strings are left untouched so we do not accidentally split on a colon /// that is part of the dependency string. fn parse_install_dependency(spec: &str) -> (&str, Option<&str>) { let Some((dep, name)) = spec.rsplit_once(':') else { return (spec, None); }; let looks_like_path = dep.starts_with('.') || dep.starts_with('/') || dep.contains(['/', '\\']); if is_valid_install_name(name) && (looks_like_path || dep.contains(':')) { (dep, Some(name)) } else { (spec, None) } } #[derive(Debug, Copy, Clone)] pub(crate) struct Deno; impl LanguageImpl for Deno { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result { let progress = reporter.on_install_start(&hook); // 1. Install deno let deno_dir = store.tools_path(ToolBucket::Deno); let installer = DenoInstaller::new(deno_dir); let (deno_request, allows_download) = match &hook.language_request { LanguageRequest::Any { system_only } => (&DenoRequest::Any, !system_only), LanguageRequest::Deno(deno_request) => (deno_request, true), _ => unreachable!(), }; let deno = installer .install(store, deno_request, allows_download) .await .context("Failed to install deno")?; let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; info.with_toolchain(deno.deno().to_path_buf()); info.with_language_version((**deno.version()).clone()); // 2. Create env let env_bin_dir = bin_dir(&info.env_path); fs_err::tokio::create_dir_all(&env_bin_dir).await?; // Relative install targets in `additional_dependencies` are resolved by Deno // against the process working directory. For remote hooks that should be the // cloned hook repository so `./cli.ts:name` refers to files shipped by the hook. // For local hooks we keep resolution in the user's work tree. let install_dir = hook.repo_path().unwrap_or(hook.work_dir()); // We share one Deno cache bucket across install and run. Executable shims live in // the per-hook env bin dir, while downloaded modules and npm artifacts are reused // from this cache bucket. let deno_cache_dir = store.cache_path(CacheBucket::Deno); fs_err::tokio::create_dir_all(&deno_cache_dir).await?; // 3. Install additional dependencies as executables in the hook env. // // Current Deno contract: // - prek does not try to install the remote hook repo itself // - prek does not inspect or rewrite `entry` to derive install targets // - every `additional_dependencies` item is provisioned into `/bin` // // This keeps installation and execution separate. If a remote hook repo wants to // expose its own executable, it must declare a local file in // `additional_dependencies`, for example `./cli.ts:repo-tool`, and then use // `repo-tool` in `entry`. // // We intentionally pass `--allow-all` because `deno install` bakes permissions into // the installed wrapper. Since prek does not parse `entry` or repo metadata to infer // a minimal permission set, the simplest predictable behavior is to install the // executable with full permissions and let the hook author choose the installed // command name explicitly when needed via `dep:name`. if !hook.additional_dependencies.is_empty() { debug!(deps = ?hook.additional_dependencies, "Installing deno dependencies"); } for spec in &hook.additional_dependencies { let (dep, name) = parse_install_dependency(spec); let mut install_cmd = Cmd::new(deno.deno(), "deno install dependency"); install_cmd .current_dir(install_dir) .env(EnvVars::DENO_DIR, &deno_cache_dir) .env(EnvVars::DENO_NO_UPDATE_CHECK, "1") .arg("install") .arg("--allow-all") .arg("--global") .arg("--force") .arg("--root") .arg(&info.env_path); if let Some(name) = name { install_cmd.arg("--name").arg(name); } install_cmd .arg(dep) .check(true) .output() .await .with_context(|| format!("Failed to install deno dependency `{spec}`"))?; } info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, info: &InstallInfo) -> Result<()> { let deno = DenoResult::from_executable(info.toolchain.clone()) .fill_version() .await .context("Failed to query deno version")?; if **deno.version() != info.language_version { anyhow::bail!( "Deno version mismatch: expected {}, found {}", info.language_version, deno.version() ); } Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let deno_cache_dir = store.cache_path(CacheBucket::Deno); let info = hook.install_info().expect("Deno must be installed"); let deno_binary = &info.toolchain; let env_dir = &info.env_path; let deno_bin_dir = deno_binary.parent().expect("Deno binary must have parent"); let new_path = prepend_paths(&[&bin_dir(env_dir), deno_bin_dir]).context("Failed to join PATH")?; let entry = hook.entry.resolve(Some(&new_path))?; let run = async |batch: &[&Path]| { let mut cmd = Cmd::new(&entry[0], "deno hook"); let mut output = cmd .current_dir(hook.work_dir()) .env(EnvVars::PATH, &new_path) .env(EnvVars::DENO_DIR, &deno_cache_dir) .env(EnvVars::DENO_NO_UPDATE_CHECK, "1") .envs(&hook.env) .args(&entry[1..]) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); // Collect results let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } #[cfg(test)] mod tests { use super::parse_install_dependency; #[test] fn parse_install_dependency_without_name() { assert_eq!( parse_install_dependency("npm:prettier@3"), ("npm:prettier@3", None) ); } #[test] fn parse_install_dependency_with_name() { assert_eq!( parse_install_dependency("npm:prettier@3:fmt-tool"), ("npm:prettier@3", Some("fmt-tool")) ); } #[test] fn parse_install_dependency_with_local_path_name() { assert_eq!( parse_install_dependency("./tools/echo.ts:echo-tool"), ("./tools/echo.ts", Some("echo-tool")) ); } #[test] fn parse_install_dependency_with_invalid_name_keeps_original() { assert_eq!( parse_install_dependency("./tools/echo.ts:not valid"), ("./tools/echo.ts:not valid", None) ); } } ================================================ FILE: crates/prek/src/languages/deno/installer.rs ================================================ use std::env::consts::EXE_EXTENSION; use std::fmt::Display; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::LazyLock; use anyhow::{Context, Result}; use itertools::Itertools; use prek_consts::env_vars::EnvVars; use serde::Deserialize; use target_lexicon::{Architecture, HOST, OperatingSystem}; use tracing::{debug, trace, warn}; use crate::fs::LockedFile; use crate::http::{REQWEST_CLIENT, download_and_extract}; use crate::languages::deno::DenoRequest; use crate::languages::deno::version::DenoVersion; use crate::process::Cmd; use crate::store::Store; #[derive(Debug)] pub(crate) struct DenoResult { deno: PathBuf, version: DenoVersion, } impl Display for DenoResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}@{}", self.deno.display(), self.version)?; Ok(()) } } /// Override the Deno binary name for testing. static DENO_BINARY_NAME: LazyLock = LazyLock::new(|| { if let Ok(name) = EnvVars::var(EnvVars::PREK_INTERNAL__DENO_BINARY_NAME) { name } else { "deno".to_string() } }); impl DenoResult { pub(crate) fn from_executable(deno: PathBuf) -> Self { Self { deno, version: DenoVersion::default(), } } pub(crate) fn from_dir(dir: &Path) -> Self { let deno = bin_dir(dir).join("deno").with_extension(EXE_EXTENSION); Self::from_executable(deno) } pub(crate) fn with_version(mut self, version: DenoVersion) -> Self { self.version = version; self } pub(crate) async fn fill_version(mut self) -> Result { let output = Cmd::new(&self.deno, "deno --version") .env(EnvVars::DENO_NO_UPDATE_CHECK, "1") .arg("--version") .check(true) .output() .await?; // Output format: "deno 2.1.0 (release, x86_64-unknown-linux-gnu)\n..." let output_str = String::from_utf8_lossy(&output.stdout); let version_str = output_str .lines() .next() .and_then(|line| line.strip_prefix("deno ")) .and_then(|rest| rest.split_whitespace().next()) .context("Failed to parse deno version output")?; self.version = version_str .parse() .context("Failed to parse deno version")?; Ok(self) } pub(crate) fn deno(&self) -> &Path { &self.deno } pub(crate) fn version(&self) -> &DenoVersion { &self.version } } pub(crate) struct DenoInstaller { root: PathBuf, } impl DenoInstaller { pub(crate) fn new(root: PathBuf) -> Self { Self { root } } /// Install a version of Deno. pub(crate) async fn install( &self, store: &Store, request: &DenoRequest, allows_download: bool, ) -> Result { fs_err::tokio::create_dir_all(&self.root).await?; let _lock = LockedFile::acquire(self.root.join(".lock"), "deno").await?; if let Ok(deno_result) = self.find_installed(request) { trace!(%deno_result, "Found installed deno"); return Ok(deno_result); } // Find all deno executables in PATH and check their versions if let Some(deno_result) = self.find_system_deno(request).await? { trace!(%deno_result, "Using system deno"); return Ok(deno_result); } if !allows_download { anyhow::bail!("No suitable system Deno version found and downloads are disabled"); } let resolved_version = self.resolve_version(request).await?; trace!(version = %resolved_version, "Downloading deno"); self.download(store, &resolved_version).await } /// Get the installed version of Deno. fn find_installed(&self, req: &DenoRequest) -> Result { let mut installed = fs_err::read_dir(&self.root) .ok() .into_iter() .flatten() .filter_map(|entry| match entry { Ok(entry) => Some(entry), Err(err) => { warn!(?err, "Failed to read entry"); None } }) .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir())) .filter_map(|entry| { let dir_name = entry.file_name(); let version = DenoVersion::from_str(&dir_name.to_string_lossy()).ok()?; Some((version, entry.path())) }) .sorted_unstable_by(|(a, _), (b, _)| a.cmp(b)) .rev(); installed .find_map(|(v, path)| { if req.matches(&v, Some(&path)) { Some(DenoResult::from_dir(&path).with_version(v)) } else { None } }) .context("No installed deno version matches the request") } async fn resolve_version(&self, req: &DenoRequest) -> Result { // Latest versions come first, so we can find the latest matching version. let versions = self .list_remote_versions() .await .context("Failed to list remote versions")?; let version = versions .into_iter() .find(|version| req.matches(version, None)) .context("Version not found on remote")?; Ok(version) } /// List all versions of Deno available from the official versions endpoint. /// /// Uses which is lightweight and doesn't /// have rate-limit issues like the GitHub API. async fn list_remote_versions(&self) -> Result> { #[derive(Deserialize)] struct VersionsResponse { cli: Vec, } let url = "https://deno.com/versions.json"; let response: VersionsResponse = REQWEST_CLIENT.get(url).send().await?.json().await?; // Versions are already sorted in descending order (newest first) let versions: Vec = response .cli .into_iter() .filter_map(|v| DenoVersion::from_str(&v).ok()) .collect(); if versions.is_empty() { anyhow::bail!("No Deno versions found"); } Ok(versions) } /// Install a specific version of Deno. async fn download(&self, store: &Store, version: &DenoVersion) -> Result { let arch = match HOST.architecture { Architecture::X86_64 => "x86_64", Architecture::Aarch64(_) => "aarch64", _ => anyhow::bail!("Unsupported architecture for Deno"), }; let os = match HOST.operating_system { OperatingSystem::Darwin(_) => "apple-darwin", OperatingSystem::Linux => "unknown-linux-gnu", OperatingSystem::Windows => "pc-windows-msvc", _ => anyhow::bail!("Unsupported OS for Deno"), }; let filename = format!("deno-{arch}-{os}.zip"); let url = format!("https://dl.deno.land/release/v{version}/{filename}"); let target = self.root.join(version.to_string()); download_and_extract(&url, &filename, store, async |extracted| { if target.exists() { debug!(target = %target.display(), "Removing existing deno"); fs_err::tokio::remove_dir_all(&target).await?; } // Deno ZIP contains just the binary at the root level. // After strip_component, `extracted` may be the binary itself (if singular) // or a directory containing the binary. let extracted_binary = if extracted.is_file() { extracted.to_path_buf() } else { extracted.join("deno").with_extension(EXE_EXTENSION) }; let target_bin_dir = bin_dir(&target); fs_err::tokio::create_dir_all(&target_bin_dir).await?; let target_binary = target_bin_dir.join("deno").with_extension(EXE_EXTENSION); debug!(?extracted_binary, target = %target_binary.display(), "Moving deno to target"); fs_err::tokio::rename(&extracted_binary, &target_binary).await?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs_err::tokio::metadata(&target_binary).await?.permissions(); perms.set_mode(0o755); fs_err::tokio::set_permissions(&target_binary, perms).await?; } anyhow::Ok(()) }) .await .context("Failed to download and extract deno")?; Ok(DenoResult::from_dir(&target).with_version(version.clone())) } /// Find a suitable system Deno installation that matches the request. async fn find_system_deno(&self, deno_request: &DenoRequest) -> Result> { let deno_paths = match which::which_all(&*DENO_BINARY_NAME) { Ok(paths) => paths, Err(e) => { debug!("No deno executables found in PATH: {}", e); return Ok(None); } }; // Check each deno executable for a matching version, stop early if found for deno_path in deno_paths { match DenoResult::from_executable(deno_path).fill_version().await { Ok(deno_result) => { // Check if this version matches the request if deno_request.matches(&deno_result.version, Some(&deno_result.deno)) { trace!( %deno_result, "Found a matching system deno" ); return Ok(Some(deno_result)); } trace!( %deno_result, "System deno does not match requested version" ); } Err(e) => { warn!(?e, "Failed to get version for system deno"); } } } debug!( ?deno_request, "No system deno matches the requested version" ); Ok(None) } } pub(crate) fn bin_dir(prefix: &Path) -> PathBuf { prefix.join("bin") } ================================================ FILE: crates/prek/src/languages/deno/mod.rs ================================================ #[allow(clippy::module_inception)] mod deno; pub(crate) mod installer; pub(crate) mod version; pub(crate) use deno::Deno; pub(crate) use version::DenoRequest; ================================================ FILE: crates/prek/src/languages/deno/version.rs ================================================ use std::fmt::Display; use std::ops::Deref; use std::path::Path; use std::str::FromStr; use serde::Deserialize; use crate::hook::InstallInfo; use crate::languages::version::{Error, try_into_u64_slice}; #[derive(Debug, Clone, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct DenoVersion(semver::Version); impl Default for DenoVersion { fn default() -> Self { DenoVersion(semver::Version::new(0, 0, 0)) } } impl Deref for DenoVersion { type Target = semver::Version; fn deref(&self) -> &Self::Target { &self.0 } } impl Display for DenoVersion { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } impl FromStr for DenoVersion { type Err = semver::Error; fn from_str(s: &str) -> Result { let s = s.strip_prefix('v').unwrap_or(s).trim(); semver::Version::parse(s).map(DenoVersion) } } /// `language_version` field of deno can be one of the following: /// - `default`: Find system installed deno, or download the latest version. /// - `system`: Find system installed deno, or error if not found. /// - `deno` or `deno@latest`: Same as `default`. /// - `x.y` or `deno@x.y`: Install the latest version with the same major and minor version. /// - `x.y.z` or `deno@x.y.z`: Install the specific version. /// - `^x.y.z`: Install the latest version that satisfies the semver requirement. /// Or any other semver compatible version requirement. #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) enum DenoRequest { Any, Major(u64), MajorMinor(u64, u64), MajorMinorPatch(u64, u64, u64), Range(semver::VersionReq), } impl FromStr for DenoRequest { type Err = Error; fn from_str(s: &str) -> Result { if s.is_empty() { return Ok(DenoRequest::Any); } // Handle "deno" or "deno@version" format if let Some(version_part) = s.strip_prefix("deno@") { if version_part.eq_ignore_ascii_case("latest") { return Ok(DenoRequest::Any); } return Self::parse_version_numbers(version_part, s); } if s == "deno" { return Ok(DenoRequest::Any); } Self::parse_version_numbers(s, s).or_else(|_| { semver::VersionReq::parse(s) .map(DenoRequest::Range) .map_err(|_| Error::InvalidVersion(s.to_string())) }) } } impl DenoRequest { pub(crate) fn is_any(&self) -> bool { matches!(self, DenoRequest::Any) } fn parse_version_numbers( version_str: &str, original_request: &str, ) -> Result { let parts = try_into_u64_slice(version_str) .map_err(|_| Error::InvalidVersion(original_request.to_string()))?; match parts.as_slice() { [major] => Ok(DenoRequest::Major(*major)), [major, minor] => Ok(DenoRequest::MajorMinor(*major, *minor)), [major, minor, patch] => Ok(DenoRequest::MajorMinorPatch(*major, *minor, *patch)), _ => Err(Error::InvalidVersion(original_request.to_string())), } } pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { let version = &install_info.language_version; self.matches( &DenoVersion(version.clone()), Some(install_info.toolchain.as_ref()), ) } pub(crate) fn matches(&self, version: &DenoVersion, _toolchain: Option<&Path>) -> bool { match self { Self::Any => true, Self::Major(major) => version.major == *major, Self::MajorMinor(major, minor) => version.major == *major && version.minor == *minor, Self::MajorMinorPatch(major, minor, patch) => { version.major == *major && version.minor == *minor && version.patch == *patch } Self::Range(req) => req.matches(version), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_deno_version_from_str() { let v: DenoVersion = "2.1.0".parse().unwrap(); assert_eq!(v.major, 2); assert_eq!(v.minor, 1); assert_eq!(v.patch, 0); let v: DenoVersion = "v2.1.3".parse().unwrap(); assert_eq!(v.major, 2); assert_eq!(v.minor, 1); assert_eq!(v.patch, 3); } #[test] fn test_deno_request_from_str() { assert_eq!(DenoRequest::from_str("deno").unwrap(), DenoRequest::Any); assert_eq!( DenoRequest::from_str("deno@latest").unwrap(), DenoRequest::Any ); assert_eq!(DenoRequest::from_str("").unwrap(), DenoRequest::Any); assert_eq!(DenoRequest::from_str("2").unwrap(), DenoRequest::Major(2)); assert_eq!( DenoRequest::from_str("deno@2").unwrap(), DenoRequest::Major(2) ); assert_eq!( DenoRequest::from_str("2.1").unwrap(), DenoRequest::MajorMinor(2, 1) ); assert_eq!( DenoRequest::from_str("deno@2.1").unwrap(), DenoRequest::MajorMinor(2, 1) ); assert_eq!( DenoRequest::from_str("2.1.0").unwrap(), DenoRequest::MajorMinorPatch(2, 1, 0) ); assert_eq!( DenoRequest::from_str("deno@2.1.0").unwrap(), DenoRequest::MajorMinorPatch(2, 1, 0) ); } #[test] fn test_deno_request_range() { let req = DenoRequest::from_str(">=2.0").unwrap(); assert!(matches!(req, DenoRequest::Range(_))); let req = DenoRequest::from_str(">=2.0, <3.0").unwrap(); assert!(matches!(req, DenoRequest::Range(_))); } #[test] fn test_deno_request_invalid() { assert!(DenoRequest::from_str("2.1.0.1").is_err()); assert!(DenoRequest::from_str("2.1a").is_err()); assert!(DenoRequest::from_str("invalid").is_err()); } #[test] fn test_deno_request_matches() { let version = DenoVersion(semver::Version::new(2, 1, 4)); assert!(DenoRequest::Any.matches(&version, None)); assert!(DenoRequest::Major(2).matches(&version, None)); assert!(!DenoRequest::Major(1).matches(&version, None)); assert!(DenoRequest::MajorMinor(2, 1).matches(&version, None)); assert!(!DenoRequest::MajorMinor(2, 2).matches(&version, None)); assert!(DenoRequest::MajorMinorPatch(2, 1, 4).matches(&version, None)); assert!(!DenoRequest::MajorMinorPatch(2, 1, 5).matches(&version, None)); } } ================================================ FILE: crates/prek/src/languages/docker.rs ================================================ use std::borrow::Cow; use std::collections::BTreeSet; use std::collections::hash_map::DefaultHasher; use std::fs; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::str::FromStr; use std::sync::{Arc, LazyLock}; use anyhow::{Context, Result}; use lazy_regex::regex; use prek_consts::env_vars::EnvVars; use tracing::{trace, warn}; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::{USE_COLOR, run_by_batch}; use crate::store::Store; #[derive(Debug, Copy, Clone)] pub(crate) struct Docker; #[derive(Debug, thiserror::Error)] enum Error { #[error("Failed to parse docker inspect output: {0}")] Serde(#[from] serde_json::Error), #[error("Failed to run `docker inspect`: {0}")] Process(#[from] std::io::Error), } /// Check if the current process is running inside a Docker container. /// see fn is_in_docker() -> bool { if fs::metadata("/.dockerenv").is_ok() || fs::metadata("/run/.containerenv").is_ok() { return true; } false } /// Get container id the process is running in. /// /// There are no reliable way to get the container id inside container, see /// /// for details. /// /// Adapted from /// Uses `/proc/self/cgroup` for cgroup v1, /// uses `/proc/self/mountinfo` for cgroup v2 fn current_container_id() -> Result { current_container_id_from_paths("/proc/self/cgroup", "/proc/self/mountinfo") } fn current_container_id_from_paths( cgroup_path: impl AsRef, mountinfo_path: impl AsRef, ) -> Result { if let Ok(container_id) = container_id_from_cgroup_v1(cgroup_path) { return Ok(container_id); } container_id_from_cgroup_v2(mountinfo_path) } fn container_id_from_cgroup_v1(cgroup: impl AsRef) -> Result { let content = fs::read_to_string(cgroup).context("Failed to read cgroup v1 info")?; content .lines() .find_map(parse_id_from_line) .context("Failed to detect Docker container id from cgroup v1") } fn parse_id_from_line(line: &str) -> Option { let last_slash_idx = line.rfind('/')?; let last_section = &line[last_slash_idx + 1..]; let container_id = if let Some(colon_idx) = last_section.rfind(':') { // Since containerd v1.5.0+, containerId is divided by the last colon when the // cgroupDriver is systemd: // https://github.com/containerd/containerd/blob/release/1.5/pkg/cri/server/helpers_linux.go#L64 last_section[colon_idx + 1..].to_string() } else { let start_idx = last_section.rfind('-').map(|i| i + 1).unwrap_or(0); let end_idx = last_section.rfind('.').unwrap_or(last_section.len()); if start_idx > end_idx { return None; } last_section[start_idx..end_idx].to_string() }; if container_id.len() == 64 && container_id.chars().all(|c| c.is_ascii_hexdigit()) { return Some(container_id); } None } fn container_id_from_cgroup_v2(mount_info: impl AsRef) -> Result { let content = fs::read_to_string(mount_info).context("Failed to read cgroup v2 mount info")?; regex!(r".*/(containers|overlay-containers)/([0-9a-f]{64})/.*") .captures(&content) .and_then(|caps| caps.get(2)) .map(|m| m.as_str().to_owned()) .context("Failed to find Docker container id in cgroup v2 mount info") } #[derive(Debug, Copy, Clone, PartialEq, Eq)] enum RuntimeKind { Auto, AppleContainer, Docker, Podman, } impl FromStr for RuntimeKind { type Err = String; fn from_str(s: &str) -> Result { match s.to_ascii_lowercase().as_str() { "container" => Ok(RuntimeKind::AppleContainer), "docker" => Ok(RuntimeKind::Docker), "podman" => Ok(RuntimeKind::Podman), "auto" => Ok(RuntimeKind::Auto), _ => Err(format!("Invalid container runtime: {s}")), } } } #[derive(serde::Deserialize, Debug)] struct Mount { #[serde(rename = "Source")] source: String, #[serde(rename = "Destination")] destination: String, } impl RuntimeKind { fn cmd(&self) -> &str { match self { RuntimeKind::AppleContainer => "container", RuntimeKind::Docker => "docker", RuntimeKind::Podman => "podman", RuntimeKind::Auto => unreachable!("Auto should be resolved before use"), } } /// Detect if the current runtime is rootless. fn detect_rootless(self) -> Result { match self { RuntimeKind::AppleContainer => Ok(false), RuntimeKind::Docker => { let output = Command::new(self.cmd()) .arg("info") .arg("--format") .arg("'{{ .SecurityOptions }}'") .output()?; let stdout = str::from_utf8(&output.stdout)?; Ok(stdout.contains("name=rootless")) } RuntimeKind::Podman => { let output = Command::new(self.cmd()) .arg("info") .arg("--format") .arg("{{ .Host.Security.Rootless -}}") .output()?; let stdout = str::from_utf8(&output.stdout)?; Ok(stdout.eq_ignore_ascii_case("true")) } RuntimeKind::Auto => unreachable!("Auto should be resolved before use"), } } /// List the mounts of the current container. fn list_mounts(self) -> Result> { if !is_in_docker() { anyhow::bail!("Not in a container"); } let container_id = current_container_id()?; trace!(?container_id, "In Docker container"); let output = Command::new(self.cmd()) .arg("inspect") .arg("--format") .arg("'{{json .Mounts}}'") .arg(&container_id) .output()? .stdout; let stdout = String::from_utf8_lossy(&output); let stdout = stdout.trim().trim_matches('\''); let mounts: Vec = serde_json::from_str(stdout)?; trace!(?mounts, "Get docker mounts"); Ok(mounts) } } struct ContainerRuntimeInfo { runtime: RuntimeKind, rootless: bool, mounts: Vec, } impl ContainerRuntimeInfo { /// Detect container runtime provider, prioritise docker over podman if /// both are on the path, unless `PREK_CONTAINER_RUNTIME` is set to override detection. fn resolve_runtime_kind( env_override: Option, docker_available: DF, podman_available: PF, apple_container_available: CF, ) -> RuntimeKind where DF: Fn() -> bool, PF: Fn() -> bool, CF: Fn() -> bool, { if let Some(val) = env_override { match RuntimeKind::from_str(&val) { Ok(runtime) => { if runtime != RuntimeKind::Auto { trace!( "Container runtime overridden by {}={}", EnvVars::PREK_CONTAINER_RUNTIME, val ); return runtime; } } Err(_) => { warn!( "Invalid value for {}: {}, falling back to auto detection", EnvVars::PREK_CONTAINER_RUNTIME, val ); } } } if docker_available() { return RuntimeKind::Docker; } if podman_available() { return RuntimeKind::Podman; } if apple_container_available() { return RuntimeKind::AppleContainer; } trace!("No container runtime found on PATH, defaulting to docker"); RuntimeKind::Docker } fn detect_runtime() -> Self { let runtime = Self::resolve_runtime_kind( EnvVars::var(EnvVars::PREK_CONTAINER_RUNTIME).ok(), || which::which("docker").is_ok(), || which::which("podman").is_ok(), || which::which("container").is_ok(), ); let rootless = runtime.detect_rootless().unwrap_or_else(|e| { warn!("Failed to detect if container runtime is rootless: {e}, defaulting to rootful"); false }); let mounts = runtime.list_mounts().unwrap_or_else(|e| { warn!("Failed to get container mounts: {e}, assuming no mounts"); vec![] }); Self { runtime, rootless, mounts, } } /// Get the command name of the container runtime. fn cmd(&self) -> &str { self.runtime.cmd() } fn is_rootless(&self) -> bool { self.rootless } fn is_podman(&self) -> bool { self.runtime == RuntimeKind::Podman } fn is_apple_container(&self) -> bool { self.runtime == RuntimeKind::AppleContainer } /// Get the path of the current directory in the host. fn map_to_host_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> { for mount in &self.mounts { if let Ok(suffix) = path.strip_prefix(&mount.destination) { if suffix.components().next().is_none() { // Exact match return Cow::Owned(PathBuf::from(&mount.source)); } let path = Path::new(&mount.source).join(suffix); return Cow::Owned(path); } } Cow::Borrowed(path) } } static CONTAINER_RUNTIME: LazyLock = LazyLock::new(ContainerRuntimeInfo::detect_runtime); impl Docker { fn docker_tag(info: &InstallInfo) -> String { let mut hasher = DefaultHasher::new(); info.language.hash(&mut hasher); info.language_version.hash(&mut hasher); let deps = info.dependencies.iter().collect::>(); deps.hash(&mut hasher); let digest = hex::encode(hasher.finish().to_le_bytes()); format!("prek-{digest}") } async fn build_docker_image( hook: &Hook, install_info: &InstallInfo, pull: bool, ) -> Result { let Some(src) = hook.repo_path() else { anyhow::bail!("Language `docker` cannot work with `local` repository"); }; let tag = Self::docker_tag(install_info); let mut cmd = Cmd::new(CONTAINER_RUNTIME.cmd(), "build docker image"); let cmd = cmd .arg("build") .arg("--tag") .arg(&tag) .arg("--label") .arg("org.opencontainers.image.vendor=prek") .arg("--label") .arg(format!("org.opencontainers.image.source={}", hook.repo())) .arg("--label") .arg(format!("prek.hook.id={}", hook.id)) .arg("--label") .arg("prek.managed=true"); // Always attempt to pull all referenced images. if pull { cmd.arg("--pull"); } // This must come last for old versions of docker. // see https://github.com/pre-commit/pre-commit/issues/477 cmd.arg("."); cmd.current_dir(src).check(true).output().await?; Ok(tag) } pub(crate) fn docker_run_cmd(work_dir: &Path) -> Cmd { let mut command = Cmd::new(CONTAINER_RUNTIME.cmd(), "run container"); command.arg("run").arg("--rm"); if *USE_COLOR { command.arg("--tty"); } // Run as a non-root user #[cfg(unix)] { let add_user_args = |cmd: &mut Cmd| { let uid = unsafe { libc::geteuid() }; let gid = unsafe { libc::getegid() }; cmd.arg("--user").arg(format!("{uid}:{gid}")); }; // If runtime is rootful, set user to non-root user id matching current user id. if !CONTAINER_RUNTIME.is_rootless() { add_user_args(&mut command); } else if CONTAINER_RUNTIME.is_podman() { // For rootless podman, set user to non-root use id matching // current user id and add additional `--userns` param to map the user id correctly. add_user_args(&mut command); command.arg("--userns").arg("keep-id"); } // Otherwise (rootless Docker): do nothing as it will cause permission // problems with bind mounted files. In this state, `root:root` inside the container is // the same as current `uid:gid` on the host - see subuid / subgid. } // https://docs.docker.com/reference/cli/docker/container/run/#volumes-from // The `Z` option tells Docker to label the content with a private // unshared label. Only the current container can use a private volume. let work_dir = CONTAINER_RUNTIME.map_to_host_path(work_dir); let z = if CONTAINER_RUNTIME.is_apple_container() { "" // Not currently supported } else { ",Z" }; let volume = format!("{}:/src:rw{z}", work_dir.display()); if !CONTAINER_RUNTIME.is_apple_container() { // Run an init inside the container that forwards signals and reaps processes command.arg("--init"); } command .arg("--volume") .arg(volume) .arg("--workdir") .arg("/src"); command } } impl LanguageImpl for Docker { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result { let progress = reporter.on_install_start(&hook); let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; Docker::build_docker_image(&hook, &info, true) .await .context("Failed to build docker image")?; info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, _info: &InstallInfo) -> Result<()> { Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); // Pass environment variables on the command line (they will appear in ps output). let env_args: Vec = hook .env .iter() .flat_map(|(key, value)| ["-e".to_owned(), format!("{key}={value}")]) .collect(); let docker_tag = Docker::build_docker_image( hook, hook.install_info().expect("Docker env must be installed"), false, ) .await .context("Failed to build docker image")?; let entry = hook.entry.split()?; let run = async |batch: &[&Path]| { // docker run [OPTIONS] IMAGE [COMMAND] [ARG...] let mut cmd = Docker::docker_run_cmd(hook.work_dir()); let mut output = cmd .current_dir(hook.work_dir()) .args(&env_args) .arg("--entrypoint") .arg(&entry[0]) .arg(&docker_tag) .args(&entry[1..]) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); // Collect results let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; use std::io::Write; const CONTAINER_ID_V1: &str = "7be92808767a667f35c8505cbf40d14e931ef6db5b0210329cf193b15ba9d605"; const CGROUP_V1_SAMPLE: &str = r"9:cpuset:/system.slice/docker-7be92808767a667f35c8505cbf40d14e931ef6db5b0210329cf193b15ba9d605.scope 8:cpuacct:/system.slice/docker-7be92808767a667f35c8505cbf40d14e931ef6db5b0210329cf193b15ba9d605.scope "; const CONTAINER_ID_V2: &str = "6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc0"; const MOUNTINFO_SAMPLE: &str = r"402 401 0:45 /docker/containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc0/hostname /etc/hostname rw,nosuid,nodev,relatime - tmpfs tmpfs rw,size=65536k,mode=755 403 401 0:45 /docker/containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc0/resolv.conf /etc/resolv.conf rw,nosuid,nodev,relatime - tmpfs tmpfs rw,size=65536k,mode=755 "; #[test] fn test_container_id_from_cgroup_v1() -> anyhow::Result<()> { for (sample, expected) in [ // with suffix (CGROUP_V1_SAMPLE, CONTAINER_ID_V1), // with prefix and suffix ( "13:name=systemd:/podruntime/docker/kubepods/crio-dc679f8a8319c8cf7d38e1adf263bc08d234f0749ea715fb6ca3bb259db69956.stuff", "dc679f8a8319c8cf7d38e1adf263bc08d234f0749ea715fb6ca3bb259db69956", ), // just container id ( "13:name=systemd:/pod/d86d75589bf6cc254f3e2cc29debdf85dde404998aa128997a819ff991827356", "d86d75589bf6cc254f3e2cc29debdf85dde404998aa128997a819ff991827356", ), // with prefix ( "//\n1:name=systemd:/podruntime/docker/kubepods/docker-dc579f8a8319c8cf7d38e1adf263bc08d230600179b07acfd7eaf9646778dc31", "dc579f8a8319c8cf7d38e1adf263bc08d230600179b07acfd7eaf9646778dc31", ), // with two dashes in prefix ( "11:perf_event:/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod4415fd05_2c0f_4533_909b_f2180dca8d7c.slice/cri-containerd-713a77a26fe2a38ebebd5709604a048c3d380db1eb16aa43aca0b2499e54733c.scope", "713a77a26fe2a38ebebd5709604a048c3d380db1eb16aa43aca0b2499e54733c", ), // with colon ( "11:devices:/system.slice/containerd.service/kubepods-pod87a18a64_b74a_454a_b10b_a4a36059d0a3.slice:cri-containerd:05c48c82caff3be3d7f1e896981dd410e81487538936914f32b624d168de9db0", "05c48c82caff3be3d7f1e896981dd410e81487538936914f32b624d168de9db0", ), ] { let mut cgroup_file = tempfile::NamedTempFile::new()?; cgroup_file.write_all(sample.as_bytes())?; cgroup_file.flush()?; let actual = container_id_from_cgroup_v1(cgroup_file.path())?; assert_eq!(actual, expected); } Ok(()) } #[test] fn invalid_container_id_from_cgroup_v1() -> anyhow::Result<()> { for sample in [ // Too short "9:cpuset:/system.slice/docker-7be92808767a667f35c8505cbf40d14e931ef6db5b0210329cf193b15ba9d60.scope", // Non-hex characters "9:cpuset:/system.slice/docker-7be92808767a667f35c8505cbf40d14e931ef6db5b0210329cf193b15ba9d6g0.scope", // No container id "9:cpuset:/system.slice/docker-.scope", ] { let mut cgroup_file = tempfile::NamedTempFile::new()?; cgroup_file.write_all(sample.as_bytes())?; cgroup_file.flush()?; let result = container_id_from_cgroup_v1(cgroup_file.path()); assert!(result.is_err()); } Ok(()) } #[test] fn test_container_id_from_cgroup_v2() -> anyhow::Result<()> { for (sample, expected) in [ // Docker rootful container ( r"402 401 0:45 /var/lib/docker/containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc0/hostname /etc/hostname rw,nosuid,nodev,relatime - tmpfs tmpfs rw,size=65536k,mode=755 403 401 0:45 /var/lib/docker/containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc0/resolv.conf /etc/resolv.conf rw,nosuid,nodev,relatime - tmpfs tmpfs rw,size=65536k,mode=755 ", "6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc0", ), // Docker rootless container ( r"402 401 0:45 /home/testuser/.local/share/docker/containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc1/hostname /etc/hostname rw,nosuid,nodev,relatime - tmpfs tmpfs rw,size=65536k,mode=755 403 401 0:45 /home/testuser/.local/share/docker/containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc1/resolv.conf /etc/resolv.conf rw,nosuid,nodev,relatime - tmpfs tmpfs rw,size=65536k,mode=755 ", "6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc1", ), // Podman rootful container ( r"1099 1105 0:107 /containers/storage/overlay-containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc2/userdata/hostname /etc/hostname rw,nosuid,nodev,relatime - tmpfs tmpfs rw,seclabel,size=3256724k,nr_inodes=814181,mode=700,uid=1000,gid=1000,inode64 1100 1105 0:107 /containers/storage/overlay-containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc2/userdata/resolv.conf /etc/resolv.conf rw,nosuid,nodev,relatime - tmpfs tmpfs rw,seclabel,size=3256724k,nr_inodes=814181,mode=700,uid=1000,gid=1000,inode64 ", "6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc2", ), // Podman rootless container ( r"1099 1105 0:107 /containers/overlay-containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc3/userdata/hostname /etc/hostname rw,nosuid,nodev,relatime - tmpfs tmpfs rw,seclabel,size=3256724k,nr_inodes=814181,mode=700,uid=1000,gid=1000,inode64 1100 1105 0:107 /containers/overlay-containers/6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc3/userdata/resolv.conf /etc/resolv.conf rw,nosuid,nodev,relatime - tmpfs tmpfs rw,seclabel,size=3256724k,nr_inodes=814181,mode=700,uid=1000,gid=1000,inode64 ", "6d81fc3a1c26e24a27803e263d534be37c821e390521961a77f782c46fd85bc3", ), ] { let mut mountinfo_file = tempfile::NamedTempFile::new()?; mountinfo_file.write_all(sample.as_bytes())?; mountinfo_file.flush()?; let actual = container_id_from_cgroup_v2(mountinfo_file.path())?; assert_eq!(actual, expected); } Ok(()) } #[test] fn test_current_container_id_prefers_cgroup_v1() -> anyhow::Result<()> { let mut cgroup_file = tempfile::NamedTempFile::new()?; let mut mountinfo_file = tempfile::NamedTempFile::new()?; cgroup_file.write_all(CGROUP_V1_SAMPLE.as_bytes())?; mountinfo_file.write_all(MOUNTINFO_SAMPLE.as_bytes())?; cgroup_file.flush()?; mountinfo_file.flush()?; let container_id = current_container_id_from_paths(cgroup_file.path(), mountinfo_file.path())?; assert_eq!(container_id, CONTAINER_ID_V1); Ok(()) } #[test] fn test_current_container_id_falls_back_to_cgroup_v2() -> anyhow::Result<()> { let mut cgroup_file = tempfile::NamedTempFile::new()?; let mut mountinfo_file = tempfile::NamedTempFile::new()?; cgroup_file.write_all(b"0::/\n")?; // No cgroup v1 container id available. mountinfo_file.write_all(MOUNTINFO_SAMPLE.as_bytes())?; cgroup_file.flush()?; mountinfo_file.flush()?; let container_id = current_container_id_from_paths(cgroup_file.path(), mountinfo_file.path())?; assert_eq!(container_id, CONTAINER_ID_V2); Ok(()) } #[test] fn test_current_container_id_errors_when_no_match() -> anyhow::Result<()> { let cgroup_file = tempfile::NamedTempFile::new()?; let mut mountinfo_file = tempfile::NamedTempFile::new()?; mountinfo_file.write_all(b"501 500 0:45 /proc /proc rw\n")?; mountinfo_file.flush()?; let result = current_container_id_from_paths(cgroup_file.path(), mountinfo_file.path()); assert!(result.is_err()); Ok(()) } #[test] fn test_detect_container_runtime() { fn runtime_with( env_override: Option<&str>, docker_available: bool, podman_available: bool, apple_container_available: bool, ) -> RuntimeKind { ContainerRuntimeInfo::resolve_runtime_kind( env_override.map(ToString::to_string), || docker_available, || podman_available, || apple_container_available, ) } assert_eq!(runtime_with(None, true, false, false), RuntimeKind::Docker); assert_eq!(runtime_with(None, false, true, false), RuntimeKind::Podman); assert_eq!( runtime_with(None, false, false, true), RuntimeKind::AppleContainer ); assert_eq!(runtime_with(None, false, false, false), RuntimeKind::Docker); assert_eq!( runtime_with(Some("auto"), true, false, false), RuntimeKind::Docker ); assert_eq!( runtime_with(Some("auto"), false, true, false), RuntimeKind::Podman ); assert_eq!( runtime_with(Some("auto"), false, false, true), RuntimeKind::AppleContainer ); assert_eq!( runtime_with(Some("auto"), false, false, false), RuntimeKind::Docker ); assert_eq!( runtime_with(Some("docker"), true, false, false), RuntimeKind::Docker ); assert_eq!( runtime_with(Some("docker"), false, true, false), RuntimeKind::Docker ); assert_eq!( runtime_with(Some("DOCKER"), false, false, false), RuntimeKind::Docker ); assert_eq!( runtime_with(Some("podman"), true, false, false), RuntimeKind::Podman ); assert_eq!( runtime_with(Some("podman"), false, true, false), RuntimeKind::Podman ); assert_eq!( runtime_with(Some("podman"), false, false, false), RuntimeKind::Podman ); assert_eq!( runtime_with(Some("container"), true, true, false), RuntimeKind::AppleContainer ); assert_eq!( runtime_with(Some("invalid"), false, false, false), RuntimeKind::Docker ); } } ================================================ FILE: crates/prek/src/languages/docker_image.rs ================================================ use std::path::Path; use std::process::Stdio; use std::sync::Arc; use anyhow::Result; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::languages::docker::Docker; use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] pub(crate) struct DockerImage; impl LanguageImpl for DockerImage { async fn install( &self, hook: Arc, _store: &Store, _reporter: &HookInstallReporter, ) -> Result { Ok(InstalledHook::NoNeedInstall(hook)) } async fn check_health(&self, _info: &InstallInfo) -> Result<()> { Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); // Pass environment variables on the command line (they will appear in ps output). let env_args: Vec = hook .env .iter() .flat_map(|(key, value)| ["-e".to_owned(), format!("{key}={value}")]) .collect(); let entry = hook.entry.split()?; let run = async |batch: &[&Path]| { let mut cmd = Docker::docker_run_cmd(hook.work_dir()); let mut output = cmd .current_dir(hook.work_dir()) .args(&env_args) .args(&entry[..]) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); // Collect results let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } ================================================ FILE: crates/prek/src/languages/fail.rs ================================================ use std::io::Write; use std::path::Path; use std::sync::Arc; use anyhow::Result; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::store::Store; #[derive(Debug, Copy, Clone)] pub(crate) struct Fail; impl LanguageImpl for Fail { async fn install( &self, hook: Arc, _store: &Store, _reporter: &HookInstallReporter, ) -> Result { Ok(InstalledHook::NoNeedInstall(hook)) } async fn check_health(&self, _info: &InstallInfo) -> Result<()> { Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, _reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let mut out = Vec::new(); writeln!(out, "{}\n", hook.entry.raw())?; for f in filenames { out.extend(f.to_string_lossy().as_bytes()); out.push(b'\n'); } out.push(b'\n'); Ok((1, out)) } } ================================================ FILE: crates/prek/src/languages/golang/golang.rs ================================================ use std::ops::Deref; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use anyhow::Context; use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::languages::golang::GoRequest; use crate::languages::golang::installer::GoInstaller; use crate::languages::version::LanguageRequest; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::{CacheBucket, Store, ToolBucket}; #[derive(Debug, Copy, Clone)] pub(crate) struct Golang; impl LanguageImpl for Golang { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> anyhow::Result { let progress = reporter.on_install_start(&hook); // 1. Install Go let go_dir = store.tools_path(ToolBucket::Go); let installer = GoInstaller::new(go_dir); let (version, allows_download) = match &hook.language_request { LanguageRequest::Any { system_only } => (&GoRequest::Any, !system_only), LanguageRequest::Golang(version) => (version, true), _ => unreachable!(), }; let go = installer .install(store, version, allows_download) .await .context("Failed to install go")?; let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; info.with_toolchain(go.bin().to_path_buf()) .with_language_version(go.version().deref().clone()); // 2. Create environment fs_err::tokio::create_dir_all(bin_dir(&info.env_path)).await?; // 3. Install dependencies // go: ~/.cache/prek/tools/go/1.24.0/bin/go // go_root: ~/.cache/prek/tools/go/1.24.0 // go_cache: ~/.cache/prek/cache/go // go_bin: ~/.cache/prek/hooks/envs//bin let go_root = go .bin() .parent() .and_then(|p| p.parent()) .expect("Go root should exist"); let go_cache = store.cache_path(CacheBucket::Go); let go_install_cmd = || { if go.is_from_system() { let mut cmd = go.cmd("go install"); cmd.arg("install") .env(EnvVars::GOTOOLCHAIN, "local") .env(EnvVars::GOBIN, bin_dir(&info.env_path)); cmd } else { let mut cmd = go.cmd("go install"); cmd.arg("install") .env(EnvVars::GOTOOLCHAIN, "local") .env(EnvVars::GOROOT, go_root) .env(EnvVars::GOBIN, bin_dir(&info.env_path)) .env(EnvVars::GOFLAGS, "-modcacherw") .env(EnvVars::GOPATH, &go_cache); cmd } }; // GOPATH used to store downloaded source code (in $GOPATH/pkg/mod) if let Some(repo) = hook.repo_path() { go_install_cmd() .arg("./...") .current_dir(repo) .remove_git_envs() .check(true) .output() .await?; } for dep in &hook.additional_dependencies { let mut cmd = go_install_cmd(); if let Some(repo) = hook.repo_path() { cmd.current_dir(repo); } cmd.arg(dep).remove_git_envs().check(true).output().await?; } info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, _info: &InstallInfo) -> anyhow::Result<()> { Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], store: &Store, reporter: &HookRunReporter, ) -> anyhow::Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let env_dir = hook.env_path().expect("Node hook must have env path"); let go_bin = bin_dir(env_dir); let go_tools = store.tools_path(ToolBucket::Go); let go_root_bin = hook.toolchain_dir().expect("Go root should exist"); let go_root = go_root_bin.parent().expect("Go root should exist"); let go_cache = store.cache_path(CacheBucket::Go); // Only set GOROOT and GOPATH if using the Go installed by prek let go_envs = if go_root_bin.starts_with(go_tools) { vec![(EnvVars::GOROOT, go_root), (EnvVars::GOPATH, &go_cache)] } else { vec![] }; let new_path = prepend_paths(&[&go_bin, go_root_bin]).context("Failed to join PATH")?; let entry = hook.entry.resolve(Some(&new_path))?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "go hook") .current_dir(hook.work_dir()) .args(&entry[1..]) .env(EnvVars::PATH, &new_path) .env(EnvVars::GOTOOLCHAIN, "local") .env(EnvVars::GOBIN, &go_bin) .env(EnvVars::GOFLAGS, "-modcacherw") .envs(go_envs.iter().copied()) .envs(&hook.env) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } pub(crate) fn bin_dir(env_path: &Path) -> PathBuf { env_path.join("bin") } ================================================ FILE: crates/prek/src/languages/golang/gomod.rs ================================================ use std::io; use std::path::Path; use anyhow::Result; use tracing::trace; use crate::config::Language; use crate::hook::Hook; use crate::languages::version::LanguageRequest; fn parse_go_mod_directives(contents: &str) -> (Option, Option) { let mut go_version: Option = None; let mut toolchain: Option = None; for line in contents.lines() { let mut line = line.trim(); if line.is_empty() { continue; } // Strip `//` comments. if let Some((before, _)) = line.split_once("//") { line = before.trim(); if line.is_empty() { continue; } } let mut tokens = line.split_whitespace(); let Some(directive) = tokens.next() else { continue; }; let value = tokens.next(); // `go 1.22.0` if go_version.is_none() && directive == "go" { if let Some(version) = value { go_version = Some(version.to_string()); } continue; } // `toolchain go1.22.1` if toolchain.is_none() && directive == "toolchain" { if let Some(version) = value { // `toolchain` in go.mod does not accept `default`. if version != "default" { toolchain = Some(version.to_string()); } } } } (go_version, toolchain) } fn normalize_go_semver_min(version: &str) -> String { // `go.mod` commonly uses `1.23` (no patch). The semver range parser is happier when // we provide a full `MAJOR.MINOR.PATCH` minimum. let mut parts = version.split('.').collect::>(); if parts.is_empty() { return version.to_string(); } // If any part isn't a pure integer (e.g., `1.23rc1`), keep it as-is. // TODO: support pre-release versions properly. if parts.iter().any(|p| p.parse::().is_err()) { return version.to_string(); } match parts.len() { 1 => { parts.push("0"); parts.push("0"); } 2 => { parts.push("0"); } _ => {} } parts.join(".") } fn choose_language_version_from_go_mod(contents: &str) -> Option { let (go_version, toolchain) = parse_go_mod_directives(contents); // Prefer `go` to maximize cache reuse: it's typically stable across patch updates. let go_version = go_version.or(toolchain)?; let stripped = go_version.strip_prefix("go").unwrap_or(&go_version); let normalized = normalize_go_semver_min(stripped); Some(format!(">= {normalized}")) } async fn extract_go_mod_language_request(repo_path: &Path) -> Result> { let go_mod = repo_path.join("go.mod"); let contents = match fs_err::tokio::read(&go_mod).await { Ok(bytes) => bytes, Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), Err(err) => return Err(err.into()), }; let contents = str::from_utf8(&contents)?; Ok(choose_language_version_from_go_mod(contents)) } pub(crate) async fn extract_go_mod_metadata(hook: &mut Hook) -> Result<()> { // Respect an explicitly configured `language_version`. if !hook.language_request.is_any() { trace!(hook = %hook, "Skipping go.mod metadata extraction because language_version is already configured"); return Ok(()); } let Some(repo_path) = hook.repo_path() else { return Ok(()); }; let Some(req_str) = extract_go_mod_language_request(repo_path).await? else { trace!(hook = %hook, "No go or toolchain directive found in go.mod"); return Ok(()); }; let req = match LanguageRequest::parse(Language::Golang, &req_str) { Ok(req) => req, Err(err) => { trace!(%req_str, error = %err, "Ignoring invalid go.mod-derived language_version"); return Ok(()); } }; trace!(hook = %hook, version = %req_str, "Using go.mod-derived language_version"); hook.language_request = req; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn go_line_is_used_when_only_go_present() { let contents = r"module example.com/foo go 1.22.0 "; assert_eq!( choose_language_version_from_go_mod(contents).as_deref(), Some(">= 1.22.0") ); } #[test] fn go_is_preferred_over_toolchain() { let contents = r"module example.com/foo go 1.22.0 toolchain go1.22.3 "; assert_eq!( choose_language_version_from_go_mod(contents).as_deref(), Some(">= 1.22.0") ); } #[test] fn invalid_toolchain_value_is_ignored() { let contents = r"module example.com/foo toolchain default "; assert_eq!( choose_language_version_from_go_mod(contents).as_deref(), None ); } #[test] fn comments_and_whitespace_are_ignored() { let contents = "// header // go 1.22 go 1.20.4 // ignored // trailing "; assert_eq!( choose_language_version_from_go_mod(contents).as_deref(), Some(">= 1.20.4") ); } #[test] fn toolchain_is_used_when_no_go_present() { let contents = r"module example.com/foo toolchain go1.23.10 "; assert_eq!( choose_language_version_from_go_mod(contents).as_deref(), Some(">= 1.23.10") ); } #[test] fn go_minor_is_normalized_to_patch() { let contents = r"module example.com/foo go 1.23 "; assert_eq!( choose_language_version_from_go_mod(contents).as_deref(), Some(">= 1.23.0") ); } #[tokio::test] async fn extract_language_request_from_repo_go_line() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; fs_err::tokio::write( dir.path().join("go.mod"), "module example.com/foo\n\ngo 1.22\n", ) .await?; let Some(req) = extract_go_mod_language_request(dir.path()).await? else { anyhow::bail!("Expected a language request"); }; assert_eq!(req, ">= 1.22.0"); Ok(()) } #[tokio::test] async fn extract_language_request_from_repo_toolchain_when_no_go() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; fs_err::tokio::write( dir.path().join("go.mod"), "module example.com/foo\n\ntoolchain go1.23.10\n", ) .await?; let Some(req) = extract_go_mod_language_request(dir.path()).await? else { anyhow::bail!("Expected a language request"); }; assert_eq!(req, ">= 1.23.10"); Ok(()) } #[tokio::test] async fn extract_language_request_ignores_invalid_toolchain_value() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; fs_err::tokio::write( dir.path().join("go.mod"), "module example.com/foo\n\ntoolchain default\n", ) .await?; let req = extract_go_mod_language_request(dir.path()).await?; assert!(req.is_none()); Ok(()) } #[tokio::test] async fn extract_language_request_missing_go_mod_is_none() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; let req = extract_go_mod_language_request(dir.path()).await?; assert!(req.is_none()); Ok(()) } } ================================================ FILE: crates/prek/src/languages/golang/installer.rs ================================================ use std::env::consts::EXE_EXTENSION; use std::fmt::Display; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::LazyLock; use anyhow::{Context, Result}; use itertools::Itertools; use prek_consts::env_vars::EnvVars; use target_lexicon::{Architecture, HOST, OperatingSystem}; use tracing::{debug, trace, warn}; use crate::fs::LockedFile; use crate::git; use crate::http::download_and_extract; use crate::languages::golang::GoRequest; use crate::languages::golang::golang::bin_dir; use crate::languages::golang::version::GoVersion; use crate::process::Cmd; use crate::store::Store; pub(crate) struct GoResult { path: PathBuf, version: GoVersion, from_system: bool, } impl Display for GoResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}@{}", self.path.display(), self.version)?; Ok(()) } } /// Override the Go binary name for testing. static GO_BINARY_NAME: LazyLock = LazyLock::new(|| { if let Ok(name) = EnvVars::var(EnvVars::PREK_INTERNAL__GO_BINARY_NAME) { name } else { "go".to_string() } }); impl GoResult { fn from_executable(path: PathBuf, from_system: bool) -> Self { Self { path, from_system, version: GoVersion::default(), } } pub(crate) fn from_dir(dir: &Path, from_system: bool) -> Self { let go = bin_dir(dir).join("go").with_extension(EXE_EXTENSION); Self::from_executable(go, from_system) } pub(crate) fn bin(&self) -> &Path { &self.path } pub(crate) fn version(&self) -> &GoVersion { &self.version } pub(crate) fn is_from_system(&self) -> bool { self.from_system } pub(crate) fn cmd(&self, summary: &str) -> Cmd { Cmd::new(&self.path, summary) } pub(crate) fn with_version(mut self, version: GoVersion) -> Self { self.version = version; self } pub(crate) async fn fill_version(mut self) -> Result { let output = self .cmd("go version") .arg("version") .env(EnvVars::GOTOOLCHAIN, "local") .check(true) .output() .await?; // e.g. "go version go1.24.5 darwin/arm64" let version_str = String::from_utf8(output.stdout)?; let version_str = version_str .split_ascii_whitespace() .nth(2) .with_context(|| format!("Failed to parse Go version from output: {version_str}"))?; let version = GoVersion::from_str(version_str)?; self.version = version; Ok(self) } } pub(crate) struct GoInstaller { root: PathBuf, } impl GoInstaller { pub(crate) fn new(root: PathBuf) -> Self { Self { root } } pub(crate) async fn install( &self, store: &Store, request: &GoRequest, allows_download: bool, ) -> Result { fs_err::tokio::create_dir_all(&self.root).await?; let _lock = LockedFile::acquire(self.root.join(".lock"), "go").await?; if let Ok(go) = self.find_installed(request) { trace!(%go, "Found installed go"); return Ok(go); } if let Some(go) = self.find_system_go(request).await? { trace!(%go, "Using system go"); return Ok(go); } if !allows_download { anyhow::bail!("No suitable system Go version found and downloads are disabled"); } let resolved_version = self .resolve_version(request) .await .with_context(|| format!("Failed to resolve go version `{request}`"))?; trace!(version = %resolved_version, "Installing go"); self.download(store, &resolved_version).await } fn find_installed(&self, request: &GoRequest) -> Result { let mut installed = fs_err::read_dir(&self.root) .ok() .into_iter() .flatten() .filter_map(|entry| match entry { Ok(entry) => Some(entry), Err(e) => { warn!(?e, "Failed to read entry"); None } }) .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir())) .filter_map(|entry| { let dir_name = entry.file_name(); let version = GoVersion::from_str(&dir_name.to_string_lossy()).ok()?; Some((version, entry.path())) }) .sorted_unstable_by(|(a, _), (b, _)| a.cmp(b)) .rev(); installed .find_map(|(version, path)| { if request.matches(&version, Some(&path)) { trace!(%version, "Found matching installed go"); Some(GoResult::from_dir(&path, false).with_version(version)) } else { trace!(%version, "Installed go does not match request"); None } }) .context("No installed go version matches the request") } async fn resolve_version(&self, req: &GoRequest) -> Result { let output = git::git_cmd("list go tags")? .arg("ls-remote") .arg("--tags") .arg("https://github.com/golang/go") .output() .await? .stdout; let output_str = str::from_utf8(&output)?; let versions: Vec = output_str .lines() .filter_map(|line| { let tag = line.split('\t').nth(1)?; let tag = tag.strip_prefix("refs/tags/go")?; GoVersion::from_str(tag).ok() }) .sorted_unstable_by(|a, b| b.cmp(a)) .collect(); let version = versions .into_iter() .find(|version| req.matches(version, None)) .with_context(|| format!("Version `{req}` not found on remote"))?; Ok(version) } async fn download(&self, store: &Store, version: &GoVersion) -> Result { let arch = match HOST.architecture { Architecture::X86_32(_) => "386", Architecture::X86_64 => "amd64", Architecture::Aarch64(_) => "arm64", Architecture::S390x => "s390x", Architecture::Powerpc => "ppc64", Architecture::Powerpc64le => "ppc64le", _ => anyhow::bail!("Unsupported architecture"), }; let os = match HOST.operating_system { OperatingSystem::Darwin(_) => "darwin", OperatingSystem::Linux => "linux", OperatingSystem::Windows => "windows", OperatingSystem::Aix => "aix", OperatingSystem::Netbsd => "netbsd", OperatingSystem::Openbsd => "openbsd", OperatingSystem::Solaris => "solaris", OperatingSystem::Dragonfly => "dragonfly", OperatingSystem::Illumos => "illumos", _ => anyhow::bail!("Unsupported OS"), }; let ext = if cfg!(windows) { "zip" } else { "tar.gz" }; let filename = format!("go{version}.{os}-{arch}.{ext}"); let url = format!("https://go.dev/dl/{filename}"); let target = self.root.join(version.to_string()); download_and_extract(&url, &filename, store, async |extracted| { if target.exists() { debug!(target = %target.display(), "Removing existing go"); fs_err::tokio::remove_dir_all(&target).await?; } debug!(?extracted, target = %target.display(), "Moving go to target"); // TODO: retry on Windows fs_err::tokio::rename(extracted, &target).await?; anyhow::Ok(()) }) .await .context("Failed to download and extract go")?; Ok(GoResult::from_dir(&target, false).with_version(version.clone())) } async fn find_system_go(&self, go_request: &GoRequest) -> Result> { let go_paths = match which::which_all(&*GO_BINARY_NAME) { Ok(paths) => paths, Err(e) => { debug!("No go executables found in PATH: {}", e); return Ok(None); } }; for go_path in go_paths { match GoResult::from_executable(go_path, true) .fill_version() .await { Ok(go) => { // Check if this version matches the request if go_request.matches(&go.version, Some(&go.path)) { trace!( %go, "Found matching system go" ); return Ok(Some(go)); } trace!( %go, "System go does not match requested version" ); } Err(e) => { warn!(?e, "Failed to get version for system go"); } } } debug!(?go_request, "No system go matches the requested version"); Ok(None) } } #[cfg(all(test, unix))] mod tests { use std::os::unix::fs::PermissionsExt; use super::*; #[tokio::test] async fn fill_version_uses_local_gotoolchain() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; let fake_go = temp_dir.path().join("go"); fs_err::write( &fake_go, indoc::indoc! {r#"#!/bin/sh if [ "$1" = "version" ]; then if [ "${GOTOOLCHAIN:-}" = "local" ]; then printf 'go version go1.24.13 linux/amd64\n' else printf 'go version go1.26.0 linux/amd64\n' fi exit 0 fi printf 'unexpected args: %s\n' "$*" >&2 exit 1 "#}, )?; let mut permissions = fs_err::metadata(&fake_go)?.permissions(); permissions.set_mode(0o755); fs_err::set_permissions(&fake_go, permissions)?; let go = GoResult::from_executable(fake_go, true) .fill_version() .await?; assert_eq!(go.version().to_string(), "1.24.13"); Ok(()) } } ================================================ FILE: crates/prek/src/languages/golang/mod.rs ================================================ #[allow(clippy::module_inception)] mod golang; mod gomod; mod installer; mod version; pub(crate) use golang::Golang; pub(crate) use gomod::extract_go_mod_metadata; pub(crate) use version::GoRequest; ================================================ FILE: crates/prek/src/languages/golang/version.rs ================================================ use std::fmt::Display; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::str::FromStr; use serde::Deserialize; use crate::hook::InstallInfo; use crate::languages::version::{Error, try_into_u64_slice}; #[derive(Debug, Clone, Deserialize)] pub(crate) struct GoVersion(semver::Version); impl Default for GoVersion { fn default() -> Self { GoVersion(semver::Version::new(0, 0, 0)) } } impl Deref for GoVersion { type Target = semver::Version; fn deref(&self) -> &Self::Target { &self.0 } } impl Display for GoVersion { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } impl FromStr for GoVersion { type Err = semver::Error; // TODO: go1.20.0b1, go1.20.0rc1? fn from_str(s: &str) -> Result { let s = s.strip_prefix("go").unwrap_or(s).trim(); semver::Version::parse(s).map(GoVersion) } } /// `language_version` field of golang can be one of the following: /// `default` /// `system` /// `go` /// `go1.20` or `1.20` /// `go1.20.3` or `1.20.3` /// `go1.20.0b1` or `1.20.0b1` /// `go1.20rc1` or `1.20rc1` /// `go1.18beta1` or `1.18beta1` /// `>= 1.20, < 1.22` /// `local/path/to/go` #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) enum GoRequest { Any, Major(u64), MajorMinor(u64, u64), MajorMinorPatch(u64, u64, u64), Path(PathBuf), Range(semver::VersionReq, String), // TODO: support prerelease versions like `go1.20.0b1`, `go1.20rc1` // MajorMinorPrerelease(u64, u64, String), } impl Display for GoRequest { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { GoRequest::Any => write!(f, "any"), GoRequest::Major(major) => write!(f, "go{major}"), GoRequest::MajorMinor(major, minor) => write!(f, "go{major}.{minor}"), GoRequest::MajorMinorPatch(major, minor, patch) => { write!(f, "go{major}.{minor}.{patch}") } GoRequest::Path(path) => write!(f, "path: {}", path.display()), GoRequest::Range(_, raw) => write!(f, "{raw}"), } } } impl FromStr for GoRequest { type Err = Error; fn from_str(s: &str) -> Result { if s.is_empty() { return Ok(GoRequest::Any); } // Check if it starts with "go" - parse as specific version if let Some(version_part) = s.strip_prefix("go") { if version_part.is_empty() { return Ok(GoRequest::Any); } return Self::parse_version_numbers(version_part, s); } Self::parse_version_numbers(s, s) .or_else(|_| { semver::VersionReq::parse(s) .map(|version_req| GoRequest::Range(version_req, s.into())) .map_err(|_| Error::InvalidVersion(s.to_string())) }) .or_else(|_| { let path = PathBuf::from(s); if path.exists() { Ok(GoRequest::Path(path)) } else { // TODO: better error message Err(Error::InvalidVersion(s.to_string())) } }) } } impl GoRequest { pub(crate) fn is_any(&self) -> bool { matches!(self, GoRequest::Any) } fn parse_version_numbers( version_str: &str, original_request: &str, ) -> Result { let parts = try_into_u64_slice(version_str) .map_err(|_| Error::InvalidVersion(original_request.to_string()))?; match parts.as_slice() { [major] => Ok(GoRequest::Major(*major)), [major, minor] => Ok(GoRequest::MajorMinor(*major, *minor)), [major, minor, patch] => Ok(GoRequest::MajorMinorPatch(*major, *minor, *patch)), _ => Err(Error::InvalidVersion(original_request.to_string())), } } pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { let version = &install_info.language_version; self.matches( &GoVersion(version.clone()), Some(install_info.toolchain.as_ref()), ) } pub(crate) fn matches(&self, version: &GoVersion, toolchain: Option<&Path>) -> bool { match self { GoRequest::Any => true, GoRequest::Major(major) => version.0.major == *major, GoRequest::MajorMinor(major, minor) => { version.0.major == *major && version.0.minor == *minor } GoRequest::MajorMinorPatch(major, minor, patch) => { version.0.major == *major && version.0.minor == *minor && version.0.patch == *patch } // FIXME: consider resolving symlinks and normalizing paths before comparison GoRequest::Path(path) => toolchain.is_some_and(|t| t == path), GoRequest::Range(req, _) => req.matches(&version.0), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_go_request_from_str() { let cases = vec![ ("", GoRequest::Any), ("go", GoRequest::Any), ("go1", GoRequest::Major(1)), ("1", GoRequest::Major(1)), ("go1.20", GoRequest::MajorMinor(1, 20)), ("1.20", GoRequest::MajorMinor(1, 20)), ("go1.20.3", GoRequest::MajorMinorPatch(1, 20, 3)), ("1.20.3", GoRequest::MajorMinorPatch(1, 20, 3)), ( ">= 1.20, < 1.22", GoRequest::Range( semver::VersionReq::parse(">= 1.20, < 1.22").unwrap(), ">= 1.20, < 1.22".into(), ), ), ]; for (input, expected) in cases { let req = GoRequest::from_str(input).unwrap(); assert_eq!(req, expected, "Input: {input}"); } } #[test] fn test_go_request_invalid() { let invalid_cases = vec!["go1.20.3.4", "go1.beta", "invalid_version"]; for input in invalid_cases { let req = GoRequest::from_str(input); assert!(req.is_err(), "Input: {input}"); } } #[test] fn test_go_request_matches() { let version = GoVersion(semver::Version::new(1, 20, 3)); let cases = vec![ (GoRequest::Any, true), (GoRequest::Major(1), true), (GoRequest::Major(2), false), (GoRequest::MajorMinor(1, 20), true), (GoRequest::MajorMinor(1, 21), false), (GoRequest::MajorMinorPatch(1, 20, 3), true), (GoRequest::MajorMinorPatch(1, 20, 4), false), ( GoRequest::Range( semver::VersionReq::parse(">= 1.19, < 1.21").unwrap(), ">= 1.19, < 1.21".into(), ), true, ), ( GoRequest::Range( semver::VersionReq::parse(">= 1.21").unwrap(), ">= 1.21".into(), ), false, ), ]; for (req, expected) in cases { let result = req.matches(&version, None); assert_eq!(result, expected, "Request: {req}"); } } #[test] fn test_go_request_display() { let cases = vec![ (GoRequest::Any, "any"), (GoRequest::Major(1), "go1"), (GoRequest::MajorMinor(1, 20), "go1.20"), (GoRequest::MajorMinorPatch(1, 20, 3), "go1.20.3"), ( GoRequest::Range( semver::VersionReq::parse(">= 1.20, < 1.22").unwrap(), ">= 1.20, < 1.22".into(), ), ">= 1.20, < 1.22", ), ]; for (req, expected) in cases { let req_str = req.to_string(); assert_eq!(req_str, expected, "Request: {req:?}"); } } } ================================================ FILE: crates/prek/src/languages/haskell.rs ================================================ use std::path::Path; use std::process::Stdio; use std::sync::{Arc, LazyLock}; use anyhow::{Context, Result}; use mea::once::OnceCell; use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::Store; static CABAL_UPDATE_ONCE: OnceCell<()> = OnceCell::new(); static SKIP_CABAL_UPDATE: LazyLock = LazyLock::new(|| EnvVars::var(EnvVars::PREK_INTERNAL__SKIP_CABAL_UPDATE).is_ok()); #[derive(Debug, Copy, Clone)] pub(crate) struct Haskell; impl LanguageImpl for Haskell { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result { let progress = reporter.on_install_start(&hook); let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; debug!(%hook, target = %info.env_path.display(), "Installing Haskell environment"); let bin_dir = info.env_path.join("bin"); fs_err::tokio::create_dir_all(&bin_dir).await?; // Identify packages: *.cabal files in repo + additional_dependencies let search_path = hook.repo_path().unwrap_or(hook.project().path()); let pkgs = fs_err::read_dir(search_path)? .flatten() .filter_map(|entry| { let path = entry.path(); if path.is_file() && path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("cabal")) { path.file_name() .map(|name| name.to_string_lossy().to_string()) } else { None } }) .chain(hook.additional_dependencies.iter().cloned()) .collect::>(); if pkgs.is_empty() { anyhow::bail!("Expected .cabal files or additional_dependencies"); } // Run `cabal update` unless explicitly skipped via PREK_INTERNAL__SKIP_CABAL_UPDATE (e.g., in CI) if !*SKIP_CABAL_UPDATE { // `cabal update` is slow, so only run it once per process. CABAL_UPDATE_ONCE .get_or_try_init(async || { Cmd::new("cabal", "update cabal package database") .arg("update") .check(true) .output() .await .context("Failed to run `cabal update`") .map(|_| ()) }) .await?; } // cabal v2-install --installdir (default install-method is copy) Cmd::new("cabal", "install haskell dependencies") .current_dir(search_path) .arg("v2-install") .arg("--installdir") .arg(&bin_dir) .args(pkgs) .check(true) .output() .await .context("Failed to install haskell dependencies")?; info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, _info: &InstallInfo) -> Result<()> { Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let env_dir = hook.env_path().expect("Haskell must have env path"); let bin_dir = env_dir.join("bin"); let new_path = prepend_paths(&[&bin_dir]).context("Failed to join PATH")?; let entry = hook.entry.resolve(Some(&new_path))?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "run haskell hook") .current_dir(hook.work_dir()) .args(&entry[1..]) .env(EnvVars::PATH, &new_path) .envs(&hook.env) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } ================================================ FILE: crates/prek/src/languages/julia.rs ================================================ use std::path::Path; use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] pub(crate) struct Julia; impl LanguageImpl for Julia { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result { let progress = reporter.on_install_start(&hook); let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; debug!(%hook, target = %info.env_path.display(), "Installing Julia environment"); fs_err::tokio::create_dir_all(&info.env_path).await?; let search_path = hook.repo_path().unwrap_or_else(|| hook.work_dir()); let find_src = |names: &[&str]| { names .iter() .map(|n| search_path.join(n)) .find(|p| p.exists()) }; // Copy Project.toml if exists let project_dest = info.env_path.join("Project.toml"); if let Some(src) = find_src(&["JuliaProject.toml", "Project.toml"]) { fs_err::tokio::copy(src, project_dest).await?; } else { // Create an empty file to ensure this is a Julia project fs_err::tokio::File::create(project_dest).await?; } // Copy Manifest.toml (lock) if exists if let Some(src) = find_src(&["JuliaManifest.toml", "Manifest.toml"]) { fs_err::tokio::copy(src, info.env_path.join("Manifest.toml")).await?; } let julia_code = indoc::indoc! {r" using Pkg Pkg.instantiate() if !isempty(ARGS) Pkg.add(ARGS) end "}; Cmd::new("julia", "instantiate julia environment") .current_dir(search_path) .arg("--startup-file=no") .arg(format!("--project={}", info.env_path.display())) .arg("-e") .arg(julia_code) .arg("--") .args(&hook.additional_dependencies) .check(true) .output() .await .context("Failed to instantiate Julia environment")?; info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, _info: &InstallInfo) -> Result<()> { Cmd::new("julia", "check julia version") .arg("--version") .check(true) .output() .await .context("Julia is not available")?; Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let env_dir = hook.env_path().expect("Julia must have env path"); let mut entry = hook.entry.split()?; if let Some(repo_path) = hook.repo_path() { let jl_path = repo_path.join(&entry[0]); if jl_path.exists() { entry[0] = jl_path.to_string_lossy().to_string(); } } let run = async |batch: &[&Path]| { let mut output = Cmd::new("julia", "run julia hook") .current_dir(hook.work_dir()) .arg("--startup-file=no") .arg(format!("--project={}", env_dir.display())) .args(&entry) .envs(&hook.env) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } ================================================ FILE: crates/prek/src/languages/lua.rs ================================================ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use semver::Version; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] pub(crate) struct Lua; pub(crate) struct LuaInfo { pub(crate) version: Version, pub(crate) executable: std::path::PathBuf, } pub(crate) async fn query_lua_info() -> Result { let stdout = Cmd::new("lua", "get lua version") .arg("-v") .check(true) .output() .await? .stdout; // Lua 5.4.8 Copyright (C) 1994-2025 Lua.org, PUC-Rio let version = String::from_utf8_lossy(&stdout) .split_whitespace() .nth(1) .context("Failed to get Lua version")? .parse::() .context("Failed to parse Lua version")?; let stdout = Cmd::new("luarocks", "get lua executable") .arg("config") .arg("variables.LUA") .check(true) .output() .await? .stdout; let executable = PathBuf::from(String::from_utf8_lossy(&stdout).trim()); Ok(LuaInfo { version, executable, }) } impl LanguageImpl for Lua { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result { let progress = reporter.on_install_start(&hook); let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; debug!(%hook, target = %info.env_path.display(), "Installing Lua environment"); // Check lua and luarocks are installed. let lua_info = query_lua_info().await.context("Failed to query Lua info")?; // Install dependencies for the remote repository. if let Some(repo_path) = hook.repo_path() { if let Some(rockspec) = Self::get_rockspec_file(repo_path) { Self::install_rockspec(&info.env_path, repo_path, &rockspec).await?; } } // Install additional dependencies. for dep in &hook.additional_dependencies { Self::install_dependency(&info.env_path, dep).await?; } info.with_toolchain(lua_info.executable) .with_language_version(lua_info.version); info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, info: &InstallInfo) -> Result<()> { let current_lua_info = query_lua_info() .await .context("Failed to query current Lua info")?; if current_lua_info.version != info.language_version { anyhow::bail!( "Lua version mismatch: expected `{}`, found `{}`", info.language_version, current_lua_info.version ); } if current_lua_info.executable != info.toolchain { anyhow::bail!( "Lua executable mismatch: expected `{}`, found `{}`", info.toolchain.display(), current_lua_info.executable.display() ); } Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let env_dir = hook.env_path().expect("Lua must have env path"); let new_path = prepend_paths(&[&env_dir.join("bin")]).context("Failed to join PATH")?; let entry = hook.entry.resolve(Some(&new_path))?; let version = &hook .install_info() .expect("Lua must have install info") .language_version; // version without patch, e.g. 5.4 let version = format!("{}.{}", version.major, version.minor); let lua_path = Lua::get_lua_path(env_dir, &version); let lua_cpath = Lua::get_lua_cpath(env_dir, &version); let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "run lua command") .current_dir(hook.work_dir()) .args(&entry[1..]) .env(EnvVars::PATH, &new_path) .env(EnvVars::LUA_PATH, &lua_path) .env(EnvVars::LUA_CPATH, &lua_cpath) .envs(&hook.env) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } impl Lua { async fn install_rockspec(env_path: &Path, root_path: &Path, rockspec: &Path) -> Result<()> { Cmd::new("luarocks", "luarocks make rockspec") .current_dir(root_path) .arg("--tree") .arg(env_path) .arg("make") .arg(rockspec) .check(true) .output() .await .context("Failed to install dependency with rockspec")?; Ok(()) } async fn install_dependency(env_path: &Path, dependency: &str) -> Result<()> { Cmd::new("luarocks", "luarocks install dependency") .arg("--tree") .arg(env_path) .arg("install") .arg(dependency) .check(true) .output() .await .context("Failed to install Lua dependency")?; Ok(()) } fn get_rockspec_file(root_path: &Path) -> Option { if let Ok(entries) = std::fs::read_dir(root_path) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) == Some("rockspec") { return Some(path); } } } None } fn get_lua_path(env_dir: &Path, version: &str) -> String { let share_dir = env_dir.join("share"); format!( "{};{};;", share_dir.join("lua").join(version).join("?.lua").display(), share_dir .join("lua") .join(version) .join("?") .join("init.lua") .display() ) } fn get_lua_cpath(env_dir: &Path, version: &str) -> String { let lib_dir = env_dir.join("lib"); let so_ext = if cfg!(windows) { "dll" } else { "so" }; format!( "{};;", lib_dir .join("lua") .join(version) .join(format!("?.{so_ext}")) .display() ) } } ================================================ FILE: crates/prek/src/languages/mod.rs ================================================ use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; use anyhow::Result; use prek_consts::env_vars::EnvVars; use prek_identify::parse_shebang; use tracing::{instrument, trace}; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::config::Language; use crate::fs::CWD; use crate::hook::{Hook, InstallInfo, InstalledHook, Repo}; use crate::hooks; use crate::store::{CacheBucket, Store, ToolBucket}; mod bun; mod deno; mod docker; mod docker_image; mod fail; mod golang; mod haskell; mod julia; mod lua; mod node; mod pygrep; mod python; mod ruby; mod rust; mod script; mod swift; mod system; pub mod version; static BUN: bun::Bun = bun::Bun; static DENO: deno::Deno = deno::Deno; static DOCKER: docker::Docker = docker::Docker; static DOCKER_IMAGE: docker_image::DockerImage = docker_image::DockerImage; static FAIL: fail::Fail = fail::Fail; static GOLANG: golang::Golang = golang::Golang; static HASKELL: haskell::Haskell = haskell::Haskell; static JULIA: julia::Julia = julia::Julia; static LUA: lua::Lua = lua::Lua; static NODE: node::Node = node::Node; static PYGREP: pygrep::Pygrep = pygrep::Pygrep; static PYTHON: python::Python = python::Python; static RUBY: ruby::Ruby = ruby::Ruby; static RUST: rust::Rust = rust::Rust; static SCRIPT: script::Script = script::Script; static SWIFT: swift::Swift = swift::Swift; static SYSTEM: system::System = system::System; static UNIMPLEMENTED: Unimplemented = Unimplemented; trait LanguageImpl { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result; async fn check_health(&self, info: &InstallInfo) -> Result<()>; async fn run( &self, hook: &InstalledHook, filenames: &[&Path], store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)>; } #[derive(thiserror::Error, Debug)] #[error("Language `{0}` is not implemented yet")] struct UnimplementedError(String); struct Unimplemented; impl LanguageImpl for Unimplemented { async fn install( &self, hook: Arc, _store: &Store, _reporter: &HookInstallReporter, ) -> Result { Ok(InstalledHook::NoNeedInstall(hook)) } async fn check_health(&self, _info: &InstallInfo) -> Result<()> { Ok(()) } async fn run( &self, hook: &InstalledHook, _filenames: &[&Path], _store: &Store, _reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { anyhow::bail!(UnimplementedError(format!("{}", hook.language))) } } // `pre-commit` language support: // bun: install requested version, support env, support additional deps // conda: only system version, support env, support additional deps // coursier: only system version, support env, support additional deps // dart: only system version, support env, support additional deps // docker_image: only system version, no env, no additional deps // docker: only system version, support env, no additional deps // dotnet: only system version, support env, no additional deps // fail: only system version, no env, no additional deps // golang: install requested version, support env, support additional deps // haskell: only system version, support env, support additional deps // lua: only system version, support env, support additional deps // node: install requested version, support env, support additional deps (delegated to nodeenv) // perl: only system version, support env, support additional deps // pygrep: only system version, no env, no additional deps // python: install requested version, support env, support additional deps (delegated to virtualenv) // r: only system version, support env, support additional deps // ruby: install requested version, support env, support additional deps (delegated to rbenv) // rust: install requested version, support env, support additional deps (delegated to rustup and cargo) // script: only system version, no env, no additional deps // swift: only system version, support env, no additional deps // system: only system version, no env, no additional deps impl Language { pub fn supported(lang: Language) -> bool { matches!( lang, Self::Bun | Self::Deno | Self::Docker | Self::DockerImage | Self::Fail | Self::Golang | Self::Haskell | Self::Julia | Self::Lua | Self::Node | Self::Pygrep | Self::Python | Self::Ruby | Self::Rust | Self::Script | Self::Swift | Self::System ) } pub fn supports_install_env(self) -> bool { !matches!( self, Self::DockerImage | Self::Fail | Self::Script | Self::System ) } pub fn tool_buckets(self) -> &'static [ToolBucket] { match self { Self::Bun => &[ToolBucket::Bun], Self::Deno => &[ToolBucket::Deno], Self::Golang => &[ToolBucket::Go], Self::Node => &[ToolBucket::Node], Self::Python | Self::Pygrep => &[ToolBucket::Uv, ToolBucket::Python], Self::Ruby => &[ToolBucket::Ruby], Self::Rust => &[ToolBucket::Rustup], _ => &[], } } pub fn cache_buckets(self) -> &'static [CacheBucket] { match self { Self::Deno => &[CacheBucket::Deno], Self::Golang => &[CacheBucket::Go], Self::Python | Self::Pygrep => &[CacheBucket::Uv, CacheBucket::Python], Self::Rust => &[CacheBucket::Cargo], _ => &[], } } /// Return whether the language allows specifying the version, e.g. we can install a specific /// requested language version. /// See pub fn supports_language_version(self) -> bool { matches!( self, Self::Bun | Self::Deno | Self::Golang | Self::Node | Self::Python | Self::Ruby | Self::Rust ) } /// Whether the language supports installing dependencies. /// /// For example, Python and Node.js support installing dependencies, while /// System and Fail do not. pub fn supports_dependency(self) -> bool { !matches!( self, Self::DockerImage | Self::Fail | Self::Pygrep | Self::Script | Self::System | Self::Docker | Self::Dotnet | Self::Swift ) } pub async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result { match self { Self::Bun => BUN.install(hook, store, reporter).await, Self::Deno => DENO.install(hook, store, reporter).await, Self::Docker => DOCKER.install(hook, store, reporter).await, Self::DockerImage => DOCKER_IMAGE.install(hook, store, reporter).await, Self::Fail => FAIL.install(hook, store, reporter).await, Self::Golang => GOLANG.install(hook, store, reporter).await, Self::Haskell => HASKELL.install(hook, store, reporter).await, Self::Julia => JULIA.install(hook, store, reporter).await, Self::Lua => LUA.install(hook, store, reporter).await, Self::Node => NODE.install(hook, store, reporter).await, Self::Pygrep => PYGREP.install(hook, store, reporter).await, Self::Python => PYTHON.install(hook, store, reporter).await, Self::Ruby => RUBY.install(hook, store, reporter).await, Self::Rust => RUST.install(hook, store, reporter).await, Self::Script => SCRIPT.install(hook, store, reporter).await, Self::Swift => SWIFT.install(hook, store, reporter).await, Self::System => SYSTEM.install(hook, store, reporter).await, _ => UNIMPLEMENTED.install(hook, store, reporter).await, } } pub async fn check_health(&self, info: &InstallInfo) -> Result<()> { match self { Self::Bun => BUN.check_health(info).await, Self::Deno => DENO.check_health(info).await, Self::Docker => DOCKER.check_health(info).await, Self::DockerImage => DOCKER_IMAGE.check_health(info).await, Self::Fail => FAIL.check_health(info).await, Self::Golang => GOLANG.check_health(info).await, Self::Haskell => HASKELL.check_health(info).await, Self::Julia => JULIA.check_health(info).await, Self::Lua => LUA.check_health(info).await, Self::Node => NODE.check_health(info).await, Self::Pygrep => PYGREP.check_health(info).await, Self::Python => PYTHON.check_health(info).await, Self::Ruby => RUBY.check_health(info).await, Self::Rust => RUST.check_health(info).await, Self::Script => SCRIPT.check_health(info).await, Self::Swift => SWIFT.check_health(info).await, Self::System => SYSTEM.check_health(info).await, _ => UNIMPLEMENTED.check_health(info).await, } } #[instrument(level = "trace", skip_all, fields(hook_id = %hook.id, language = %hook.language))] pub async fn run( &self, hook: &InstalledHook, filenames: &[&Path], store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { match hook.repo() { Repo::Meta { .. } => { return hooks::MetaHooks::from_str(&hook.id) .unwrap() .run(store, hook, filenames, reporter) .await; } Repo::Builtin { .. } => { return hooks::BuiltinHooks::from_str(&hook.id) .unwrap() .run(store, hook, filenames, reporter) .await; } Repo::Remote { .. } => { // Fast path for hooks implemented in Rust if hooks::check_fast_path(hook) { return hooks::run_fast_path(store, hook, filenames, reporter).await; } } Repo::Local { .. } => {} } match self { Self::Bun => BUN.run(hook, filenames, store, reporter).await, Self::Deno => DENO.run(hook, filenames, store, reporter).await, Self::Docker => DOCKER.run(hook, filenames, store, reporter).await, Self::DockerImage => DOCKER_IMAGE.run(hook, filenames, store, reporter).await, Self::Fail => FAIL.run(hook, filenames, store, reporter).await, Self::Golang => GOLANG.run(hook, filenames, store, reporter).await, Self::Haskell => HASKELL.run(hook, filenames, store, reporter).await, Self::Julia => JULIA.run(hook, filenames, store, reporter).await, Self::Lua => LUA.run(hook, filenames, store, reporter).await, Self::Node => NODE.run(hook, filenames, store, reporter).await, Self::Pygrep => PYGREP.run(hook, filenames, store, reporter).await, Self::Python => PYTHON.run(hook, filenames, store, reporter).await, Self::Ruby => RUBY.run(hook, filenames, store, reporter).await, Self::Rust => RUST.run(hook, filenames, store, reporter).await, Self::Script => SCRIPT.run(hook, filenames, store, reporter).await, Self::Swift => SWIFT.run(hook, filenames, store, reporter).await, Self::System => SYSTEM.run(hook, filenames, store, reporter).await, _ => UNIMPLEMENTED.run(hook, filenames, store, reporter).await, } } } /// Try to extract metadata from the given hook. pub(crate) async fn extract_metadata(hook: &mut Hook) -> Result<()> { match hook.language { Language::Python => python::extract_metadata(hook).await, Language::Golang => golang::extract_go_mod_metadata(hook).await, _ => Ok(()), } } /// Resolve the actual process invocation, honoring shebangs and PATH lookups. pub(crate) fn resolve_command(mut cmds: Vec, paths: Option<&OsStr>) -> Vec { let env_path = if paths.is_none() { EnvVars::var_os(EnvVars::PATH) } else { None }; let paths = paths.or(env_path.as_deref()); let candidate = &cmds[0]; let resolved_binary = match which::which_in(candidate, paths, &*CWD) { Ok(p) => p, Err(_) => PathBuf::from(candidate), }; trace!("Resolved command: {}", resolved_binary.display()); if let Ok(mut shebang_argv) = parse_shebang(&resolved_binary) { trace!("Found shebang: {:?}", shebang_argv); #[allow(unused_mut)] let mut interpreter = shebang_argv[0].as_str(); #[cfg(windows)] { let interpreter_path = Path::new(interpreter); // Git for Windows behavior: if a shebang points to a Unix-style absolute // interpreter path (e.g. `/bin/sh`) that does not exist on Windows, // fall back to PATH lookup of its basename (`sh`). if !interpreter_path.exists() // Restrict this fallback to path-like interpreter values so plain // commands (like `python`) keep their normal resolution path below. && (interpreter_path.has_root() || interpreter.contains(['/', '\\'])) // Extract basename from shebang path (`/bin/sh` -> `sh`) and resolve it. && let Some(file_name) = interpreter_path.file_name().and_then(OsStr::to_str) { interpreter = file_name; } } // Resolve the interpreter path, convert "python3" to "python3.exe" on Windows if let Ok(p) = which::which_in(interpreter, paths, &*CWD) { shebang_argv[0] = p.to_string_lossy().to_string(); trace!("Resolved interpreter: {}", shebang_argv[0]); } shebang_argv.push(resolved_binary.to_string_lossy().to_string()); shebang_argv.extend_from_slice(&cmds[1..]); shebang_argv } else { cmds[0] = resolved_binary.to_string_lossy().to_string(); cmds } } #[cfg(test)] mod tests { use std::ffi::OsString; use std::path::Path; use tempfile::tempdir; use super::resolve_command; fn write_file(path: &Path, contents: &str) { fs_err::write(path, contents).expect("write test file"); } #[cfg(unix)] fn make_executable(path: &Path) { use std::os::unix::fs::PermissionsExt; let metadata = fs_err::metadata(path).expect("stat test file"); let mut perms = metadata.permissions(); perms.set_mode(perms.mode() | 0o111); fs_err::set_permissions(path, perms).expect("set executable bit"); } #[cfg(windows)] fn make_executable(_path: &Path) {} #[test] fn resolve_command_passthrough_when_not_found() { let cmd = "__prek_nonexistent_command__".to_string(); let resolved = resolve_command(vec![cmd.clone()], None); assert_eq!(resolved, vec![cmd]); } #[test] fn resolve_command_resolves_shebang_interpreter_from_path() { let dir = tempdir().expect("create temp dir"); let script_path = dir.path().join("hook-script"); write_file( &script_path, "#!/usr/bin/env prek-test-interpreter\necho hi\n", ); #[cfg(windows)] let interpreter_path = dir.path().join("prek-test-interpreter.exe"); #[cfg(not(windows))] let interpreter_path = dir.path().join("prek-test-interpreter"); write_file(&interpreter_path, ""); make_executable(&interpreter_path); let paths = OsString::from(dir.path().as_os_str()); let resolved = resolve_command( vec![script_path.to_string_lossy().into_owned()], Some(paths.as_os_str()), ); assert_eq!(resolved[0], interpreter_path.to_string_lossy()); assert_eq!(resolved[1], script_path.to_string_lossy()); } #[cfg(windows)] #[test] fn resolve_command_windows_rewrites_bin_sh_to_path_sh() { let dir = tempdir().expect("create temp dir"); let script_path = dir.path().join("legacy-hook"); write_file(&script_path, "#!/bin/sh\necho legacy\n"); let sh_path = dir.path().join("sh.exe"); write_file(&sh_path, ""); let paths = OsString::from(dir.path().as_os_str()); let resolved = resolve_command( vec![script_path.to_string_lossy().into_owned()], Some(paths.as_os_str()), ); assert_eq!(resolved[0], sh_path.to_string_lossy()); assert_eq!(resolved[1], script_path.to_string_lossy()); } #[cfg(windows)] #[test] fn resolve_command_windows_keeps_existing_absolute_interpreter_path() { let dir = tempdir().expect("create temp dir"); let interp_dir = dir.path().join("bin"); fs_err::create_dir_all(&interp_dir).expect("create interpreter dir"); let interp_path = interp_dir.join("sh.exe"); write_file(&interp_path, ""); let shebang_interpreter = interp_path.to_string_lossy().replace('\\', "/"); let script_path = dir.path().join("legacy-hook"); write_file( &script_path, &format!("#!{shebang_interpreter}\necho legacy\n"), ); let paths = OsString::from(dir.path().as_os_str()); let resolved = resolve_command( vec![script_path.to_string_lossy().into_owned()], Some(paths.as_os_str()), ); let resolved_interp = Path::new(&resolved[0]); assert_eq!(resolved_interp, interp_path.as_path()); assert_eq!(resolved[1], script_path.to_string_lossy()); } } ================================================ FILE: crates/prek/src/languages/node/installer.rs ================================================ use std::env::consts::EXE_EXTENSION; use std::fmt::Display; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::string::ToString; use std::sync::LazyLock; use anyhow::{Context, Result}; use itertools::Itertools; use prek_consts::env_vars::EnvVars; use target_lexicon::{Architecture, HOST, OperatingSystem}; use tracing::{debug, trace, warn}; use crate::fs::LockedFile; use crate::http::{REQWEST_CLIENT, download_and_extract}; use crate::languages::node::NodeRequest; use crate::languages::node::version::NodeVersion; use crate::process::Cmd; use crate::store::Store; #[derive(Debug)] pub(crate) struct NodeResult { node: PathBuf, npm: PathBuf, version: NodeVersion, } impl Display for NodeResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}@{}", self.node.display(), self.version)?; Ok(()) } } /// Override the Node binary name for testing. static NODE_BINARY_NAME: LazyLock = LazyLock::new(|| { if let Ok(name) = EnvVars::var(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME) { name } else { "node".to_string() } }); impl NodeResult { pub(crate) fn from_executables(node: PathBuf, npm: PathBuf) -> Self { Self { node, npm, version: NodeVersion::default(), } } pub(crate) fn from_dir(dir: &Path) -> Self { let node = bin_dir(dir).join("node").with_extension(EXE_EXTENSION); let npm = bin_dir(dir) .join("npm") .with_extension(if cfg!(windows) { "cmd" } else { "" }); Self::from_executables(node, npm) } pub(crate) fn with_version(mut self, version: NodeVersion) -> Self { self.version = version; self } pub(crate) async fn fill_version(mut self) -> Result { // https://nodejs.org/api/process.html#processrelease let output = Cmd::new(&self.node, "node -p") .arg("-p") .arg("JSON.stringify({version: process.version, lts: process.release.lts || false})") .check(true) .output() .await?; let output_str = String::from_utf8_lossy(&output.stdout); let version: NodeVersion = serde_json::from_str(&output_str).context("Failed to parse node version")?; self.version = version; Ok(self) } pub(crate) fn node(&self) -> &Path { &self.node } pub(crate) fn npm(&self) -> &Path { &self.npm } pub(crate) fn version(&self) -> &NodeVersion { &self.version } } pub(crate) struct NodeInstaller { root: PathBuf, } impl NodeInstaller { pub(crate) fn new(root: PathBuf) -> Self { Self { root } } /// Install a version of Node.js. pub(crate) async fn install( &self, store: &Store, request: &NodeRequest, allows_download: bool, ) -> Result { fs_err::tokio::create_dir_all(&self.root).await?; let _lock = LockedFile::acquire(self.root.join(".lock"), "node").await?; if let Ok(node_result) = self.find_installed(request) { trace!(%node_result, "Found installed node"); return Ok(node_result); } // Find all node and npm executables in PATH and check their versions if let Some(node_result) = self.find_system_node(request).await? { trace!(%node_result, "Using system node"); return Ok(node_result); } if !allows_download { anyhow::bail!("No suitable system Node version found and downloads are disabled"); } let resolved_version = self.resolve_version(request).await?; trace!(version = %resolved_version, "Downloading node"); self.download(store, &resolved_version).await } /// Get the installed version of Node.js. fn find_installed(&self, req: &NodeRequest) -> Result { let mut installed = fs_err::read_dir(&self.root) .ok() .into_iter() .flatten() .filter_map(|entry| match entry { Ok(entry) => Some(entry), Err(err) => { warn!(?err, "Failed to read entry"); None } }) .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir())) .filter_map(|entry| { let dir_name = entry.file_name(); let version = NodeVersion::from_str(&dir_name.to_string_lossy()).ok()?; Some((version, entry.path())) }) .sorted_unstable_by(|(a, _), (b, _)| a.version.cmp(&b.version)) .rev(); installed .find_map(|(v, path)| { if req.matches(&v, Some(&path)) { Some(NodeResult::from_dir(&path).with_version(v)) } else { None } }) .context("No installed node version matches the request") } async fn resolve_version(&self, req: &NodeRequest) -> Result { // Latest versions come first, so we can find the latest matching version. let versions = self .list_remote_versions() .await .context("Failed to list remote versions")?; let version = versions .into_iter() .find(|version| req.matches(version, None)) .context("Version not found on remote")?; Ok(version) } /// List all versions of Node.js available on the Node.js website. async fn list_remote_versions(&self) -> Result> { let url = "https://nodejs.org/dist/index.json"; let versions: Vec = REQWEST_CLIENT.get(url).send().await?.json().await?; Ok(versions) } // TODO: support mirror? /// Install a specific version of Node.js. async fn download(&self, store: &Store, version: &NodeVersion) -> Result { let mut arch = match HOST.architecture { Architecture::X86_32(_) => "x86", Architecture::X86_64 => "x64", Architecture::Aarch64(_) => "arm64", Architecture::Arm(_) => "armv7l", Architecture::S390x => "s390x", Architecture::Powerpc => "ppc64", Architecture::Powerpc64le => "ppc64le", _ => anyhow::bail!("Unsupported architecture"), }; let os = match HOST.operating_system { OperatingSystem::Darwin(_) => "darwin", OperatingSystem::Linux => "linux", OperatingSystem::Windows => "win", OperatingSystem::Aix => "aix", _ => anyhow::bail!("Unsupported OS"), }; if os == "darwin" && arch == "arm64" && version.major() < 16 { // Node.js 16 and later are required for arm64 on macOS. arch = "x64"; } let ext = if cfg!(windows) { "zip" } else { "tar.xz" }; let filename = format!("node-v{}-{os}-{arch}.{ext}", version.version()); let url = format!("https://nodejs.org/dist/v{}/{filename}", version.version()); let target = self.root.join(version.to_string()); download_and_extract(&url, &filename, store, async |extracted| { if target.exists() { debug!(target = %target.display(), "Removing existing node"); fs_err::tokio::remove_dir_all(&target).await?; } debug!(?extracted, target = %target.display(), "Moving node to target"); // TODO: retry on Windows fs_err::tokio::rename(extracted, &target).await?; anyhow::Ok(()) }) .await .context("Failed to download and extract node")?; Ok(NodeResult::from_dir(&target).with_version(version.clone())) } /// Find a suitable system Node.js installation that matches the request. async fn find_system_node(&self, node_request: &NodeRequest) -> Result> { let node_paths = match which::which_all(&*NODE_BINARY_NAME) { Ok(paths) => paths, Err(e) => { debug!("No node executables found in PATH: {}", e); return Ok(None); } }; // Check each node executable for a matching version, stop early if found for node_path in node_paths { if let Some(npm_path) = Self::find_npm_in_same_directory(&node_path)? { match NodeResult::from_executables(node_path, npm_path) .fill_version() .await { Ok(node_result) => { // Check if this version matches the request if node_request.matches(&node_result.version, Some(&node_result.node)) { trace!( %node_result, "Found a matching system node" ); return Ok(Some(node_result)); } trace!( %node_result, "System node does not match requested version" ); } Err(e) => { warn!(?e, "Failed to get version for system node"); } } } else { trace!( node = %node_path.display(), "No npm found in same directory as node executable" ); } } debug!( ?node_request, "No system node matches the requested version" ); Ok(None) } /// Find npm executable in the same directory as the given node executable. fn find_npm_in_same_directory(node_path: &Path) -> Result> { let node_dir = node_path .parent() .context("Node executable has no parent directory")?; for name in ["npm", "npm.cmd", "npm.bat"] { let npm_path = node_dir.join(name); if npm_path.try_exists()? && is_executable(&npm_path) { trace!( node = %node_path.display(), npm = %npm_path.display(), "Found npm in same directory as node" ); return Ok(Some(npm_path)); } } trace!( node = %node_path.display(), "npm not found in same directory as node" ); Ok(None) } } pub(crate) fn bin_dir(prefix: &Path) -> PathBuf { if cfg!(windows) { prefix.to_path_buf() } else { prefix.join("bin") } } pub(crate) fn lib_dir(prefix: &Path) -> PathBuf { if cfg!(windows) { prefix.join("node_modules") } else { prefix.join("lib").join("node_modules") } } fn is_executable(path: &Path) -> bool { #[cfg(windows)] { path.extension() .is_some_and(|ext| ext == EXE_EXTENSION || ext == "cmd" || ext == "bat") } #[cfg(not(windows))] { use std::os::unix::fs::MetadataExt; path.is_file() && fs_err::metadata(path).is_ok_and(|m| m.mode() & 0o111 != 0) } } ================================================ FILE: crates/prek/src/languages/node/mod.rs ================================================ mod installer; #[allow(clippy::module_inception)] mod node; mod version; pub(crate) use node::Node; pub(crate) use version::NodeRequest; ================================================ FILE: crates/prek/src/languages/node/node.rs ================================================ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::InstalledHook; use crate::hook::{Hook, InstallInfo}; use crate::languages::LanguageImpl; use crate::languages::node::NodeRequest; use crate::languages::node::installer::{NodeInstaller, NodeResult, bin_dir, lib_dir}; use crate::languages::node::version::EXTRA_KEY_LTS; use crate::languages::version::LanguageRequest; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::{Store, ToolBucket}; #[derive(Debug, Copy, Clone)] pub(crate) struct Node; impl LanguageImpl for Node { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result { let progress = reporter.on_install_start(&hook); // 1. Install node // 1) Find from `$PREK_HOME/tools/node` // 2) Find from system // 3) Download from remote // 2. Create env // 3. Install dependencies // 1. Install node let node_dir = store.tools_path(ToolBucket::Node); let installer = NodeInstaller::new(node_dir); let (node_request, allows_download) = match &hook.language_request { LanguageRequest::Any { system_only } => (&NodeRequest::Any, !system_only), LanguageRequest::Node(node_request) => (node_request, true), _ => unreachable!(), }; let node = installer .install(store, node_request, allows_download) .await .context("Failed to install node")?; let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; let lts = serde_json::to_string(&node.version().lts).context("Failed to serialize LTS")?; info.with_toolchain(node.node().to_path_buf()); info.with_language_version(node.version().version.clone()); info.with_extra(EXTRA_KEY_LTS, <s); // 2. Create env let bin_dir = bin_dir(&info.env_path); let lib_dir = lib_dir(&info.env_path); fs_err::tokio::create_dir_all(&bin_dir).await?; fs_err::tokio::create_dir_all(&lib_dir).await?; // 3. Install dependencies let deps = hook.install_dependencies(); if deps.is_empty() { debug!("No dependencies to install"); } else { // npm install : // If sits inside the root of your project, its dependencies will be installed // and may be hoisted to the top-level node_modules as they would for other types of dependencies. // If sits outside the root of your project, npm will not install the package dependencies // in the directory , but it will create a symlink to . // // NOTE: If you want to install the content of a directory like a package from the registry // instead of creating a link, you would need to use the --install-links option. // `npm` is a script that uses `/usr/bin/env node`, so we need to add the // node toolchain directory to PATH so that `npm` can find `node`. let node_bin = node.node().parent().expect("Node binary must have parent"); let new_path = prepend_paths(&[&bin_dir, node_bin]).context("Failed to join PATH")?; Cmd::new(node.npm(), "npm install") .arg("install") .arg("-g") .arg("--no-progress") .arg("--no-save") .arg("--no-fund") .arg("--no-audit") .arg("--install-links") .args(&*deps) .env(EnvVars::PATH, new_path) .env(EnvVars::NPM_CONFIG_PREFIX, &info.env_path) .env_remove(EnvVars::NPM_CONFIG_USERCONFIG) .env(EnvVars::NODE_PATH, &lib_dir) .check(true) .output() .await?; } info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, info: &InstallInfo) -> Result<()> { let node = NodeResult::from_executables(info.toolchain.clone(), PathBuf::new()) .fill_version() .await .context("Failed to query node version")?; if node.version().version != info.language_version { anyhow::bail!( "Node version mismatch: expected {}, found {}", info.language_version, node.version().version ); } Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let env_dir = hook.env_path().expect("Node must have env path"); let node_bin = hook.toolchain_dir().expect("Node binary must have parent"); let new_path = prepend_paths(&[&bin_dir(env_dir), node_bin]).context("Failed to join PATH")?; let entry = hook.entry.resolve(Some(&new_path))?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "node hook") .current_dir(hook.work_dir()) .args(&entry[1..]) .env(EnvVars::PATH, &new_path) .env(EnvVars::NPM_CONFIG_PREFIX, env_dir) .env_remove(EnvVars::NPM_CONFIG_USERCONFIG) .env(EnvVars::NODE_PATH, lib_dir(env_dir)) .envs(&hook.env) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); // Collect results let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } ================================================ FILE: crates/prek/src/languages/node/version.rs ================================================ use std::fmt::Display; use std::path::{Path, PathBuf}; use std::str::FromStr; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use crate::hook::InstallInfo; use crate::languages::version::{Error, try_into_u64_slice}; #[derive(Debug, Clone)] pub(crate) enum Lts { NotLts, Codename(String), } impl Lts { pub(crate) fn code_name(&self) -> Option<&str> { match self { Self::NotLts => None, Self::Codename(name) => Some(name), } } } impl<'de> Deserialize<'de> for Lts { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let value = Value::deserialize(deserializer)?; match value { Value::String(s) => Ok(Lts::Codename(s)), Value::Bool(false) => Ok(Lts::NotLts), Value::Null => Ok(Lts::NotLts), _ => Ok(Lts::NotLts), } } } impl Serialize for Lts { fn serialize(&self, serializer: S) -> anyhow::Result where S: serde::Serializer, { match self { Lts::Codename(name) => serializer.serialize_str(name), Lts::NotLts => serializer.serialize_bool(false), } } } #[derive(Debug, Clone)] pub(crate) struct NodeVersion { pub version: semver::Version, pub lts: Lts, } impl Default for NodeVersion { fn default() -> Self { NodeVersion { version: semver::Version::new(0, 0, 0), lts: Lts::NotLts, } } } impl<'de> Deserialize<'de> for NodeVersion { fn deserialize(deserializer: D) -> anyhow::Result where D: Deserializer<'de>, { #[derive(Deserialize)] struct _Version { version: String, lts: Lts, } let raw = _Version::deserialize(deserializer)?; let version_str = raw.version.strip_prefix('v').unwrap_or(&raw.version).trim(); let version = semver::Version::parse(version_str).map_err(serde::de::Error::custom)?; Ok(NodeVersion { version, lts: raw.lts, }) } } impl Display for NodeVersion { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.version)?; if let Some(name) = self.lts.code_name() { write!(f, "-{name}")?; } Ok(()) } } impl FromStr for NodeVersion { type Err = semver::Error; fn from_str(s: &str) -> Result { // Split on the first '-' to separate version and codename let (version_part, lts) = match s.split_once('-') { Some((ver, codename)) => (ver, Lts::Codename(codename.to_string())), None => (s, Lts::NotLts), }; let version = semver::Version::parse(version_part)?; Ok(NodeVersion { version, lts }) } } impl NodeVersion { pub fn major(&self) -> u64 { self.version.major } pub fn minor(&self) -> u64 { self.version.minor } pub fn patch(&self) -> u64 { self.version.patch } pub fn version(&self) -> &semver::Version { &self.version } } /// The `language_version` field of node language, can be one of the following: /// - `default`: Find the system installed node, or download the latest version. /// - `system`: Find the system installed node, or return an error if not found. /// - `x.y.z`: Install the specific version of node. /// - `x.y`: Install the latest version of node with the same major and minor version. /// - `x`: Install the latest version of node with the same major version. /// - `^x.y.z`: Install the latest version of node that satisfies the version requirement. /// Or any other semver compatible version requirement. /// - `lts/`: Install the latest version of node with the specified code name. /// - `local/path/to/node`: Use the node executable at the specified path. #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) enum NodeRequest { Any, Major(u64), MajorMinor(u64, u64), MajorMinorPatch(u64, u64, u64), Path(PathBuf), Range(semver::VersionReq), // A bare `lts` request is interpreted as the latest LTS version. Lts, // A request like `lts/Argon` is interpreted as the LTS version with the code name "Argon". CodeName(String), } impl FromStr for NodeRequest { type Err = Error; fn from_str(request: &str) -> Result { if request.is_empty() { return Ok(Self::Any); } if let Some(version_part) = request.strip_prefix("node") { if version_part.is_empty() { return Ok(Self::Any); } Self::parse_version_numbers(version_part, request) } else if request.eq_ignore_ascii_case("lts") { Ok(NodeRequest::Lts) } else if let Some(code_name) = request.strip_prefix("lts/") { if code_name .chars() .all(|c| matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9')) { Ok(NodeRequest::CodeName(code_name.to_string())) } else { Err(Error::InvalidVersion(request.to_string())) } } else { Self::parse_version_numbers(request, request) .or_else(|_| { semver::VersionReq::parse(request) .map(NodeRequest::Range) .map_err(|_| Error::InvalidVersion(request.to_string())) }) .or_else(|_| { let path = PathBuf::from(request); if path.exists() { Ok(NodeRequest::Path(path)) } else { Err(Error::InvalidVersion(request.to_string())) } }) } } } pub(crate) const EXTRA_KEY_LTS: &str = "lts"; impl NodeRequest { pub(crate) fn is_any(&self) -> bool { matches!(self, NodeRequest::Any) } fn parse_version_numbers( version_str: &str, original_request: &str, ) -> Result { let parts = try_into_u64_slice(version_str) .map_err(|_| Error::InvalidVersion(original_request.to_string()))?; match parts.as_slice() { [major] => Ok(NodeRequest::Major(*major)), [major, minor] => Ok(NodeRequest::MajorMinor(*major, *minor)), [major, minor, patch] => Ok(NodeRequest::MajorMinorPatch(*major, *minor, *patch)), _ => Err(Error::InvalidVersion(original_request.to_string())), } } pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { let version = &install_info.language_version; let tls = install_info .get_extra(EXTRA_KEY_LTS) .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or(Lts::NotLts); self.matches( &NodeVersion { version: version.clone(), lts: tls, }, Some(install_info.toolchain.as_ref()), ) } pub(crate) fn matches(&self, version: &NodeVersion, toolchain: Option<&Path>) -> bool { match self { NodeRequest::Any => true, NodeRequest::Major(major) => version.major() == *major, NodeRequest::MajorMinor(major, minor) => { version.major() == *major && version.minor() == *minor } NodeRequest::MajorMinorPatch(major, minor, patch) => { version.major() == *major && version.minor() == *minor && version.patch() == *patch } // FIXME: consider resolving symlinks and normalizing paths before comparison NodeRequest::Path(path) => toolchain.is_some_and(|t| t == path), NodeRequest::Range(req) => req.matches(version.version()), NodeRequest::Lts => version.lts.code_name().is_some(), NodeRequest::CodeName(name) => version .lts .code_name() .is_some_and(|n| n.eq_ignore_ascii_case(name)), } } } #[cfg(test)] mod tests { use super::{EXTRA_KEY_LTS, NodeRequest}; use crate::config::Language; use crate::hook::InstallInfo; use rustc_hash::FxHashSet; use std::path::PathBuf; use std::str::FromStr; #[test] fn test_node_request_from_str() { assert_eq!(NodeRequest::from_str("node").unwrap(), NodeRequest::Any); assert_eq!( NodeRequest::from_str("node12").unwrap(), NodeRequest::Major(12) ); assert_eq!( NodeRequest::from_str("node12.18").unwrap(), NodeRequest::MajorMinor(12, 18) ); assert_eq!( NodeRequest::from_str("node12.18.3").unwrap(), NodeRequest::MajorMinorPatch(12, 18, 3) ); assert_eq!(NodeRequest::from_str("lts").unwrap(), NodeRequest::Lts); assert_eq!( NodeRequest::from_str("lts/Argon").unwrap(), NodeRequest::CodeName("Argon".to_string()) ); assert_eq!(NodeRequest::from_str("").unwrap(), NodeRequest::Any); assert_eq!(NodeRequest::from_str("12").unwrap(), NodeRequest::Major(12)); assert_eq!( NodeRequest::from_str("12.18").unwrap(), NodeRequest::MajorMinor(12, 18) ); assert_eq!( NodeRequest::from_str("12.18.3").unwrap(), NodeRequest::MajorMinorPatch(12, 18, 3) ); assert_eq!( NodeRequest::from_str(">=12.18").unwrap(), NodeRequest::Range(semver::VersionReq::parse(">=12.18").unwrap()) ); } #[test] fn test_node_request_invalid() { assert!(NodeRequest::from_str("node12.18.3.4").is_err()); assert!(NodeRequest::from_str("node12.18.3a").is_err()); assert!(NodeRequest::from_str("node12.18.x").is_err()); assert!(NodeRequest::from_str("node^12.18.3").is_err()); assert!(NodeRequest::from_str("invalid").is_err()); assert!(NodeRequest::from_str("lts/$$$").is_err()); } #[test] fn test_node_request_satisfied_by() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; let mut install_info = InstallInfo::new(Language::Node, FxHashSet::default(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(12, 18, 3)) .with_toolchain(PathBuf::from("/usr/bin/node")) .with_extra(EXTRA_KEY_LTS, "\"Argon\""); let request = NodeRequest::Major(12); assert!(request.satisfied_by(&install_info)); let request = NodeRequest::MajorMinor(12, 18); assert!(request.satisfied_by(&install_info)); let request = NodeRequest::MajorMinorPatch(12, 18, 3); assert!(request.satisfied_by(&install_info)); let request = NodeRequest::Lts; assert!(request.satisfied_by(&install_info)); let request = NodeRequest::CodeName("Argon".to_string()); assert!(request.satisfied_by(&install_info)); let request = NodeRequest::CodeName("argon".to_string()); assert!(request.satisfied_by(&install_info)); let request = NodeRequest::CodeName("Boron".to_string()); assert!(!request.satisfied_by(&install_info)); let request = NodeRequest::Path(PathBuf::from("/usr/bin/node")); assert!(request.satisfied_by(&install_info)); let request = NodeRequest::Path(PathBuf::from("/usr/bin/nodejs")); assert!(!request.satisfied_by(&install_info)); let request = NodeRequest::Range(semver::VersionReq::parse(">=12.18").unwrap()); assert!(request.satisfied_by(&install_info)); let request = NodeRequest::Range(semver::VersionReq::parse(">=13.0").unwrap()); assert!(!request.satisfied_by(&install_info)); Ok(()) } } ================================================ FILE: crates/prek/src/languages/pygrep/mod.rs ================================================ #[allow(clippy::module_inception)] mod pygrep; pub(crate) use pygrep::Pygrep; ================================================ FILE: crates/prek/src/languages/pygrep/pygrep.rs ================================================ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; use prek_consts::env_vars::EnvVars; use tokio::io::AsyncWriteExt; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::languages::python::{Uv, python_exec, query_python_info_cached}; use crate::process::Cmd; use crate::run::CONCURRENCY; use crate::store::{CacheBucket, Store, ToolBucket}; #[derive(Debug, Default)] struct Args { ignore_case: bool, multiline: bool, negate: bool, } impl Args { fn parse(args: &[String]) -> Result { let mut parsed = Args::default(); for arg in args { match arg.as_str() { "--ignore-case" | "-i" => parsed.ignore_case = true, "--multiline" => parsed.multiline = true, "--negate" => parsed.negate = true, _ => anyhow::bail!("Unknown argument: {arg}"), } } Ok(parsed) } fn to_args(&self) -> Vec<&'static str> { fn as_str(value: bool) -> &'static str { if value { "1" } else { "0" } } vec![ as_str(self.ignore_case), as_str(self.multiline), as_str(self.negate), ] } } #[derive(serde::Deserialize, thiserror::Error, Debug)] #[serde(tag = "type")] enum Error { #[error("Failed to parse regex: {message}")] Regex { message: String }, #[error("IO error: {message}")] IO { message: String }, #[error("Unknown error: {message}")] Unknown { message: String }, } // We have to implement `pygrep` in Python, because Python `re` module has many differences // from Rust `regex` crate. static SCRIPT: &str = include_str!("script.py"); const INSTALL_PYTHON_VERSION: &str = "3.13"; pub(crate) struct Pygrep; fn find_installed_python(python_dir: &Path) -> Option { fs_err::read_dir(python_dir) .ok() .into_iter() .flatten() .flatten() .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir())) // Ignore any `.` prefixed directories .filter(|path| { path.file_name() .to_str() .map(|name| !name.starts_with('.')) .unwrap_or(true) }) .map(|entry| python_exec(&entry.path())) .next() } impl LanguageImpl for Pygrep { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result { let progress = reporter.on_install_start(&hook); let uv_dir = store.tools_path(ToolBucket::Uv); let uv = Uv::install(store, &uv_dir).await?; let python_dir = store.tools_path(ToolBucket::Python); // Find or download a Python interpreter. let mut python = None; // 1) Try to find one from `prek` managed Python versions. if let Some(installed) = find_installed_python(&python_dir) { python = Some(installed); } else { // 2) If not found, try to find a system installed Python (system or system uv managed). debug!("No managed Python interpreter found, trying to find a system installed one"); let mut output = uv .cmd("uv python find", store) .arg("python") .arg("find") .arg("--python-preference") .arg("managed") .arg("--no-python-downloads") .arg("--no-config") .arg("--no-project") // `--managed_python` conflicts with `--python-preference`, ignore any user setting .env_remove(EnvVars::UV_MANAGED_PYTHON) .env_remove(EnvVars::UV_NO_MANAGED_PYTHON) .check(false) .output() .await?; if output.status.success() { python = Some(PathBuf::from( String::from_utf8_lossy(&output.stdout).trim(), )); } else { // 3) If still not found, try to download a Python interpreter. debug!("No Python interpreter found, trying to install one"); output = uv .cmd("uv python install", store) .arg("python") .arg("install") .arg(INSTALL_PYTHON_VERSION) .arg("--no-config") .arg("--no-project") .env(EnvVars::UV_PYTHON_INSTALL_DIR, &python_dir) .check(false) .output() .await?; if output.status.success() { if let Some(installed) = find_installed_python(&python_dir) { python = Some(installed); } } } } let Some(python) = python else { anyhow::bail!("Failed to find or install a Python interpreter for `pygrep`."); }; let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; info.with_toolchain(python); info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, info: &InstallInfo) -> Result<()> { query_python_info_cached(&info.toolchain) .await .context("Failed to query Python info")?; Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let info = hook.install_info().expect("Pygrep hook must be installed"); let cache = store.cache_path(CacheBucket::Python); fs_err::tokio::create_dir_all(&cache).await?; let py_script = tempfile::NamedTempFile::new_in(cache)?; fs_err::tokio::write(&py_script, SCRIPT) .await .context("Failed to write Python script")?; let args = Args::parse(&hook.args).context("Failed to parse `args`")?; let mut cmd = Cmd::new(&info.toolchain, "python script") .current_dir(hook.work_dir()) .envs(&hook.env) .arg("-I") // Isolate mode. .arg("-B") // Don't write bytecode. .arg(py_script.path()) .args(args.to_args()) .arg(CONCURRENCY.to_string()) .arg(hook.entry.raw()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .check(false) .spawn()?; let mut stdin = cmd.stdin.take().context("Failed to take stdin")?; // TODO: avoid this clone if possible. let filenames: Vec<_> = filenames.iter().map(PathBuf::from).collect(); let write_task = tokio::spawn(async move { for filename in filenames { stdin .write_all(format!("{}\n", filename.display()).as_bytes()) .await?; } let _ = stdin.shutdown().await; anyhow::Ok(()) }); let output = cmd .wait_with_output() .await .context("Failed to wait for command output")?; write_task.await.context("Failed to write stdin")??; reporter.on_run_complete(progress); if output.status.success() { // When successful, the Python script writes status code JSON to stderr // and grep results to stdout let stderr_str = String::from_utf8_lossy(&output.stderr); let code_output: serde_json::Value = serde_json::from_str(&stderr_str).with_context(|| { format!( "Failed to parse status code JSON from stderr. Stderr content: '{stderr_str}'", ) })?; let code = code_output .get("code") .and_then(serde_json::Value::as_i64) .unwrap_or(0); let code = i32::try_from(code).unwrap_or(0); Ok((code, output.stdout)) } else { // When there's an error, try to parse error JSON from stderr let stderr_str = String::from_utf8_lossy(&output.stderr); if stderr_str.trim().is_empty() { // No stderr output - create a generic error anyhow::bail!( "Python script failed with exit code {} but produced no error output", output.status.code().unwrap_or(-1) ); } // Try to parse as JSON first match serde_json::from_str::(&stderr_str) { Ok(err) => Err(err.into()), Err(_) => { // Not JSON - treat as plain text error message anyhow::bail!( "Python script failed with exit code {}: {}", output.status.code().unwrap_or(-1), stderr_str.trim() ) } } } } } ================================================ FILE: crates/prek/src/languages/pygrep/script.py ================================================ from __future__ import annotations import json import io import re import sys from concurrent.futures import ThreadPoolExecutor from queue import Queue from re import Pattern from threading import Thread def process_file( filename: str, pattern: Pattern[bytes], multiline: bool, negate: bool, queue: Queue ) -> None: try: if multiline: if negate: ret, output = _process_filename_at_once_negated(pattern, filename) else: ret, output = _process_filename_at_once(pattern, filename) else: if negate: ret, output = _process_filename_by_line_negated(pattern, filename) else: ret, output = _process_filename_by_line(pattern, filename) queue.put((ret, output)) except Exception as e: # Put error result in queue so consumer can handle it queue.put((1, f"Error processing {filename}: {e}\n".encode())) def _process_filename_by_line( pattern: Pattern[bytes], filename: str ) -> tuple[int, bytes]: retv = 0 output = io.BytesIO() with open(filename, "rb") as f: for line_no, line in enumerate(f, start=1): if pattern.search(line): retv = 1 output.write(f"{filename}:{line_no}:".encode()) output.write(line.rstrip(b"\r\n")) output.write(b"\n") return retv, output.getvalue() def _process_filename_at_once( pattern: Pattern[bytes], filename: str ) -> tuple[int, bytes]: retv = 0 output = io.BytesIO() with open(filename, "rb") as f: contents = f.read() match = pattern.search(contents) if match: retv = 1 line_no = contents[: match.start()].count(b"\n") output.write(f"{filename}:{line_no + 1}:".encode()) matched_lines = match[0].split(b"\n") matched_lines[0] = contents.split(b"\n")[line_no] output.write(b"\n".join(matched_lines)) output.write(b"\n") return retv, output.getvalue() def _process_filename_by_line_negated( pattern: Pattern[bytes], filename: str ) -> tuple[int, bytes]: with open(filename, "rb") as f: for line in f: if pattern.search(line): return 0, b"" else: return 1, filename.encode() + b"\n" def _process_filename_at_once_negated( pattern: Pattern[bytes], filename: str ) -> tuple[int, bytes]: with open(filename, "rb") as f: contents = f.read() match = pattern.search(contents) if match: return 0, b"" else: return 1, filename.encode() + b"\n" def run( ignore_case: bool, multiline: bool, negate: bool, concurrency: int, pattern: bytes ): flags = re.IGNORECASE if ignore_case else 0 if multiline: flags |= re.MULTILINE | re.DOTALL pattern = re.compile(pattern, flags) queue = Queue() pool = ThreadPoolExecutor(max_workers=concurrency) # Use a sentinel value to signal completion SENTINEL = (None, None) def producer(): try: for line in sys.stdin: line = line.strip() if not line: break pool.submit( process_file, line.strip(), pattern, multiline, negate, queue ) # Wait for all tasks to complete pool.shutdown(wait=True) finally: # Ensure sentinel is sent even if there's an error queue.put(SENTINEL) def consumer(): retv = 0 try: while True: ret, output = queue.get() # Check for sentinel value if ret is None and output is None: queue.task_done() break retv |= ret if output: sys.stdout.buffer.write(output) sys.stdout.buffer.flush() queue.task_done() except Exception: pass # Write final return code sys.stderr.buffer.write(f'{{"code": {retv}}}\n'.encode()) sys.stderr.buffer.flush() t1 = Thread(target=producer) t2 = Thread(target=consumer) t1.start() t2.start() # Wait for both threads to complete t1.join() t2.join() def main(): ignore_case = sys.argv[1] == "1" multiline = sys.argv[2] == "1" negate = sys.argv[3] == "1" concurrency = int(sys.argv[4]) pattern = sys.argv[5].encode() try: run(ignore_case, multiline, negate, concurrency, pattern) except re.error as e: error = {"type": "Regex", "message": str(e)} sys.stderr.buffer.write(json.dumps(error).encode()) sys.stderr.flush() sys.exit(1) except OSError as e: error = {"type": "IO", "message": str(e)} sys.stderr.buffer.write(json.dumps(error).encode()) sys.stderr.flush() sys.exit(1) except Exception as e: error = {"type": "Unknown", "message": repr(e)} sys.stderr.buffer.write(json.dumps(error).encode()) sys.stderr.flush() sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: crates/prek/src/languages/python/mod.rs ================================================ use anyhow::Result; use crate::hook::Hook; mod pep723; mod pyproject; #[allow(clippy::module_inception)] mod python; mod uv; mod version; /// Extract Python hook metadata with explicit precedence: /// PEP 723 > user-configured `language_version` > pyproject.toml > default. pub(crate) async fn extract_metadata(hook: &mut Hook) -> Result<()> { pyproject::extract_pyproject_metadata(hook).await?; pep723::extract_pep723_metadata(hook).await } pub(crate) use python::Python; pub(crate) use python::{python_exec, query_python_info_cached}; pub(crate) use uv::Uv; pub(crate) use version::PythonRequest; ================================================ FILE: crates/prek/src/languages/python/pep723.rs ================================================ // MIT License // // Copyright (c) 2025 Astral Software Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. use std::path::Path; use std::str::FromStr; use std::sync::LazyLock; use anyhow::Result; use memchr::memmem::Finder; use serde::Deserialize; use tracing::trace; use crate::hook::Hook; use crate::languages::version::LanguageRequest; static FINDER: LazyLock = LazyLock::new(|| Finder::new(b"# /// script")); /// A PEP 723 script, including its [`Pep723Metadata`]. #[derive(Debug, Clone)] pub struct Pep723Script { /// The parsed [`Pep723Metadata`] table from the script. pub metadata: Pep723Metadata, /// The content of the script before the metadata table. pub prelude: String, /// The content of the script after the metadata table. pub postlude: String, } impl Pep723Script { /// Read the PEP 723 `script` metadata from a Python file, if it exists. /// /// Returns `None` if the file is missing a PEP 723 metadata block. /// /// See: pub async fn read(file: impl AsRef) -> Result, Pep723Error> { let contents = fs_err::tokio::read(&file).await?; // Extract the `script` tag. let ScriptTag { prelude, metadata, postlude, } = match ScriptTag::parse(&contents) { Ok(Some(tag)) => tag, Ok(None) => return Ok(None), Err(err) => return Err(err), }; // Parse the metadata. let metadata = Pep723Metadata::from_str(&metadata)?; Ok(Some(Self { metadata, prelude, postlude, })) } } /// PEP 723 metadata as parsed from a `script` comment block. /// /// See: #[derive(Debug, Deserialize, Clone)] #[serde(rename_all = "kebab-case")] pub struct Pep723Metadata { pub dependencies: Option>, pub requires_python: Option, } impl FromStr for Pep723Metadata { type Err = toml::de::Error; /// Parse `Pep723Metadata` from a raw TOML string. fn from_str(raw: &str) -> Result { let metadata = toml::from_str(raw)?; Ok(metadata) } } #[derive(Debug, thiserror::Error)] pub enum Pep723Error { #[error( "An opening tag (`# /// script`) was found without a closing tag (`# ///`). Ensure that every line between the opening and closing tags (including empty lines) starts with a leading `#`." )] UnclosedBlock, #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Utf8(#[from] std::str::Utf8Error), #[error(transparent)] Toml(#[from] toml::de::Error), } #[derive(Debug, Clone, Eq, PartialEq)] pub struct ScriptTag { /// The content of the script before the metadata block. prelude: String, /// The metadata block. metadata: String, /// The content of the script after the metadata block. postlude: String, } impl ScriptTag { /// Given the contents of a Python file, extract the `script` metadata block with leading /// comment hashes removed, any preceding shebang or content (prelude), and the remaining Python /// script. /// /// Given the following input string representing the contents of a Python script: /// /// ```python /// #!/usr/bin/env python3 /// # /// script /// # requires-python = '>=3.11' /// # dependencies = [ /// # 'requests<3', /// # 'rich', /// # ] /// # /// /// /// import requests /// /// print("Hello, World!") /// ``` /// /// This function would return: /// /// - Preamble: `#!/usr/bin/env python3\n` /// - Metadata: `requires-python = '>=3.11'\ndependencies = [\n 'requests<3',\n 'rich',\n]` /// - Postlude: `import requests\n\nprint("Hello, World!")\n` /// /// See: pub fn parse(contents: &[u8]) -> Result, Pep723Error> { // Identify the opening pragma. let Some(index) = FINDER.find(contents) else { return Ok(None); }; // The opening pragma must be the first line, or immediately preceded by a newline. if !(index == 0 || matches!(contents[index - 1], b'\r' | b'\n')) { return Ok(None); } // Extract the preceding content. let prelude = std::str::from_utf8(&contents[..index])?; // Decode as UTF-8. let contents = &contents[index..]; let contents = std::str::from_utf8(contents)?; let mut lines = contents.lines(); // Ensure that the first line is exactly `# /// script`. if lines.next().is_none_or(|line| line != "# /// script") { return Ok(None); } // > Every line between these two lines (# /// TYPE and # ///) MUST be a comment starting // > with #. If there are characters after the # then the first character MUST be a space. The // > embedded content is formed by taking away the first two characters of each line if the // > second character is a space, otherwise just the first character (which means the line // > consists of only a single #). let mut toml = vec![]; for line in lines { // Remove the leading `#`. let Some(line) = line.strip_prefix('#') else { break; }; // If the line is empty, continue. if line.is_empty() { toml.push(""); continue; } // Otherwise, the line _must_ start with ` `. let Some(line) = line.strip_prefix(' ') else { break; }; toml.push(line); } // Find the closing `# ///`. The precedence is such that we need to identify the _last_ such // line. // // For example, given: // ```python // # /// script // # // # /// // # // # /// // ``` // // The latter `///` is the closing pragma let Some(index) = toml.iter().rev().position(|line| *line == "///") else { return Err(Pep723Error::UnclosedBlock); }; let index = toml.len() - index; // Discard any lines after the closing `# ///`. // // For example, given: // ```python // # /// script // # // # /// // # // # // ``` // // We need to discard the last two lines. toml.truncate(index - 1); // Join the lines into a single string. let prelude = prelude.to_string(); let metadata = toml.join("\n") + "\n"; let postlude = contents .lines() .skip(index + 1) .collect::>() .join("\n") + "\n"; Ok(Some(Self { prelude, metadata, postlude, })) } } /// Extract PEP 723 inline metadata for `python` hooks. /// First part of `entry` must be a file path to the Python script. /// Effectively, we are implementing a new `python-script` language which works like `script`. /// But we don't want to introduce a new language just for this for now. pub(crate) async fn extract_pep723_metadata(hook: &mut Hook) -> Result<()> { if !hook.additional_dependencies.is_empty() { trace!( "Skipping reading PEP 723 metadata for hook `{hook}` because it already has `additional_dependencies`", ); return Ok(()); } let repo_path = hook.repo_path().unwrap_or(hook.work_dir()); let split = hook.entry.split()?; let file = repo_path.join(&split[0]); let Some(script) = Pep723Script::read(&file).await? else { return Ok(()); }; if let Some(dependencies) = script.metadata.dependencies { hook.additional_dependencies = dependencies.into_iter().collect(); } if let Some(language_request) = script.metadata.requires_python { if !hook.language_request.is_any() { trace!( "`language_version` is ignored because `requires_python` is specified in the PEP 723 metadata" ); } hook.language_request = LanguageRequest::parse(hook.language, &language_request)?; } Ok(()) } ================================================ FILE: crates/prek/src/languages/python/pyproject.rs ================================================ use std::io; use std::path::Path; use anyhow::Result; use serde::Deserialize; use tracing::trace; use crate::config::Language; use crate::hook::Hook; use crate::languages::version::LanguageRequest; #[derive(Debug, Deserialize)] struct PyProjectToml { project: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "kebab-case")] struct ProjectTable { requires_python: Option, } async fn extract_pyproject_requires_python(repo_path: &Path) -> Result> { let pyproject = repo_path.join("pyproject.toml"); let contents = match fs_err::tokio::read_to_string(&pyproject).await { Ok(contents) => contents, Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), Err(err) => return Err(err.into()), }; let parsed = match toml::from_str::(&contents) { Ok(parsed) => parsed, Err(err) => { trace!(error = %err, "Ignoring unparsable pyproject.toml"); return Ok(None); } }; Ok(parsed.project.and_then(|project| project.requires_python)) } /// Extract `requires-python` from the hook repo's `pyproject.toml`. /// /// Only acts when `language_request` is still `Any` (i.e. no explicit /// `language_version` was configured by the user). pub(crate) async fn extract_pyproject_metadata(hook: &mut Hook) -> Result<()> { if !hook.language_request.is_any() { trace!( hook = %hook, "Skipping pyproject.toml metadata extraction because language_version is already configured", ); return Ok(()); } let Some(repo_path) = hook.repo_path() else { return Ok(()); }; let Some(req_str) = extract_pyproject_requires_python(repo_path).await? else { trace!(hook = %hook, "No requires-python found in pyproject.toml"); return Ok(()); }; let req = match LanguageRequest::parse(Language::Python, &req_str) { Ok(req) => req, Err(err) => { trace!(%req_str, error = %err, "Ignoring invalid pyproject.toml requires-python"); return Ok(()); } }; trace!(hook = %hook, version = %req_str, "Using pyproject.toml-derived language_version"); hook.language_request = req; Ok(()) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn valid_requires_python() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; fs_err::tokio::write( dir.path().join("pyproject.toml"), "[project]\nrequires-python = \">=3.10\"\n", ) .await?; let req = extract_pyproject_requires_python(dir.path()).await?; assert_eq!(req.as_deref(), Some(">=3.10")); Ok(()) } #[tokio::test] async fn missing_file_returns_none() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; let req = extract_pyproject_requires_python(dir.path()).await?; assert!(req.is_none()); Ok(()) } #[tokio::test] async fn missing_project_table_returns_none() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; fs_err::tokio::write( dir.path().join("pyproject.toml"), "[build-system]\nrequires = [\"setuptools\"]\n", ) .await?; let req = extract_pyproject_requires_python(dir.path()).await?; assert!(req.is_none()); Ok(()) } #[tokio::test] async fn missing_requires_python_returns_none() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; fs_err::tokio::write( dir.path().join("pyproject.toml"), "[project]\nname = \"my-project\"\n", ) .await?; let req = extract_pyproject_requires_python(dir.path()).await?; assert!(req.is_none()); Ok(()) } #[tokio::test] async fn unparsable_toml_returns_none() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; fs_err::tokio::write( dir.path().join("pyproject.toml"), "this is not valid toml {{{\n", ) .await?; let req = extract_pyproject_requires_python(dir.path()).await?; assert!(req.is_none()); Ok(()) } #[tokio::test] async fn invalid_version_specifier_is_ignored() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; fs_err::tokio::write( dir.path().join("pyproject.toml"), "[project]\nrequires-python = \"not a valid specifier\"\n", ) .await?; let req = extract_pyproject_requires_python(dir.path()).await?; assert_eq!(req.as_deref(), Some("not a valid specifier")); // The string is returned, but LanguageRequest::parse would reject it. // extract_pyproject_metadata handles that gracefully (trace + return Ok(())). let parse_result = LanguageRequest::parse(Language::Python, "not a valid specifier"); assert!(parse_result.is_err()); Ok(()) } } ================================================ FILE: crates/prek/src/languages/python/python.rs ================================================ use std::env::consts::EXE_EXTENSION; use std::fs; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Arc, LazyLock}; use anyhow::{Context, Result}; use mea::once::OnceMap; use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use rustc_hash::FxBuildHasher; use serde::Deserialize; use tracing::{debug, trace}; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::InstalledHook; use crate::hook::{Hook, InstallInfo}; use crate::languages::LanguageImpl; use crate::languages::python::PythonRequest; use crate::languages::python::uv::Uv; use crate::languages::version::LanguageRequest; use crate::process; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::{Store, ToolBucket}; #[derive(Debug, Copy, Clone)] pub(crate) struct Python; pub(crate) struct PythonInfo { pub(crate) version: semver::Version, pub(crate) python_exec: PathBuf, } #[derive(Debug, Clone, thiserror::Error)] pub(crate) enum PythonInfoError { #[error("Failed to parse Python info JSON: {0}")] Parse(String), #[error("Failed to query Python info: {0}")] Query(String), #[error("{0}")] Message(String), } static PYTHON_INFO_CACHE: LazyLock, FxBuildHasher>> = LazyLock::new(|| OnceMap::with_hasher(FxBuildHasher)); async fn query_python_info(python: &Path) -> Result { #[derive(Deserialize)] struct QueryPythonInfo { version: semver::Version, base_exec_prefix: PathBuf, } static QUERY_PYTHON_INFO: &str = indoc::indoc! {r#" import sys, json info = { "version": ".".join(map(str, sys.version_info[:3])), "base_exec_prefix": sys.base_exec_prefix, } print(json.dumps(info)) "#}; let stdout = Cmd::new(python, "python -c") .arg("-I") .arg("-c") .arg(QUERY_PYTHON_INFO) .check(true) .output() .await .map_err(|err| PythonInfoError::Query(err.to_string()))? .stdout; let info: QueryPythonInfo = serde_json::from_slice(&stdout).map_err(|err| PythonInfoError::Parse(err.to_string()))?; let python_exec = python_exec(&info.base_exec_prefix); Ok(PythonInfo { version: info.version, python_exec, }) } pub(crate) async fn query_python_info_cached( python: &Path, ) -> Result, PythonInfoError> { let python = fs::canonicalize(python).unwrap_or_else(|_| python.to_path_buf()); PYTHON_INFO_CACHE .try_compute(python.clone(), async move || { let info = query_python_info(&python).await?; Ok(Arc::new(info)) }) .await } impl LanguageImpl for Python { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result { let progress = reporter.on_install_start(&hook); let uv_dir = store.tools_path(ToolBucket::Uv); let uv = Uv::install(store, &uv_dir) .await .context("Failed to install uv")?; let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; debug!(%hook, target = %info.env_path.display(), "Installing environment"); // Create venv (auto download Python if needed) Self::create_venv(&uv, store, &info, &hook.language_request) .await .context("Failed to create Python virtual environment")?; // Install dependencies let mut pip_install = Self::pip_install_command(&uv, store, &info.env_path); if let Some(repo_path) = hook.repo_path() { trace!( "Installing dependencies from repo path: {}", repo_path.display() ); pip_install .arg("--directory") .arg(repo_path) .arg(".") .args(&hook.additional_dependencies) .output() .await?; } else if !hook.additional_dependencies.is_empty() { trace!( "Installing additional dependencies: {:?}", hook.additional_dependencies ); pip_install .args(&hook.additional_dependencies) .output() .await?; } else { debug!("No dependencies to install"); } let python = python_exec(&info.env_path); let python_info = query_python_info(&python) .await .context("Failed to query Python info")?; info.with_language_version(python_info.version) .with_toolchain(python_info.python_exec); info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, info: &InstallInfo) -> Result<()> { let python = python_exec(&info.env_path); let python_info = query_python_info_cached(&python) .await .context("Failed to query Python info")?; if python_info.version != info.language_version { anyhow::bail!( "Python version mismatch: expected {}, found {}", info.language_version, python_info.version ); } Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let env_dir = hook.env_path().expect("Python must have env path"); let new_path = prepend_paths(&[&bin_dir(env_dir)]).context("Failed to join PATH")?; let entry = hook.entry.resolve(Some(&new_path))?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "python hook") .current_dir(hook.work_dir()) .args(&entry[1..]) .env(EnvVars::VIRTUAL_ENV, env_dir) .env(EnvVars::PATH, &new_path) .env_remove(EnvVars::PYTHONHOME) .envs(&hook.env) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); // Collect results let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } fn to_uv_python_request(request: &LanguageRequest) -> Option { match request { LanguageRequest::Any { .. } => None, LanguageRequest::Python(request) => match request { PythonRequest::Any => None, PythonRequest::Major(major) => Some(format!("{major}")), PythonRequest::MajorMinor(major, minor) => Some(format!("{major}.{minor}")), PythonRequest::MajorMinorPatch(major, minor, patch) => { Some(format!("{major}.{minor}.{patch}")) } PythonRequest::Range(_, raw) => Some(raw.clone()), PythonRequest::Path(path) => Some(path.to_string_lossy().to_string()), }, _ => unreachable!(), } } impl Python { fn remove_uv_python_override_envs(cmd: &mut Cmd) -> &mut Cmd { // Ensure uv selects the hook virtualenv interpreter. cmd.env_remove(EnvVars::UV_PYTHON) .env_remove(EnvVars::UV_SYSTEM_PYTHON) // `--managed-python` and `--no-managed-python` conflict with our explicit preference. .env_remove(EnvVars::UV_MANAGED_PYTHON) .env_remove(EnvVars::UV_NO_MANAGED_PYTHON) } fn pip_install_command(uv: &Uv, store: &Store, env_path: &Path) -> Cmd { let mut cmd = uv.cmd("uv pip", store); cmd.arg("pip") .arg("install") // Explicitly set project to root to avoid uv searching for project-level configs. // `--project` has no other effect on `uv pip` subcommands. .args(["--project", "/"]) .env(EnvVars::VIRTUAL_ENV, env_path); Self::remove_uv_python_override_envs(&mut cmd) // Remove GIT environment variables that may leak from git hooks (e.g., in worktrees). // These can break packages using setuptools_scm for file discovery. .remove_git_envs() .check(true); cmd } async fn create_venv( uv: &Uv, store: &Store, info: &InstallInfo, python_request: &LanguageRequest, ) -> Result<()> { // Try creating venv without downloads first match Self::create_venv_command(uv, store, info, python_request, false, false) .check(true) .output() .await { Ok(_) => { debug!( "Venv created successfully with no downloads: `{}`", info.env_path.display() ); Ok(()) } Err(e @ process::Error::Status { .. }) => { // Check if we can retry with downloads if Self::can_retry_with_downloads(&e) { if !python_request.allows_download() { anyhow::bail!( "No suitable system Python version found and downloads are disabled" ); } debug!( "Retrying venv creation with managed Python downloads: `{}`", info.env_path.display() ); Self::create_venv_command(uv, store, info, python_request, true, true) .check(true) .output() .await?; return Ok(()); } // If we can't retry, return the original error Err(e.into()) } Err(e) => { debug!("Failed to create venv `{}`: {e}", info.env_path.display()); Err(e.into()) } } } fn create_venv_command( uv: &Uv, store: &Store, info: &InstallInfo, python_request: &LanguageRequest, set_install_dir: bool, allow_downloads: bool, ) -> Cmd { let mut cmd = uv.cmd("create venv", store); cmd.arg("venv") .arg(&info.env_path) .args(["--python-preference", "managed"]) // Avoid discovering a project or workspace .arg("--no-project") // Explicitly set project to root to avoid uv searching for project-level configs .args(["--project", "/"]); Self::remove_uv_python_override_envs(&mut cmd); if set_install_dir { cmd.env( EnvVars::UV_PYTHON_INSTALL_DIR, store.tools_path(ToolBucket::Python), ); } if allow_downloads { cmd.arg("--allow-python-downloads"); } else { cmd.arg("--no-python-downloads"); } if let Some(python) = to_uv_python_request(python_request) { cmd.arg("--python").arg(python); } cmd } fn can_retry_with_downloads(error: &process::Error) -> bool { let process::Error::Status { error: process::StatusError { output: Some(output), .. }, .. } = error else { return false; }; let stderr = String::from_utf8_lossy(&output.stderr); stderr.contains("A managed Python download is available") } } fn bin_dir(venv: &Path) -> PathBuf { if cfg!(windows) { venv.join("Scripts") } else { venv.join("bin") } } pub(crate) fn python_exec(venv: &Path) -> PathBuf { bin_dir(venv).join("python").with_extension(EXE_EXTENSION) } #[cfg(test)] mod tests { use std::collections::HashMap; use std::path::PathBuf; use prek_consts::env_vars::EnvVars; use rustc_hash::FxHashSet; use super::Python; use crate::config::Language; use crate::hook::InstallInfo; use crate::languages::python::uv::Uv; use crate::languages::version::LanguageRequest; use crate::store::Store; fn setup_test_install() -> (tempfile::TempDir, Uv, Store, InstallInfo) { let temp = tempfile::tempdir().expect("create tempdir"); let hooks_dir = temp.path().join("hooks"); fs_err::create_dir_all(&hooks_dir).expect("create hooks dir"); let info = InstallInfo::new(Language::Python, FxHashSet::default(), &hooks_dir) .expect("create install info"); let store = Store::from_path(temp.path().join("store")); let uv = Uv::new(PathBuf::from("uv")); (temp, uv, store, info) } fn env_map(cmd: &crate::process::Cmd) -> HashMap> { cmd.get_envs() .map(|(key, val)| { ( key.to_string_lossy().into_owned(), val.map(|v| v.to_string_lossy().into_owned()), ) }) .collect() } #[test] fn create_venv_command_removes_uv_system_python_override() { let (_temp, uv, store, info) = setup_test_install(); let request = LanguageRequest::Any { system_only: false }; let cmd = Python::create_venv_command(&uv, &store, &info, &request, false, false); let envs = env_map(&cmd); assert_eq!(envs.get(EnvVars::UV_SYSTEM_PYTHON), Some(&None)); assert_eq!(envs.get(EnvVars::UV_PYTHON), Some(&None)); assert_eq!(envs.get(EnvVars::UV_MANAGED_PYTHON), Some(&None)); assert_eq!(envs.get(EnvVars::UV_NO_MANAGED_PYTHON), Some(&None)); } #[test] fn pip_install_command_removes_uv_system_python_override() { let (_temp, uv, store, info) = setup_test_install(); let cmd = Python::pip_install_command(&uv, &store, &info.env_path); let envs = env_map(&cmd); assert_eq!(envs.get(EnvVars::UV_SYSTEM_PYTHON), Some(&None)); assert_eq!(envs.get(EnvVars::UV_PYTHON), Some(&None)); assert_eq!(envs.get(EnvVars::UV_MANAGED_PYTHON), Some(&None)); assert_eq!(envs.get(EnvVars::UV_NO_MANAGED_PYTHON), Some(&None)); } } ================================================ FILE: crates/prek/src/languages/python/uv.rs ================================================ use std::env::consts::EXE_EXTENSION; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::LazyLock; use std::time::Duration; use anyhow::{Context, Result, bail}; use http::header::ACCEPT; use semver::{Version, VersionReq}; use target_lexicon::{Architecture, ArmArchitecture, Environment, HOST, OperatingSystem}; use tokio::task::JoinSet; use tracing::{debug, trace, warn}; use prek_consts::env_vars::EnvVars; use crate::fs::LockedFile; use crate::http::{REQWEST_CLIENT, download_and_extract}; use crate::process::Cmd; use crate::store::{CacheBucket, Store}; use crate::version; // The version range of `uv` we will install. Should update periodically. const CUR_UV_VERSION: &str = "0.10.9"; static UV_VERSION_RANGE: LazyLock = LazyLock::new(|| VersionReq::parse(">=0.7.0").unwrap()); fn wheel_platform_tag_for_host( operating_system: OperatingSystem, architecture: Architecture, environment: Environment, ) -> Result<&'static str> { let platform_tag = match (operating_system, architecture, environment) { // Linux platforms (OperatingSystem::Linux, Architecture::X86_64, Environment::Musl) => "musllinux_1_1_x86_64", (OperatingSystem::Linux, Architecture::X86_64, _) => { "manylinux_2_17_x86_64.manylinux2014_x86_64" } (OperatingSystem::Linux, Architecture::Aarch64(_), _) => { "manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64" } (OperatingSystem::Linux, Architecture::Arm(ArmArchitecture::Armv7), Environment::Musl) => { "manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l" } (OperatingSystem::Linux, Architecture::Arm(ArmArchitecture::Armv7), _) => { "manylinux_2_17_armv7l.manylinux2014_armv7l" } (OperatingSystem::Linux, Architecture::Arm(ArmArchitecture::Armv6), _) => "linux_armv6l", // Raspberry Pi Zero/1 (OperatingSystem::Linux, Architecture::X86_32(_), Environment::Musl) => { "musllinux_1_1_i686" } (OperatingSystem::Linux, Architecture::X86_32(_), _) => { "manylinux_2_17_i686.manylinux2014_i686" } (OperatingSystem::Linux, Architecture::Powerpc64, _) => { "manylinux_2_17_ppc64.manylinux2014_ppc64" } (OperatingSystem::Linux, Architecture::Powerpc64le, _) => { "manylinux_2_17_ppc64le.manylinux2014_ppc64le" } (OperatingSystem::Linux, Architecture::S390x, _) => { "manylinux_2_17_s390x.manylinux2014_s390x" } (OperatingSystem::Linux, Architecture::Riscv64(_), _) => "manylinux_2_31_riscv64", // macOS platforms (OperatingSystem::Darwin(_), Architecture::X86_64, _) => "macosx_10_12_x86_64", (OperatingSystem::Darwin(_), Architecture::Aarch64(_), _) => "macosx_11_0_arm64", // Windows platforms (OperatingSystem::Windows, Architecture::X86_64, _) => "win_amd64", (OperatingSystem::Windows, Architecture::X86_32(_), _) => "win32", (OperatingSystem::Windows, Architecture::Aarch64(_), _) => "win_arm64", _ => bail!( "Unsupported platform: operating_system={operating_system:?}, architecture={architecture:?}, environment={environment:?}" ), }; Ok(platform_tag) } // Get the uv wheel platform tag for the current host. fn get_wheel_platform_tag() -> Result { wheel_platform_tag_for_host(HOST.operating_system, HOST.architecture, HOST.environment) .map(ToString::to_string) } fn get_uv_version(uv_path: &Path) -> Result { let output = Command::new(uv_path) .arg("--version") .output() .context("Failed to execute uv")?; if !output.status.success() { bail!("Failed to get uv version"); } let version_output = String::from_utf8_lossy(&output.stdout); let version_str = version_output .split_whitespace() .nth(1) .context("Invalid version output format")?; Version::parse(version_str).map_err(Into::into) } fn validate_uv_binary(uv_path: &Path) -> Result { let version = get_uv_version(uv_path)?; if !UV_VERSION_RANGE.matches(&version) { bail!( "uv version `{version}` does not satisfy required range `{}`", &*UV_VERSION_RANGE ); } Ok(version) } async fn replace_uv_binary(source: &Path, target_path: &Path) -> Result<()> { if let Some(parent) = target_path.parent() { fs_err::tokio::create_dir_all(parent).await?; } if target_path.exists() { debug!(target = %target_path.display(), "Removing existing uv binary"); fs_err::tokio::remove_file(target_path).await?; } fs_err::tokio::rename(source, target_path).await?; Ok(()) } static UV_EXE: LazyLock> = LazyLock::new(|| { for uv_path in which::which_all("uv").ok()? { debug!("Found uv in PATH: {}", uv_path.display()); match validate_uv_binary(&uv_path) { Ok(version) => return Some((uv_path, version)), Err(err) => warn!(uv = %uv_path.display(), error = %err, "Skipping incompatible uv"), } } None }); #[derive(Debug)] enum PyPiMirror { Pypi, Tuna, Aliyun, Tencent, Custom(String), } // TODO: support reading pypi source user config, or allow user to set mirror // TODO: allow opt-out uv impl PyPiMirror { fn url(&self) -> &str { match self { Self::Pypi => "https://pypi.org/simple/", Self::Tuna => "https://pypi.tuna.tsinghua.edu.cn/simple/", Self::Aliyun => "https://mirrors.aliyun.com/pypi/simple/", Self::Tencent => "https://mirrors.cloud.tencent.com/pypi/simple/", Self::Custom(url) => url, } } fn iter() -> impl Iterator { vec![Self::Pypi, Self::Tuna, Self::Aliyun, Self::Tencent].into_iter() } } #[derive(Debug)] enum InstallSource { /// Download uv from GitHub releases. GitHub, /// Download uv from `PyPi`. PyPi(PyPiMirror), /// Install uv by running `pip install uv`. Pip, } impl InstallSource { async fn install(&self, store: &Store, target: &Path) -> Result<()> { match self { Self::GitHub => self.install_from_github(store, target).await, Self::PyPi(source) => self.install_from_pypi(store, target, source).await, Self::Pip => self.install_from_pip(target).await, } } async fn install_from_github(&self, store: &Store, target: &Path) -> Result<()> { let ext = if cfg!(windows) { "zip" } else { "tar.gz" }; let archive_name = format!("uv-{HOST}.{ext}"); let download_url = format!( "https://github.com/astral-sh/uv/releases/download/{CUR_UV_VERSION}/{archive_name}" ); download_and_extract(&download_url, &archive_name, store, async |extracted| { let source = extracted.join("uv").with_extension(EXE_EXTENSION); let target_path = target.join("uv").with_extension(EXE_EXTENSION); debug!(?source, target = %target_path.display(), "Moving uv to target"); // TODO: retry on Windows replace_uv_binary(&source, &target_path).await?; anyhow::Ok(()) }) .await .context("Failed to download and extract uv")?; Ok(()) } async fn install_from_pypi( &self, store: &Store, target: &Path, source: &PyPiMirror, ) -> Result<()> { let platform_tag = get_wheel_platform_tag()?; let wheel_name = format!("uv-{CUR_UV_VERSION}-py3-none-{platform_tag}.whl"); // Use PyPI JSON API instead of parsing HTML let api_url = match source { PyPiMirror::Pypi => format!("https://pypi.org/pypi/uv/{CUR_UV_VERSION}/json"), // For mirrors, we'll fall back to simple API approach _ => return self.install_from_simple_api(store, target, source).await, }; debug!("Fetching uv metadata from: {}", api_url); let response = REQWEST_CLIENT .get(&api_url) .header("Accept", "*/*") .send() .await?; if !response.status().is_success() { bail!( "Failed to fetch uv metadata from PyPI: {}", response.status() ); } let metadata: serde_json::Value = response.json().await?; let files = metadata["urls"] .as_array() .context("Invalid PyPI response: missing urls")?; let wheel_file = files .iter() .find(|file| { file["filename"].as_str() == Some(&wheel_name) && file["packagetype"].as_str() == Some("bdist_wheel") && file["yanked"].as_bool() != Some(true) }) .with_context(|| format!("Could not find wheel for {wheel_name} in PyPI response"))?; let download_url = wheel_file["url"] .as_str() .context("Missing download URL in PyPI response")?; self.download_and_extract_wheel(store, target, &wheel_name, download_url) .await } async fn install_from_simple_api( &self, store: &Store, target: &Path, source: &PyPiMirror, ) -> Result<()> { // Fallback for mirrors that don't support JSON API let platform_tag = get_wheel_platform_tag()?; let wheel_name = format!("uv-{CUR_UV_VERSION}-py3-none-{platform_tag}.whl"); let simple_url = format!("{}uv/", source.url()); debug!("Fetching from simple API: {}", simple_url); let response = REQWEST_CLIENT .get(&simple_url) .header(ACCEPT, "*/*") .send() .await?; let html = response.text().await?; // Simple string search to find the wheel download link let search_pattern = r#"href=""#.to_string(); let download_path = html .lines() .find(|line| line.contains(&wheel_name)) .and_then(|line| { if let Some(start) = line.find(&search_pattern) { let start = start + search_pattern.len(); if let Some(end) = line[start..].find('"') { return Some(&line[start..start + end]); } } None }) .with_context(|| { format!( "Could not find wheel download link for {wheel_name} in simple API response" ) })?; // Resolve relative URLs let download_url = if download_path.starts_with("http") { download_path.to_string() } else { format!("{simple_url}{download_path}") }; self.download_and_extract_wheel(store, target, &wheel_name, &download_url) .await } async fn download_and_extract_wheel( &self, store: &Store, target: &Path, filename: &str, download_url: &str, ) -> Result<()> { download_and_extract(download_url, filename, store, async |extracted| { // Find the uv binary in the extracted contents let data_dir = format!("uv-{CUR_UV_VERSION}.data"); let extracted_uv = extracted .join(data_dir) .join("scripts") .join("uv") .with_extension(EXE_EXTENSION); // Copy the binary to the target location let target_path = target.join("uv").with_extension(EXE_EXTENSION); debug!(?extracted_uv, target = %target_path.display(), "Moving uv to target"); replace_uv_binary(&extracted_uv, &target_path).await?; // Set executable permissions on Unix #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let metadata = fs_err::tokio::metadata(&target_path).await?; let mut perms = metadata.permissions(); perms.set_mode(0o755); fs_err::tokio::set_permissions(&target_path, perms).await?; } Ok(()) }) .await .context("Failed to download and extract uv wheel")?; Ok(()) } async fn install_from_pip(&self, target: &Path) -> Result<()> { // When running `pip install` in multiple threads, it can fail // without extracting files properly. Cmd::new("python3", "pip install uv") .arg("-m") .arg("pip") .arg("install") .arg("--prefix") .arg(target) .arg("--only-binary=:all:") .arg("--progress-bar=off") .arg("--disable-pip-version-check") .arg(format!("uv=={CUR_UV_VERSION}")) .check(true) .output() .await?; let local_dir = target.join("local"); let uv_src = if local_dir.is_dir() { &local_dir } else { target }; let bin_dir = uv_src.join(if cfg!(windows) { "Scripts" } else { "bin" }); let lib_dir = uv_src.join(if cfg!(windows) { "Lib" } else { "lib" }); let uv = uv_src .join(&bin_dir) .join("uv") .with_extension(EXE_EXTENSION); fs_err::tokio::rename(&uv, target.join("uv").with_extension(EXE_EXTENSION)).await?; fs_err::tokio::remove_dir_all(bin_dir).await?; fs_err::tokio::remove_dir_all(lib_dir).await?; Ok(()) } } pub(crate) struct Uv { path: PathBuf, } impl Uv { pub(crate) fn new(path: PathBuf) -> Self { Self { path } } pub(crate) fn cmd(&self, summary: &str, store: &Store) -> Cmd { let mut cmd = Cmd::new(&self.path, summary); cmd.env(EnvVars::UV_CACHE_DIR, store.cache_path(CacheBucket::Uv)); cmd } async fn select_source() -> Result { async fn check_github() -> Result { let url = format!( "https://github.com/astral-sh/uv/releases/download/{CUR_UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz" ); let response = REQWEST_CLIENT .head(url) .timeout(Duration::from_secs(3)) .send() .await?; trace!(?response, "Checked GitHub"); Ok(response.status().is_success()) } async fn select_best_pypi() -> Result { let mut best = PyPiMirror::Pypi; let mut tasks = PyPiMirror::iter() .map(|source| { let client = REQWEST_CLIENT.clone(); async move { let url = format!("{}uv/", source.url()); let response = client .head(&url) .header("User-Agent", format!("prek/{}", version::version().version)) .header("Accept", "*/*") .timeout(Duration::from_secs(2)) .send() .await; (source, response) } }) .collect::>(); while let Some(result) = tasks.join_next().await { if let Ok((source, response)) = result { if let Ok(resp) = response && resp.status().is_success() { best = source; break; } } } Ok(best) } let source = tokio::select! { Ok(true) = check_github() => InstallSource::GitHub, Ok(source) = select_best_pypi() => InstallSource::PyPi(source), else => { warn!("Failed to check uv source availability, falling back to pip install"); InstallSource::Pip } }; trace!(?source, "Selected uv source"); Ok(source) } pub(crate) async fn install(store: &Store, uv_dir: &Path) -> Result { // 1) Check `uv` alongside `prek` binary (e.g. `uv tool install prek --with uv`) let prek_exe = std::env::current_exe()?.canonicalize()?; if let Some(prek_dir) = prek_exe.parent() { let uv_path = prek_dir.join("uv").with_extension(EXE_EXTENSION); if uv_path.is_file() { match validate_uv_binary(&uv_path) { Ok(_) => { trace!(uv = %uv_path.display(), "Found compatible uv alongside prek binary"); return Ok(Self::new(uv_path)); } Err(err) => { warn!(uv = %uv_path.display(), error = %err, "Skipping incompatible uv"); } } } } // 2) Check if system `uv` meets minimum version requirement if let Some((uv_path, version)) = UV_EXE.as_ref() { trace!( "Using system uv version {} at {}", version, uv_path.display() ); return Ok(Self::new(uv_path.clone())); } // 3) Use or install managed `uv` let uv_path = uv_dir.join("uv").with_extension(EXE_EXTENSION); if uv_path.is_file() { match validate_uv_binary(&uv_path) { Ok(_) => { trace!(uv = %uv_path.display(), "Found compatible managed uv"); return Ok(Self::new(uv_path)); } Err(err) => { warn!(uv = %uv_path.display(), error = %err, "Skipping incompatible managed uv"); } } } // Install new managed uv with proper locking fs_err::tokio::create_dir_all(&uv_dir).await?; let _lock = LockedFile::acquire(uv_dir.join(".lock"), "uv").await?; if uv_path.is_file() { match validate_uv_binary(&uv_path) { Ok(_) => { trace!(uv = %uv_path.display(), "Found compatible managed uv"); return Ok(Self::new(uv_path)); } Err(err) => { warn!(uv = %uv_path.display(), error = %err, "Skipping incompatible managed uv"); } } } let source = if let Some(uv_source) = uv_source_from_env() { uv_source } else { Self::select_source().await? }; source.install(store, uv_dir).await?; // Downloaded `uv` binaries can be present on disk but still fail to execute in the // current runtime environment, such as when the libc variant or dynamic loader path // does not match the host. Validate immediately so we can surface a clear error here. match validate_uv_binary(&uv_path) { Ok(version) => trace!(version = %version, "Successfully installed uv"), Err(err) => bail!( "Installed uv at `{}` failed validation: {err}. \ This usually means the downloaded uv binary is incompatible with the \ current runtime environment, for example due to a libc mismatch or a \ missing dynamic loader path. If this keeps happening, please report it \ with details about your environment and the full error output.", uv_path.display() ), } Ok(Self::new(uv_path)) } } fn uv_source_from_env() -> Option { let var = EnvVars::var(EnvVars::PREK_UV_SOURCE).ok()?; match var.as_str() { "github" => Some(InstallSource::GitHub), "pypi" => Some(InstallSource::PyPi(PyPiMirror::Pypi)), "tuna" => Some(InstallSource::PyPi(PyPiMirror::Tuna)), "aliyun" => Some(InstallSource::PyPi(PyPiMirror::Aliyun)), "tencent" => Some(InstallSource::PyPi(PyPiMirror::Tencent)), "pip" => Some(InstallSource::Pip), custom if custom.starts_with("http") => Some(InstallSource::PyPi(PyPiMirror::Custom(var))), _ => { warn!("Invalid UV_SOURCE value: {}", var); None } } } #[cfg(test)] mod tests { use super::*; #[test] fn ensure_cur_uv_version_in_range() { let version = Version::parse(CUR_UV_VERSION).expect("Invalid CUR_UV_VERSION"); assert!( UV_VERSION_RANGE.matches(&version), "CUR_UV_VERSION {CUR_UV_VERSION} does not satisfy the version requirement {}", &*UV_VERSION_RANGE ); } #[test] fn wheel_platform_tag_x86_64_linux_gnu() -> Result<()> { let tag = wheel_platform_tag_for_host( OperatingSystem::Linux, Architecture::X86_64, Environment::Gnu, )?; assert_eq!(tag, "manylinux_2_17_x86_64.manylinux2014_x86_64"); Ok(()) } #[test] fn wheel_platform_tag_x86_64_linux_musl() -> Result<()> { let tag = wheel_platform_tag_for_host( OperatingSystem::Linux, Architecture::X86_64, Environment::Musl, )?; assert_eq!(tag, "musllinux_1_1_x86_64"); Ok(()) } #[test] fn wheel_platform_tag_i686_linux_gnu() -> Result<()> { let tag = wheel_platform_tag_for_host( OperatingSystem::Linux, Architecture::X86_32(target_lexicon::X86_32Architecture::I686), Environment::Gnu, )?; assert_eq!(tag, "manylinux_2_17_i686.manylinux2014_i686"); Ok(()) } #[test] fn wheel_platform_tag_i686_linux_musl() -> Result<()> { let tag = wheel_platform_tag_for_host( OperatingSystem::Linux, Architecture::X86_32(target_lexicon::X86_32Architecture::I686), Environment::Musl, )?; assert_eq!(tag, "musllinux_1_1_i686"); Ok(()) } #[test] fn wheel_platform_tag_aarch64_linux_gnu() -> Result<()> { let tag = wheel_platform_tag_for_host( OperatingSystem::Linux, Architecture::Aarch64(target_lexicon::Aarch64Architecture::Aarch64), Environment::Gnu, )?; assert_eq!( tag, "manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64" ); Ok(()) } #[test] fn wheel_platform_tag_aarch64_linux_musl() -> Result<()> { let tag = wheel_platform_tag_for_host( OperatingSystem::Linux, Architecture::Aarch64(target_lexicon::Aarch64Architecture::Aarch64), Environment::Musl, )?; // aarch64 uses a single dual-tagged wheel for both glibc and musl assert_eq!( tag, "manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64" ); Ok(()) } #[test] fn wheel_platform_tag_armv7_linux_gnu() -> Result<()> { let tag = wheel_platform_tag_for_host( OperatingSystem::Linux, Architecture::Arm(ArmArchitecture::Armv7), Environment::Gnu, )?; assert_eq!(tag, "manylinux_2_17_armv7l.manylinux2014_armv7l"); Ok(()) } #[test] fn wheel_platform_tag_armv7_linux_musl() -> Result<()> { let tag = wheel_platform_tag_for_host( OperatingSystem::Linux, Architecture::Arm(ArmArchitecture::Armv7), Environment::Musl, )?; assert_eq!( tag, "manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l" ); Ok(()) } #[tokio::test] async fn replace_uv_binary_overwrites_existing_file() -> Result<()> { let temp = tempfile::tempdir()?; let source = temp.path().join("source-uv"); let target_dir = temp.path().join("tools").join("uv"); let target_path = target_dir.join("uv").with_extension(EXE_EXTENSION); fs_err::create_dir_all(&target_dir)?; fs_err::write(&source, b"new")?; fs_err::write(&target_path, b"old")?; replace_uv_binary(&source, &target_path).await?; assert!(!source.exists()); assert_eq!(fs_err::read(&target_path)?, b"new"); Ok(()) } #[tokio::test] async fn replace_uv_binary_recreates_missing_parent_dir() -> Result<()> { let temp = tempfile::tempdir()?; let source = temp.path().join("source-uv"); let target_dir = temp.path().join("tools").join("uv"); let target_path = target_dir.join("uv").with_extension(EXE_EXTENSION); fs_err::create_dir_all(&target_dir)?; fs_err::write(&target_path, b"old")?; fs_err::remove_dir_all(&target_dir)?; fs_err::write(&source, b"new")?; replace_uv_binary(&source, &target_path).await?; assert!(target_dir.exists()); assert_eq!(fs_err::read(&target_path)?, b"new"); Ok(()) } } ================================================ FILE: crates/prek/src/languages/python/version.rs ================================================ //! Implement `-p ` argument parser of `virutualenv` from //! use std::path::PathBuf; use std::str::FromStr; use crate::hook::InstallInfo; use crate::languages::version::{Error, try_into_u64_slice}; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum PythonRequest { Any, Major(u64), MajorMinor(u64, u64), MajorMinorPatch(u64, u64, u64), Path(PathBuf), Range(semver::VersionReq, String), } /// Represents a request for a specific Python version or path. /// example formats: /// - `python` /// - `python3` /// - `python3.12` /// - `python3.13.2` /// - `python311` /// - `3` /// - `3.12` /// - `3.12.3` /// - `>=3.12` /// - `>=3.8, <3.12` /// - `/path/to/python` /// - `/path/to/python3.12` // TODO: support version like `3.8b1`, `3.8rc2`, `python3.8t`, `python3.8-64`, `pypy3.8`. impl FromStr for PythonRequest { type Err = Error; fn from_str(request: &str) -> Result { if request.is_empty() { return Ok(Self::Any); } // Check if it starts with "python" - parse as specific version if let Some(version_part) = request.strip_prefix("python") { if version_part.is_empty() { return Ok(Self::Any); } Self::parse_version_numbers(version_part, request) } else { Self::parse_version_numbers(request, request) .or_else(|_| { // Try to parse as a VersionReq (like ">= 3.12" or ">=3.8, <3.12") semver::VersionReq::parse(request) .map(|version_req| PythonRequest::Range(version_req, request.into())) .map_err(|_| Error::InvalidVersion(request.to_string())) }) .or_else(|_| { // If it doesn't match any known format, treat it as a path let path = PathBuf::from(request); if path.exists() { Ok(PythonRequest::Path(path)) } else { Err(Error::InvalidVersion(request.to_string())) } }) } } } impl PythonRequest { pub(crate) fn is_any(&self) -> bool { matches!(self, PythonRequest::Any) } /// Parse version numbers into appropriate `PythonRequest` variants fn parse_version_numbers( version_str: &str, original_request: &str, ) -> Result { let parts = try_into_u64_slice(version_str) .map_err(|_| Error::InvalidVersion(original_request.to_string()))?; let parts = split_wheel_tag_version(parts); match parts[..] { [major] => Ok(PythonRequest::Major(major)), [major, minor] => Ok(PythonRequest::MajorMinor(major, minor)), [major, minor, patch] => Ok(PythonRequest::MajorMinorPatch(major, minor, patch)), _ => Err(Error::InvalidVersion(original_request.to_string())), } } pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { let version = &install_info.language_version; match self { PythonRequest::Any => true, PythonRequest::Major(major) => version.major == *major, PythonRequest::MajorMinor(major, minor) => { version.major == *major && version.minor == *minor } PythonRequest::MajorMinorPatch(major, minor, patch) => { version.major == *major && version.minor == *minor && version.patch == *patch } // FIXME: consider resolving symlinks and normalizing paths before comparison PythonRequest::Path(path) => path == &install_info.toolchain, PythonRequest::Range(req, _) => req.matches(version), } } } /// Convert a wheel tag formatted version (e.g., `38`) to multiple components (e.g., `3.8`). /// /// The major version is always assumed to be a single digit 0-9. The minor version is all /// the following content. /// /// If not a wheel tag formatted version, the input is returned unchanged. fn split_wheel_tag_version(mut version: Vec) -> Vec { if version.len() != 1 { return version; } let release = version[0].to_string(); let mut chars = release.chars(); let Some(major) = chars.next().and_then(|c| c.to_digit(10)) else { return version; }; let Ok(minor) = chars.as_str().parse::() else { return version; }; version[0] = u64::from(major); version.push(u64::from(minor)); version } #[cfg(test)] mod tests { use super::*; use crate::config::Language; use rustc_hash::FxHashSet; #[test] fn test_parse_python_request() { // Empty request assert_eq!(PythonRequest::from_str("").unwrap(), PythonRequest::Any); assert_eq!( PythonRequest::from_str("python").unwrap(), PythonRequest::Any ); assert_eq!( PythonRequest::from_str("python3").unwrap(), PythonRequest::Major(3) ); assert_eq!( PythonRequest::from_str("python3.12").unwrap(), PythonRequest::MajorMinor(3, 12) ); assert_eq!( PythonRequest::from_str("python3.13.2").unwrap(), PythonRequest::MajorMinorPatch(3, 13, 2) ); assert_eq!( PythonRequest::from_str("3").unwrap(), PythonRequest::Major(3) ); assert_eq!( PythonRequest::from_str("3.12").unwrap(), PythonRequest::MajorMinor(3, 12) ); assert_eq!( PythonRequest::from_str("3.12.3").unwrap(), PythonRequest::MajorMinorPatch(3, 12, 3) ); assert_eq!( PythonRequest::from_str("312").unwrap(), PythonRequest::MajorMinor(3, 12) ); assert_eq!( PythonRequest::from_str("python312").unwrap(), PythonRequest::MajorMinor(3, 12) ); // VersionReq assert_eq!( PythonRequest::from_str(">=3.12").unwrap(), PythonRequest::Range( semver::VersionReq::parse(">=3.12").unwrap(), ">=3.12".to_string() ) ); assert_eq!( PythonRequest::from_str(">=3.8, <3.12").unwrap(), PythonRequest::Range( semver::VersionReq::parse(">=3.8, <3.12").unwrap(), ">=3.8, <3.12".to_string() ) ); // Invalid versions assert!(PythonRequest::from_str("invalid").is_err()); assert!(PythonRequest::from_str("3.12.3.4").is_err()); assert!(PythonRequest::from_str("3.12.a").is_err()); assert!(PythonRequest::from_str("3.b.1").is_err()); assert!(PythonRequest::from_str("3..2").is_err()); assert!(PythonRequest::from_str("a3.12").is_err()); // TODO: support assert!(PythonRequest::from_str("3.12.3a1").is_err()); assert!(PythonRequest::from_str("3.12.3rc1").is_err()); assert!(PythonRequest::from_str("python3.13.2a1").is_err()); assert!(PythonRequest::from_str("python3.13.2rc1").is_err()); assert!(PythonRequest::from_str("python3.13.2t1").is_err()); assert!(PythonRequest::from_str("python3.13.2-64").is_err()); assert!(PythonRequest::from_str("python3.13.2-64").is_err()); } #[test] fn test_satisfied_by() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; let mut install_info = InstallInfo::new(Language::Python, FxHashSet::default(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(3, 12, 1)) .with_toolchain(PathBuf::from("/usr/bin/python3.12")); assert!(PythonRequest::Any.satisfied_by(&install_info)); assert!(PythonRequest::Major(3).satisfied_by(&install_info)); assert!(PythonRequest::MajorMinor(3, 12).satisfied_by(&install_info)); assert!(PythonRequest::MajorMinorPatch(3, 12, 1).satisfied_by(&install_info)); assert!(!PythonRequest::MajorMinorPatch(3, 12, 2).satisfied_by(&install_info)); assert!( PythonRequest::Path(PathBuf::from("/usr/bin/python3.12")).satisfied_by(&install_info) ); assert!( !PythonRequest::Path(PathBuf::from("/usr/bin/python3.11")).satisfied_by(&install_info) ); let range_req = semver::VersionReq::parse(">=3.12").unwrap(); assert!( PythonRequest::Range(range_req.clone(), ">=3.12".to_string()) .satisfied_by(&install_info) ); let range_req = semver::VersionReq::parse(">=4.0").unwrap(); assert!(!PythonRequest::Range(range_req, ">=4.0".to_string()).satisfied_by(&install_info)); Ok(()) } } ================================================ FILE: crates/prek/src/languages/ruby/gem.rs ================================================ use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result}; use futures::{StreamExt, TryStreamExt}; use prek_consts::env_vars::EnvVars; use rand::RngExt; use rustc_hash::{FxHashMap, FxHashSet}; use tracing::debug; use crate::languages::ruby::installer::RubyResult; use crate::process::Cmd; use crate::run::CONCURRENCY; /// Find all .gemspec files in a directory fn find_gemspecs(dir: &Path) -> Result> { let mut gemspecs = Vec::new(); for entry in fs_err::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.extension() == Some(OsStr::new("gemspec")) { gemspecs.push(path); } } if gemspecs.is_empty() { anyhow::bail!("No .gemspec files found in {}", dir.display()); } Ok(gemspecs) } /// Build a gemspec into a .gem file async fn build_gemspec(ruby: &RubyResult, gemspec_path: &Path) -> Result { let repo_dir = gemspec_path .parent() .context("Gemspec has no parent directory")?; debug!("Building gemspec: {}", gemspec_path.display()); // Use `ruby -S gem` instead of calling gem directly to work around Windows // issue where gem.cmd/.bat can't be executed directly (os error 193) let output = Cmd::new(ruby.ruby_bin(), "gem build") .arg("-S") .arg("gem") .arg("build") .arg(gemspec_path.file_name().unwrap()) .current_dir(repo_dir) .check(true) .output() .await?; // Parse output to find generated .gem file let output_str = String::from_utf8_lossy(&output.stdout); let gem_file = output_str .lines() .find(|line| line.contains("File:")) .and_then(|line| line.split_whitespace().last()) .context("Could not find generated .gem file in output")?; let gem_path = repo_dir.join(gem_file); if !gem_path.exists() { anyhow::bail!("Generated gem file not found: {}", gem_path.display()); } Ok(gem_path) } /// Build all gemspecs in a repository, returning the list of gems built pub(crate) async fn build_gemspecs(ruby: &RubyResult, repo_dir: &Path) -> Result> { let gemspecs = find_gemspecs(repo_dir)?; let mut gem_files = Vec::new(); for gemspec in gemspecs { let gem_file = build_gemspec(ruby, &gemspec).await?; gem_files.push(gem_file); } Ok(gem_files) } /// Set common gem environment variables for isolation. fn gem_env<'a>(cmd: &'a mut Cmd, gem_home: &Path) -> &'a mut Cmd { cmd.env(EnvVars::GEM_HOME, gem_home) .env(EnvVars::BUNDLE_IGNORE_CONFIG, "1") .env_remove(EnvVars::GEM_PATH) .env_remove(EnvVars::BUNDLE_GEMFILE); // Parallelize native extension compilation (e.g. prism's C code). // Respect existing MAKEFLAGS if set (user may need to limit parallelism // in memory-constrained environments like Docker). if EnvVars::var_os("MAKEFLAGS").is_none() { cmd.env("MAKEFLAGS", format!("-j{}", *CONCURRENCY)); } cmd } /// A gem resolved by `gem install --explain`. #[derive(Debug, PartialEq)] struct ResolvedGem { name: String, version: String, /// Platform suffix for pre-built binary gems (e.g. `x86_64-linux`, `java`). platform: Option, } impl ResolvedGem { /// The `name-version[-platform]` key, matching `.gem` file stems. fn key(&self) -> String { match &self.platform { Some(p) => format!("{}-{}-{}", self.name, self.version, p), None => format!("{}-{}", self.name, self.version), } } } /// Parse `gem install --explain` output into resolved gems. /// /// Splits at the rightmost `-` where the suffix starts with a digit to find /// the version boundary, handling gem names with hyphens (e.g. /// `ruby-progressbar-1.13.0`) and platform-specific gems (e.g. /// `prism-1.9.0-x86_64-linux`). fn parse_explain_output(output: &str) -> Vec { output .lines() .filter_map(|line| { let trimmed = line.trim(); // Find rightmost '-' where the suffix starts with a digit (version boundary) let version_start = trimmed.rmatch_indices('-').find_map(|(i, _)| { trimmed .as_bytes() .get(i + 1) .filter(|b| b.is_ascii_digit()) .map(|_| i) })?; let name = &trimmed[..version_start]; if name.is_empty() { return None; } let rest = &trimmed[version_start + 1..]; // Split version from platform: gem versions use dots (not hyphens), // so the first hyphen-delimited segment starting with a non-digit // begins the platform suffix (e.g. "1.9.0-x86_64-linux"). let (version, platform) = match rest.find('-') { Some(i) if rest .as_bytes() .get(i + 1) .is_some_and(|b| !b.is_ascii_digit()) => { (&rest[..i], Some(&rest[i + 1..])) } _ => (rest, None), }; Some(ResolvedGem { name: name.to_string(), version: version.to_string(), platform: platform.map(String::from), }) }) .collect() } /// Resolve the full dependency list via `gem install --explain`. async fn resolve_gems( ruby: &RubyResult, gem_home: &Path, gem_files: &[PathBuf], additional_dependencies: &FxHashSet, ) -> Result> { let mut cmd = Cmd::new(ruby.ruby_bin(), "gem install --explain"); cmd.arg("-S") .arg("gem") .arg("install") .arg("--explain") .arg("--no-document") .arg("--no-format-executable") .arg("--no-user-install") .arg("--install-dir") .arg(gem_home) .arg("--bindir") .arg(gem_home.join("bin")) .args(gem_files) .args(additional_dependencies); gem_env(&mut cmd, gem_home); let output = cmd.check(true).output().await?; let stdout = String::from_utf8_lossy(&output.stdout); Ok(parse_explain_output(&stdout)) } /// Install a single gem with `--ignore-dependencies`. async fn install_single_gem( ruby: &RubyResult, gem_home: &Path, gem: &ResolvedGem, local_path: Option<&Path>, ) -> Result<()> { let mut cmd = Cmd::new(ruby.ruby_bin(), format!("gem install {}", gem.name)); cmd.arg("-S") .arg("gem") .arg("install") .arg("--ignore-dependencies") .arg("--no-document") .arg("--no-format-executable") .arg("--no-user-install") .arg("--install-dir") .arg(gem_home) .arg("--bindir") .arg(gem_home.join("bin")); if let Some(path) = local_path { cmd.arg(path); } else { cmd.arg(&gem.name).arg("-v").arg(&gem.version); // Request the specific platform variant when a pre-built binary gem was resolved if let Some(platform) = &gem.platform { cmd.arg("--platform").arg(platform); } } gem_env(&mut cmd, gem_home); cmd.check(true).output().await?; Ok(()) } /// Fallback: install all gems in a single sequential `gem install` command. async fn install_gems_sequential( ruby: &RubyResult, gem_home: &Path, gem_files: &[PathBuf], additional_dependencies: &FxHashSet, ) -> Result<()> { let mut cmd = Cmd::new(ruby.ruby_bin(), "gem install"); cmd.arg("-S") .arg("gem") .arg("install") .arg("--no-document") .arg("--no-format-executable") .arg("--no-user-install") .arg("--install-dir") .arg(gem_home) .arg("--bindir") .arg(gem_home.join("bin")) .args(gem_files) .args(additional_dependencies); gem_env(&mut cmd, gem_home); debug!("Installing gems sequentially to {}", gem_home.display()); cmd.check(true).output().await?; Ok(()) } /// Install gems to an isolated `GEM_HOME`. /// /// Resolves the full dependency graph via `gem install --explain`, then installs /// each gem in parallel with `--ignore-dependencies`. Falls back to a single /// sequential `gem install` if resolution fails. pub(crate) async fn install_gems( ruby: &RubyResult, gem_home: &Path, repo_path: Option<&Path>, additional_dependencies: &FxHashSet, ) -> Result<()> { let mut gem_files = Vec::new(); // Collect gems from repository. Many of these were probably built from gemspecs earlier, // but install all .gem files found (matches pre-commit behavior) if let Some(repo) = repo_path { for entry in fs_err::read_dir(repo)? { let entry = entry?; let path = entry.path(); if path.extension() == Some(OsStr::new("gem")) { gem_files.push(path); } } } // If there are no gems and no additional dependencies, skip installation if gem_files.is_empty() && additional_dependencies.is_empty() { debug!("No gems to install, skipping gem install"); return Ok(()); } // Map "name-version" → local .gem path, so parallel installs can use local files let local_gem_map: FxHashMap<&str, &Path> = gem_files .iter() .filter_map(|path| { let stem = path.file_stem()?.to_str()?; Some((stem, path.as_path())) }) .collect(); match resolve_gems(ruby, gem_home, &gem_files, additional_dependencies).await { Ok(gems) if !gems.is_empty() => { debug!("Installing {} gems in parallel", gems.len()); let result = futures::stream::iter(gems) .map(|gem| { let key = gem.key(); let local_path = local_gem_map.get(key.as_str()).copied(); async move { match install_single_gem(ruby, gem_home, &gem, local_path).await { Ok(()) => Ok(()), Err(first_err) => { // Parallel `gem install` processes can race when reading // each other's partially-written gemspec files, causing // transient failures (especially on Windows/NTFS). Retry // once after a random delay to let the other process finish. let delay = rand::rng().random_range(50..=500); debug!( "gem install {} failed, retrying in {delay}ms: {first_err:#}", gem.name ); tokio::time::sleep(Duration::from_millis(delay)).await; install_single_gem(ruby, gem_home, &gem, local_path) .await .with_context(|| { format!("retry also failed (first error: {first_err:#})") }) } } } }) .buffer_unordered(*CONCURRENCY) .try_collect::>() .await; match result { Ok(_) => Ok(()), Err(err) => { // Parallel installs may have partially succeeded (installed // gems remain in GEM_HOME). Fall back to sequential install // which will skip already-installed gems and retry the rest. debug!( "Parallel gem install failed after retry ({err:#}), \ falling back to sequential install" ); install_gems_sequential(ruby, gem_home, &gem_files, additional_dependencies) .await } } } Ok(_) => { debug!("gem install --explain returned no gems, falling back to sequential install"); install_gems_sequential(ruby, gem_home, &gem_files, additional_dependencies).await } Err(err) => { debug!("gem install --explain failed ({err:#}), falling back to sequential install"); install_gems_sequential(ruby, gem_home, &gem_files, additional_dependencies).await } } } #[cfg(test)] mod tests { use super::*; fn gem(name: &str, version: &str, platform: Option<&str>) -> ResolvedGem { ResolvedGem { name: name.into(), version: version.into(), platform: platform.map(Into::into), } } #[test] fn test_parse_explain_output() { let output = "\ Gems to install: unicode-emoji-4.1.0 ruby-progressbar-1.13.0 rubocop-ast-1.44.1 rubocop-1.82.0 "; let gems = parse_explain_output(output); assert_eq!( gems, vec![ gem("unicode-emoji", "4.1.0", None), gem("ruby-progressbar", "1.13.0", None), gem("rubocop-ast", "1.44.1", None), gem("rubocop", "1.82.0", None), ] ); } #[test] fn test_parse_explain_output_empty() { assert!(parse_explain_output("").is_empty()); assert!(parse_explain_output("Gems to install:\n").is_empty()); } #[test] fn test_parse_explain_output_platform_gems() { let output = " prism-1.9.0-x86_64-linux\n json-2.18.1-java\n"; let gems = parse_explain_output(output); assert_eq!( gems, vec![ gem("prism", "1.9.0", Some("x86_64-linux")), gem("json", "2.18.1", Some("java")), ] ); } #[test] fn test_parse_explain_output_edge_cases() { // No version separator assert!(parse_explain_output(" rubocop").is_empty()); // Empty name (leading dash) assert!(parse_explain_output(" -1.0.0").is_empty()); // Pre-release version with dot separator (RubyGems convention) let gems = parse_explain_output(" foo-bar-0.1.0.beta"); assert_eq!(gems, vec![gem("foo-bar", "0.1.0.beta", None)]); } #[test] fn test_resolved_gem_key() { assert_eq!(gem("rubocop", "1.82.0", None).key(), "rubocop-1.82.0"); assert_eq!( gem("prism", "1.9.0", Some("x86_64-linux")).key(), "prism-1.9.0-x86_64-linux" ); } } ================================================ FILE: crates/prek/src/languages/ruby/installer.rs ================================================ use std::env::consts::EXE_EXTENSION; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use itertools::Itertools; use prek_consts::env_vars::EnvVars; use serde::Deserialize; use target_lexicon::{Architecture, Environment, HOST, OperatingSystem, Triple}; use tracing::{debug, trace, warn}; use crate::fs::LockedFile; use crate::http::{REQWEST_CLIENT, download_and_extract_with}; use crate::languages::ruby::RubyRequest; use crate::process::Cmd; use crate::store::Store; const RV_RUBY_DEFAULT_URL: &str = "https://github.com/spinel-coop/rv-ruby"; /// Resolve the rv-ruby mirror base URL and whether it targets github.com. fn rv_ruby_mirror() -> (String, bool) { match EnvVars::var(EnvVars::PREK_RUBY_MIRROR) { Ok(mirror) => { let is_github = is_github_https(&mirror); (mirror, is_github) } Err(_) => (RV_RUBY_DEFAULT_URL.to_string(), true), } } /// Returns a URL compatible with the GitHub Releases API for listing rv-ruby /// versions, and whether the target host is github.com (for auth token /// decisions). /// /// When the mirror is a `github.com` URL, the path is rewritten to use the /// `api.github.com` host (e.g. `https://github.com/org/repo` becomes /// `https://api.github.com/repos/org/repo/releases/latest`). fn rv_ruby_api_url() -> (String, bool) { let (base, is_github) = rv_ruby_mirror(); let url = if is_github { // Rewrite github.com web URL to API URL. let path = base .strip_prefix("https://github.com") .expect("is_github_https should ensure this"); format!("https://api.github.com/repos{path}/releases/latest") } else { format!("{base}/releases/latest") }; (url, is_github) } /// Check whether a URL is an HTTPS URL pointing to github.com. /// Only matches the exact host `github.com` over HTTPS, so won't send /// tokens to other hosts, subdomains, path-injection attempts, /// userinfo-based redirects, or plaintext HTTP. fn is_github_https(url: &str) -> bool { (url.starts_with("https://github.com/") || url.starts_with("https://github.com:")) && !url.contains('@') } /// Returns the base URL for downloading rv-ruby release assets, and whether /// the target host is github.com (for auth token decisions). fn rv_ruby_download_base() -> (String, bool) { let (base, is_github) = rv_ruby_mirror(); (format!("{base}/releases/latest/download"), is_github) } /// Conditionally add a GitHub auth token to a request builder. /// Only sends `GITHUB_TOKEN` when `is_github` is true. fn maybe_add_github_auth(req: reqwest::RequestBuilder, is_github: bool) -> reqwest::RequestBuilder { if is_github { if let Ok(token) = EnvVars::var(EnvVars::GITHUB_TOKEN) { return req.header(http::header::AUTHORIZATION, format!("Bearer {token}")); } } req } #[derive(Deserialize)] struct GitHubRelease { assets: Vec, } #[derive(Deserialize)] struct GitHubAsset { name: String, } /// Returns the rv-ruby release asset platform suffix for the current target. /// /// These strings must match the asset filenames published by rv-ruby /// (e.g. `ruby-3.4.8.arm64_linux_musl.tar.gz`). The canonical source is /// `HostPlatform::ruby_arch_str()` in rv's `rv-platform` crate: /// /// /// The macOS names (`ventura`, `arm64_sonoma`) are Homebrew bottle tags currently /// pinned by rv-ruby's packaging script. rv currently build using macOS 15 on Intel /// which would suggest a 'sequoia' tag, but their packaging script currently renames the /// output to 'ventura'. If this ever changes, this mapping will need to be updated /// accordingly. fn rv_platform_string(triple: &Triple) -> Option<&'static str> { match ( triple.operating_system, triple.architecture, triple.environment, ) { // macOS (OperatingSystem::Darwin(_), Architecture::X86_64, _) => Some("ventura"), (OperatingSystem::Darwin(_), Architecture::Aarch64(_), _) => Some("arm64_sonoma"), // Linux glibc (OperatingSystem::Linux, Architecture::X86_64, Environment::Gnu) => Some("x86_64_linux"), (OperatingSystem::Linux, Architecture::Aarch64(_), Environment::Gnu) => Some("arm64_linux"), // Linux musl (Alpine) (OperatingSystem::Linux, Architecture::X86_64, Environment::Musl) => { Some("x86_64_linux_musl") } (OperatingSystem::Linux, Architecture::Aarch64(_), Environment::Musl) => { Some("arm64_linux_musl") } // unsupported OS/CPU/libc combination _ => None, } } /// Result of finding/installing a Ruby interpreter #[derive(Debug)] pub(crate) struct RubyResult { /// Path to ruby executable ruby_bin: PathBuf, /// Ruby version version: semver::Version, /// Ruby engine (ruby, jruby, truffleruby) engine: String, } impl RubyResult { pub(crate) fn ruby_bin(&self) -> &Path { &self.ruby_bin } pub(crate) fn version(&self) -> &semver::Version { &self.version } pub(crate) fn engine(&self) -> &str { &self.engine } } /// Ruby installer that finds or installs Ruby interpreters pub(crate) struct RubyInstaller { root: PathBuf, } impl RubyInstaller { pub(crate) fn new(root: PathBuf) -> Self { Self { root } } /// Main installation entry point pub(crate) async fn install( &self, store: &Store, request: &RubyRequest, allows_download: bool, ) -> Result { fs_err::tokio::create_dir_all(&self.root).await?; let _lock = LockedFile::acquire(self.root.join(".lock"), "ruby").await?; // 1. Check previously downloaded rubies if let Some(ruby) = self.find_installed(request) { trace!( "Using managed Ruby: {} at {}", ruby.version(), ruby.ruby_bin().display() ); return Ok(ruby); } // 2. Check system Ruby (PATH + version managers) if let Some(ruby) = self.find_system_ruby(request).await? { trace!( "Using system Ruby: {} at {}", ruby.version(), ruby.ruby_bin().display() ); return Ok(ruby); } // 3. Download if allowed and platform is supported if !allows_download { anyhow::bail!(ruby_not_found_error( request, // allows_download can only be false if the original request was // for any version of ruby, but system-only. "Automatic installation is disabled (language_version: system)." )); } let Some(platform) = rv_platform_string(&HOST) else { anyhow::bail!(ruby_not_found_error( request, // Windows, unknown CPU, etc. that doesn't have a matching rv-ruby // release asset (that we know about). "Automatic installation is not supported on this platform." )); }; let versions = match self.list_remote_versions(platform).await { Ok(v) => v, Err(e) => { anyhow::bail!( "{}\n\nCaused by:\n {e}", ruby_not_found_error( request, "Failed to fetch available Ruby versions from rv-ruby." ) ); } }; let Some(version) = versions.into_iter().find(|v| request.matches(v, None)) else { anyhow::bail!(ruby_not_found_error( request, &format!("No rv-ruby release found matching: {request}") )); }; self.download(store, &version, platform).await } /// Scan `self.root` for previously downloaded Ruby versions. fn find_installed(&self, request: &RubyRequest) -> Option { fs_err::read_dir(&self.root) .ok()? .flatten() .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir())) .filter_map(|entry| { let version = semver::Version::parse(&entry.file_name().to_string_lossy()).ok()?; let bin_dir = entry.path().join("bin"); let ruby_bin = bin_dir.join("ruby"); let gem_bin = bin_dir.join("gem"); if ruby_bin.exists() && gem_bin.exists() { Some((version, ruby_bin)) } else { None } }) .sorted_unstable_by(|(a, _), (b, _)| b.cmp(a)) // descending .find_map(|(version, ruby_bin)| { if request.matches(&version, Some(&ruby_bin)) { Some(RubyResult { ruby_bin, version, engine: "ruby".to_string(), }) } else { None } }) } /// Fetch available Ruby versions from the rv-ruby GitHub release. async fn list_remote_versions(&self, platform: &str) -> Result> { let (api_url, is_github) = rv_ruby_api_url(); let suffix = format!(".{platform}.tar.gz"); let req = REQWEST_CLIENT .get(&api_url) .header("Accept", "application/vnd.github+json"); let req = maybe_add_github_auth(req, is_github); let response = req .send() .await .with_context(|| format!("Failed to fetch rv-ruby releases from {api_url}"))?; if !response.status().is_success() { let status = response.status(); let hint = if matches!(status.as_u16(), 403 | 429) { " (this may be a rate limit — try setting GITHUB_TOKEN)" } else { "" }; anyhow::bail!("Failed to fetch rv-ruby releases from {api_url}: {status}{hint}"); } let release: GitHubRelease = response .json() .await .context("Failed to parse rv-ruby release JSON")?; let versions = release .assets .iter() .filter_map(|asset| parse_version_from_asset(&asset.name, &suffix)) .sorted_unstable() .rev() .collect(); Ok(versions) } /// Download and extract a specific Ruby version from rv-ruby. /// /// Uses `download_and_extract_with` to inject a `GITHUB_TOKEN` auth header /// for GitHub-hosted mirrors (including private partial mirrors of rv-ruby). async fn download( &self, store: &Store, version: &semver::Version, platform: &str, ) -> Result { let filename = format!("ruby-{version}.{platform}.tar.gz"); let (base_url, is_github) = rv_ruby_download_base(); let url = format!("{base_url}/{filename}"); let version_str = version.to_string(); let target = self.root.join(&version_str); debug!(url = %url, target = %target.display(), "Downloading Ruby {version}"); download_and_extract_with( &url, &filename, store, |req| maybe_add_github_auth(req, is_github), async |extracted| { // rv-ruby tarballs contain: rv-ruby@{version}/{version}/bin/ruby // After strip_component, `extracted` is the rv-ruby@{version}/ directory. // Move the inner {version}/ directory to our target. let inner = extracted.join(&version_str); if !inner.exists() { anyhow::bail!( "Expected directory '{}' inside rv-ruby archive, found: {:?}", version_str, fs_err::read_dir(extracted)? .flatten() .map(|e| e.file_name()) .collect::>() ); } if target.exists() { debug!(target = %target.display(), "Removing existing Ruby"); fs_err::tokio::remove_dir_all(&target).await?; } fs_err::tokio::rename(&inner, &target).await?; Ok(()) }, ) .await .with_context(|| format!("Failed to download Ruby {version} from {url}"))?; Ok(RubyResult { ruby_bin: target.join("bin").join("ruby"), version: version.clone(), engine: "ruby".to_string(), }) } /// Find Ruby in the system PATH async fn find_system_ruby(&self, request: &RubyRequest) -> Result> { // Try all rubies in PATH first if let Ok(ruby_paths) = which::which_all("ruby") { for ruby_path in ruby_paths { if let Some(result) = try_ruby_path(&ruby_path, request).await { return Ok(Some(result)); } } } // If we didn't find a suitable Ruby in PATH, search version manager directories #[cfg(not(target_os = "windows"))] if let Some(result) = search_version_managers(request).await { return Ok(Some(result)); } Ok(None) } } /// Try to use a Ruby at the given path async fn try_ruby_path(ruby_path: &Path, request: &RubyRequest) -> Option { // Check for gem in same directory if let Err(e) = find_gem_for_ruby(ruby_path) { warn!("Ruby at {} has no gem: {}", ruby_path.display(), e); return None; } // Query version and engine match query_ruby_info(ruby_path).await { Ok((version, engine)) => { let result = RubyResult { ruby_bin: ruby_path.to_path_buf(), version, engine, }; if request.matches(&result.version, Some(&result.ruby_bin)) { Some(result) } else { None } } Err(e) => { warn!("Failed to query Ruby at {}: {}", ruby_path.display(), e); None } } } /// Search version manager directories for suitable Ruby installations #[cfg(not(target_os = "windows"))] async fn search_version_managers(request: &RubyRequest) -> Option { let home = EnvVars::var(EnvVars::HOME).ok()?; let home_path = PathBuf::from(home); // Common version manager and Homebrew directories let search_dirs = [ // rvm: ~/.rvm/rubies/ruby-3.4.6/bin/ruby home_path.join(".rvm/rubies"), // rv: ~/.local/share/rv/rubies/3.4.6/bin/ruby home_path.join(".local/share/rv/rubies"), // rv legacy path: ~/.data/rv/rubies/3.4.6/bin/ruby home_path.join(".data/rv/rubies"), // mise: ~/.local/share/mise/installs/ruby/3.4.6/bin/ruby home_path.join(".local/share/mise/installs/ruby"), // rbenv: ~/.rbenv/versions/3.4.6/bin/ruby home_path.join(".rbenv/versions"), // asdf: ~/.asdf/installs/ruby/3.4.6/bin/ruby home_path.join(".asdf/installs/ruby"), // chruby: ~/.rubies/ruby-3.4.6/bin/ruby home_path.join(".rubies"), // chruby system-wide: /opt/rubies/ruby-3.4.6/bin/ruby PathBuf::from("/opt/rubies"), // Homebrew (Apple Silicon): /opt/homebrew/Cellar/ruby/3.4.6/bin/ruby PathBuf::from("/opt/homebrew/Cellar/ruby"), // Homebrew (Intel): /usr/local/Cellar/ruby/3.4.6/bin/ruby PathBuf::from("/usr/local/Cellar/ruby"), // Linuxbrew: /home/linuxbrew/.linuxbrew/Cellar/ruby/3.4.6/bin/ruby PathBuf::from("/home/linuxbrew/.linuxbrew/Cellar/ruby"), // Linuxbrew (user): ~/.linuxbrew/Cellar/ruby/3.4.6/bin/ruby home_path.join(".linuxbrew/Cellar/ruby"), ]; for search_dir in &search_dirs { if let Some(result) = search_ruby_installations(search_dir, request).await { return Some(result); } } None } /// Search a version manager directory for Ruby installations #[cfg(not(target_os = "windows"))] async fn search_ruby_installations(dir: &Path, request: &RubyRequest) -> Option { let entries = std::fs::read_dir(dir).ok()?; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let ruby_path = path.join("bin/ruby"); if ruby_path.exists() { if let Some(result) = try_ruby_path(&ruby_path, request).await { trace!( "Found suitable Ruby in version manager: {}", ruby_path.display() ); return Some(result); } } } None } /// Extract a Ruby version from an rv-ruby release asset name. /// /// Given suffix `.x86_64_linux.tar.gz` and asset `ruby-3.4.8.x86_64_linux.tar.gz`, /// returns `Some(Version(3.4.8))`. Returns `None` for non-matching platforms, /// non-semver versions (e.g. `0.49`), and pre-release versions. fn parse_version_from_asset(name: &str, platform_suffix: &str) -> Option { let name = name.strip_prefix("ruby-")?; let version_str = name.strip_suffix(platform_suffix)?; let version = semver::Version::parse(version_str).ok()?; // Skip pre-release versions (e.g. 3.5.0-preview1) unless explicitly requested if !version.pre.is_empty() { return None; } Some(version) } /// Generate a consistent error message for all "can't get Ruby" scenarios. fn ruby_not_found_error(request: &RubyRequest, reason: &str) -> String { format!( "No suitable Ruby found for request: {request}\n{reason}\nPlease install Ruby manually." ) } /// Find gem executable alongside Ruby fn find_gem_for_ruby(ruby_path: &Path) -> Result { let ruby_dir = ruby_path .parent() .context("Ruby executable has no parent directory")?; // Try various gem executable names (for Windows compatibility) for name in ["gem", "gem.bat", "gem.cmd"] { let gem_path = ruby_dir.join(name).with_extension(EXE_EXTENSION); if gem_path.exists() { return Ok(gem_path); } // Also try without explicit extension let gem_path = ruby_dir.join(name); if gem_path.exists() { return Ok(gem_path); } } anyhow::bail!( "No gem executable found alongside Ruby at {}", ruby_path.display() ) } /// Query Ruby version and engine async fn query_ruby_info(ruby_path: &Path) -> Result<(semver::Version, String)> { let script = "puts RUBY_ENGINE; puts RUBY_VERSION"; let output = Cmd::new(ruby_path, "query ruby version") .arg("-e") .arg(script) .check(true) .output() .await?; let mut lines = str::from_utf8(&output.stdout)?.lines(); let engine = lines.next().unwrap_or("ruby").to_string(); let version_str = lines.next().context("No version in Ruby output")?.trim(); let version = semver::Version::parse(version_str) .with_context(|| format!("Failed to parse Ruby version: {version_str}"))?; Ok((version, engine)) } #[cfg(test)] mod tests { use super::*; use std::fs; use std::str::FromStr; use target_lexicon::Triple; use tempfile::TempDir; /// Mutex to serialize tests that mutate `PREK_RUBY_MIRROR`. static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// RAII guard that serializes env var access and restores the original value on drop. /// Holds the `ENV_MUTEX` lock for its lifetime, so tests using this guard run /// sequentially. Ensures cleanup even if a test panics. struct EnvVarGuard { key: &'static str, saved: Option, _lock: std::sync::MutexGuard<'static, ()>, } impl EnvVarGuard { fn new(key: &'static str) -> Self { let lock = ENV_MUTEX .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let saved = EnvVars::var(key).ok(); Self { key, saved, _lock: lock, } } } impl Drop for EnvVarGuard { fn drop(&mut self) { match &self.saved { Some(v) => unsafe { std::env::set_var(self.key, v) }, None => unsafe { std::env::remove_var(self.key) }, } } } #[test] fn test_ruby_request_display() { assert_eq!(RubyRequest::Any.to_string(), "any"); assert_eq!(RubyRequest::Exact(3, 4, 6).to_string(), "3.4.6"); assert_eq!(RubyRequest::MajorMinor(3, 4).to_string(), "3.4"); assert_eq!(RubyRequest::Major(3).to_string(), "3"); let range = semver::VersionReq::parse(">=3.2").unwrap(); assert_eq!( RubyRequest::Range(range, ">=3.2".to_string()).to_string(), ">=3.2" ); } #[tokio::test] #[cfg(not(target_os = "windows"))] async fn test_search_ruby_installations_empty_dir() { let temp_dir = TempDir::new().unwrap(); let request = RubyRequest::Any; let result = search_ruby_installations(temp_dir.path(), &request).await; assert!(result.is_none()); } #[tokio::test] #[cfg(not(target_os = "windows"))] async fn test_search_ruby_installations_no_ruby() { let temp_dir = TempDir::new().unwrap(); // Create a subdirectory without ruby let ruby_dir = temp_dir.path().join("ruby-3.4.6"); fs::create_dir_all(ruby_dir.join("bin")).unwrap(); let request = RubyRequest::Any; let result = search_ruby_installations(temp_dir.path(), &request).await; assert!(result.is_none()); } #[tokio::test] #[cfg(not(target_os = "windows"))] async fn test_search_ruby_installations_with_file() { let temp_dir = TempDir::new().unwrap(); // Create a subdirectory with a fake ruby file (not executable) let ruby_dir = temp_dir.path().join("ruby-3.4.6"); fs::create_dir_all(ruby_dir.join("bin")).unwrap(); let ruby_path = ruby_dir.join("bin/ruby"); fs::write(&ruby_path, "#!/bin/sh\necho fake ruby").unwrap(); let request = RubyRequest::Any; let result = search_ruby_installations(temp_dir.path(), &request).await; // Result should be None because the fake ruby won't execute properly // This test verifies the function handles execution failures gracefully assert!(result.is_none()); } #[test] fn test_ruby_not_found_error() { let error = ruby_not_found_error(&RubyRequest::Exact(3, 4, 6), "Some reason."); assert!(error.contains("3.4.6")); assert!(error.contains("No suitable Ruby found")); assert!(error.contains("Some reason.")); assert!(error.contains("Please install Ruby manually.")); let error = ruby_not_found_error(&RubyRequest::Any, "Another reason."); assert!(error.contains("any")); assert!(error.contains("Another reason.")); } #[test] fn test_rv_ruby_urls_default() { let _guard = EnvVarGuard::new(EnvVars::PREK_RUBY_MIRROR); unsafe { std::env::remove_var(EnvVars::PREK_RUBY_MIRROR) }; let (api_url, api_is_github) = rv_ruby_api_url(); assert_eq!( api_url, "https://api.github.com/repos/spinel-coop/rv-ruby/releases/latest" ); assert!(api_is_github); let (dl_url, dl_is_github) = rv_ruby_download_base(); assert_eq!( dl_url, format!("{RV_RUBY_DEFAULT_URL}/releases/latest/download") ); assert!(dl_is_github); } #[test] fn test_rv_ruby_urls_github_mirror() { // A github.com mirror: API URL is rewritten, download URL uses web URL. let _guard = EnvVarGuard::new(EnvVars::PREK_RUBY_MIRROR); unsafe { std::env::set_var( EnvVars::PREK_RUBY_MIRROR, "https://github.com/myorg/vetted-rubies", ); } let (api_url, api_is_github) = rv_ruby_api_url(); assert_eq!( api_url, "https://api.github.com/repos/myorg/vetted-rubies/releases/latest" ); assert!(api_is_github); let (dl_url, dl_is_github) = rv_ruby_download_base(); assert_eq!( dl_url, "https://github.com/myorg/vetted-rubies/releases/latest/download" ); assert!(dl_is_github); } #[test] fn test_rv_ruby_urls_non_github_mirror() { // A non-github mirror: both URLs use the mirror as-is, is_github is false. let _guard = EnvVarGuard::new(EnvVars::PREK_RUBY_MIRROR); unsafe { std::env::set_var( EnvVars::PREK_RUBY_MIRROR, "https://my-mirror.example.com/rv-ruby", ); } let (api_url, api_is_github) = rv_ruby_api_url(); assert_eq!( api_url, "https://my-mirror.example.com/rv-ruby/releases/latest" ); assert!(!api_is_github); let (dl_url, dl_is_github) = rv_ruby_download_base(); assert_eq!( dl_url, "https://my-mirror.example.com/rv-ruby/releases/latest/download" ); assert!(!dl_is_github); } #[test] fn test_find_gem_for_ruby_missing() { let temp_dir = TempDir::new().unwrap(); let ruby_path = temp_dir.path().join("bin/ruby"); // Create parent dir but no gem fs::create_dir_all(temp_dir.path().join("bin")).unwrap(); fs::write(&ruby_path, "fake").unwrap(); let result = find_gem_for_ruby(&ruby_path); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("No gem executable found") ); } #[test] fn test_find_gem_for_ruby_found() { let temp_dir = TempDir::new().unwrap(); let bin_dir = temp_dir.path().join("bin"); fs::create_dir_all(&bin_dir).unwrap(); let ruby_path = bin_dir.join("ruby"); let gem_path = bin_dir.join("gem"); fs::write(&ruby_path, "fake ruby").unwrap(); fs::write(&gem_path, "fake gem").unwrap(); let result = find_gem_for_ruby(&ruby_path); assert!(result.is_ok()); assert_eq!(result.unwrap(), gem_path); } #[test] fn test_parse_version_from_asset() { let suffix = ".x86_64_linux.tar.gz"; // Standard version assert_eq!( parse_version_from_asset("ruby-3.4.8.x86_64_linux.tar.gz", suffix), Some(semver::Version::new(3, 4, 8)) ); // Different version assert_eq!( parse_version_from_asset("ruby-3.3.0.x86_64_linux.tar.gz", suffix), Some(semver::Version::new(3, 3, 0)) ); // Wrong platform: should not match assert_eq!( parse_version_from_asset("ruby-3.4.8.arm64_linux.tar.gz", suffix), None ); // Pre-release: filtered out assert_eq!( parse_version_from_asset("ruby-3.5.0-preview1.x86_64_linux.tar.gz", suffix), None ); // Non-semver (two components): filtered out assert_eq!( parse_version_from_asset("ruby-0.49.x86_64_linux.tar.gz", suffix), None ); // Not a ruby asset assert_eq!( parse_version_from_asset("something-else.tar.gz", suffix), None ); } #[test] fn test_rv_platform_string_for_macos() { let intel = Triple::from_str("x86_64-apple-darwin").unwrap(); assert_eq!(rv_platform_string(&intel), Some("ventura")); let arm = Triple::from_str("aarch64-apple-darwin").unwrap(); assert_eq!(rv_platform_string(&arm), Some("arm64_sonoma")); } #[test] fn test_rv_platform_string_for_linux() { let gnu = Triple::from_str("x86_64-unknown-linux-gnu").unwrap(); assert_eq!(rv_platform_string(&gnu), Some("x86_64_linux")); let arm_gnu = Triple::from_str("aarch64-unknown-linux-gnu").unwrap(); assert_eq!(rv_platform_string(&arm_gnu), Some("arm64_linux")); let musl = Triple::from_str("x86_64-unknown-linux-musl").unwrap(); assert_eq!(rv_platform_string(&musl), Some("x86_64_linux_musl")); let arm_musl = Triple::from_str("aarch64-unknown-linux-musl").unwrap(); assert_eq!(rv_platform_string(&arm_musl,), Some("arm64_linux_musl")); } #[test] fn test_rv_platform_string_unsupported() { let windows = Triple::from_str("x86_64-pc-windows-msvc").unwrap(); assert_eq!(rv_platform_string(&windows), None); let linux_unknown_libc = Triple::from_str("x86_64-unknown-linux-gnux32").unwrap(); assert_eq!(rv_platform_string(&linux_unknown_libc), None); } #[test] fn test_find_installed_empty_dir() { let temp_dir = TempDir::new().unwrap(); let installer = RubyInstaller::new(temp_dir.path().to_path_buf()); assert!(installer.find_installed(&RubyRequest::Any).is_none()); } #[test] fn test_find_installed_with_versions() { let temp_dir = TempDir::new().unwrap(); // Create fake Ruby installations for version in ["3.3.5", "3.4.8", "3.2.1"] { let bin_dir = temp_dir.path().join(version).join("bin"); fs::create_dir_all(&bin_dir).unwrap(); fs::write(bin_dir.join("ruby"), "fake").unwrap(); fs::write(bin_dir.join("gem"), "fake").unwrap(); } let installer = RubyInstaller::new(temp_dir.path().to_path_buf()); // Any: should return highest version let result = installer.find_installed(&RubyRequest::Any).unwrap(); assert_eq!(*result.version(), semver::Version::new(3, 4, 8)); // MajorMinor(3, 3): should return 3.3.5 let result = installer .find_installed(&RubyRequest::MajorMinor(3, 3)) .unwrap(); assert_eq!(*result.version(), semver::Version::new(3, 3, 5)); // Exact match let result = installer .find_installed(&RubyRequest::Exact(3, 2, 1)) .unwrap(); assert_eq!(*result.version(), semver::Version::new(3, 2, 1)); // No match assert!( installer .find_installed(&RubyRequest::MajorMinor(2, 7)) .is_none() ); } #[test] fn test_is_github_https() { // Exact match over HTTPS assert!(is_github_https("https://github.com/spinel-coop/rv-ruby")); assert!(is_github_https("https://github.com:443/org/repo")); // Plaintext HTTP — don't leak tokens assert!(!is_github_https("http://github.com/org/repo")); // Not github.com assert!(!is_github_https("https://gitlab.com/org/repo")); assert!(!is_github_https("https://my-mirror.example.com/rv-ruby")); // Path injection — github.com in path, not host assert!(!is_github_https("https://evil.com/github.com/rv")); // Subdomain — not the same host assert!(!is_github_https("https://api.github.com/repos/org/repo")); // Userinfo-based redirect assert!(!is_github_https("https://github.com@evil.com/org/repo")); assert!(!is_github_https( "https://github.com:password@evil.com/org/repo" )); assert!(!is_github_https("https://evil.com@github.com/org/repo")); // Other schemes assert!(!is_github_https("ftp://github.com/org/repo")); } #[test] fn test_find_installed_skips_incomplete_dirs() { let temp_dir = TempDir::new().unwrap(); // Version dir with ruby but no gem let bin_dir = temp_dir.path().join("3.4.8").join("bin"); fs::create_dir_all(&bin_dir).unwrap(); fs::write(bin_dir.join("ruby"), "fake").unwrap(); // Version dir with no bin at all fs::create_dir_all(temp_dir.path().join("3.3.0")).unwrap(); // Non-version directory fs::create_dir_all(temp_dir.path().join("not-a-version").join("bin")).unwrap(); let installer = RubyInstaller::new(temp_dir.path().to_path_buf()); assert!(installer.find_installed(&RubyRequest::Any).is_none()); } } ================================================ FILE: crates/prek/src/languages/ruby/mod.rs ================================================ #![warn(dead_code)] #![warn(clippy::missing_errors_doc)] #![warn(clippy::missing_panics_doc)] #![warn(clippy::must_use_candidate)] #![warn(clippy::module_name_repetitions)] #![warn(clippy::too_many_arguments)] mod gem; mod installer; #[allow(clippy::module_inception)] mod ruby; mod version; pub(crate) use ruby::Ruby; pub(crate) use version::RubyRequest; ================================================ FILE: crates/prek/src/languages/ruby/ruby.rs ================================================ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::languages::ruby::RubyRequest; use crate::languages::ruby::gem::{build_gemspecs, install_gems}; use crate::languages::ruby::installer::RubyInstaller; use crate::languages::version::LanguageRequest; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::{Store, ToolBucket}; #[derive(Debug, Copy, Clone)] pub(crate) struct Ruby; impl LanguageImpl for Ruby { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result { let progress = reporter.on_install_start(&hook); // 1. Install Ruby let ruby_dir = store.tools_path(ToolBucket::Ruby); let installer = RubyInstaller::new(ruby_dir); let (request, allows_download) = match &hook.language_request { LanguageRequest::Any { system_only } => (&RubyRequest::Any, !system_only), LanguageRequest::Ruby(req) => (req, true), _ => unreachable!(), }; let ruby = installer .install(store, request, allows_download) .await .context("Failed to install Ruby")?; // 2. Create InstallInfo let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; info.with_toolchain(ruby.ruby_bin().to_path_buf()) .with_language_version(ruby.version().clone()); // Store Ruby engine in metadata info.with_extra("ruby_engine", ruby.engine()); // 3. Create environment directories let gem_home = gem_home(&info.env_path); fs_err::tokio::create_dir_all(&gem_home).await?; fs_err::tokio::create_dir_all(gem_home.join("bin")).await?; // 4. Build gemspecs if let Some(repo_path) = hook.repo_path() { // Try to build gemspecs, but don't fail if there aren't any match build_gemspecs(&ruby, repo_path).await { Ok(gem_files) => { debug!("Built {} gem(s) from gemspecs", gem_files.len()); } Err(e) if e.to_string().contains("No .gemspec files") => { debug!("No gemspecs found in repo, skipping gem build"); } Err(e) => return Err(e).context("Failed to build gemspecs"), } } // 5. Install gems (Note that pre-commit installs all *.gem files, not only those built from gemspecs) install_gems( &ruby, &gem_home, hook.repo_path(), &hook.additional_dependencies, ) .await .context("Failed to install gems")?; info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, info: &InstallInfo) -> Result<()> { // 1. Verify Ruby executable exists if !info.toolchain.exists() { anyhow::bail!("Ruby executable not found at {}", info.toolchain.display()); } // 2. Verify it runs and reports correct version let script = "puts RUBY_VERSION"; let output = Cmd::new(&info.toolchain, "check ruby version") .arg("-e") .arg(script) .check(true) .output() .await?; let version_str = str::from_utf8(&output.stdout)?.trim(); let actual_version = semver::Version::parse(version_str) .with_context(|| format!("Failed to parse Ruby version: {version_str}"))?; if actual_version != info.language_version { anyhow::bail!( "Ruby version mismatch: expected {}, found {}", info.language_version, actual_version ); } // 3. Verify gem home exists let gem_home = gem_home(&info.env_path); if !gem_home.exists() { anyhow::bail!("Gem home directory not found at {}", gem_home.display()); } // 4. Verify gem bin directory exists let gem_bin = gem_home.join("bin"); if !gem_bin.exists() { anyhow::bail!("Gem bin directory not found at {}", gem_bin.display()); } Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let env_dir = hook.env_path().expect("Ruby hook must have env path"); // Prepare PATH let gem_home = gem_home(env_dir); let gem_bin = gem_home.join("bin"); let ruby_bin = hook .toolchain_dir() .expect("Ruby toolchain should have parent"); let new_path = prepend_paths(&[&gem_bin, ruby_bin]).context("Failed to join PATH")?; // Resolve entry point let entry = hook.entry.resolve(Some(&new_path))?; // Execute in batches let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "ruby hook") .current_dir(hook.work_dir()) .env(EnvVars::PATH, &new_path) .env(EnvVars::GEM_HOME, &gem_home) .env(EnvVars::BUNDLE_IGNORE_CONFIG, "1") .env_remove(EnvVars::GEM_PATH) .env_remove(EnvVars::BUNDLE_GEMFILE) .envs(&hook.env) .args(&entry[1..]) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); // Combine results let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } /// Get the `GEM_HOME` path for this environment fn gem_home(env_path: &Path) -> PathBuf { env_path.join("gems") } ================================================ FILE: crates/prek/src/languages/ruby/version.rs ================================================ use std::fmt; use std::path::{Path, PathBuf}; use std::str::FromStr; use crate::hook::InstallInfo; use crate::languages::version::{Error, try_into_u64_slice}; /// Ruby version request parsed from `language_version` field #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) enum RubyRequest { /// Any available Ruby (prefer system, then latest) Any, /// Exact major.minor.patch version Exact(u64, u64, u64), /// Major.minor (latest patch) MajorMinor(u64, u64), /// Major version (latest minor.patch) Major(u64), /// Explicit file path to Ruby interpreter Path(PathBuf), /// Semver range (e.g., ">=3.2, <4.0") Range(semver::VersionReq, String), } impl fmt::Display for RubyRequest { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Any => f.write_str("any"), Self::Exact(maj, min, patch) => write!(f, "{maj}.{min}.{patch}"), Self::MajorMinor(maj, min) => write!(f, "{maj}.{min}"), Self::Major(maj) => write!(f, "{maj}"), Self::Path(p) => write!(f, "{}", p.display()), Self::Range(_, s) => f.write_str(s), } } } impl FromStr for RubyRequest { type Err = Error; fn from_str(s: &str) -> Result { // Empty/default if s.is_empty() { return Ok(Self::Any); } // Strip "ruby-" prefix if present if let Some(version_part) = s.strip_prefix("ruby") { let version_part = version_part.strip_prefix('-').unwrap_or(version_part); if version_part.is_empty() { return Ok(Self::Any); } // Only allow version numbers after "ruby" prefix return Self::parse_version_numbers(version_part, s); } // Try parsing as version numbers (any of one to three parts) if let Ok(req) = Self::parse_version_numbers(s, s) { return Ok(req); } // Try parsing as semver range if let Ok(req) = semver::VersionReq::parse(s) { return Ok(Self::Range(req, s.to_string())); } // Finally try as a file path let path = PathBuf::from(s); if path.exists() { return Ok(Self::Path(path)); } Err(Error::InvalidVersion(s.to_string())) } } impl RubyRequest { /// Check if this request accepts any Ruby version pub(crate) fn is_any(&self) -> bool { matches!(self, Self::Any) } /// Parse version numbers into appropriate `RubyRequest` variants fn parse_version_numbers( version_str: &str, original_request: &str, ) -> Result { let parts = try_into_u64_slice(version_str) .map_err(|_| Error::InvalidVersion(original_request.to_string()))?; match parts.as_slice() { [major] => Ok(RubyRequest::Major(*major)), [major, minor] => Ok(RubyRequest::MajorMinor(*major, *minor)), [major, minor, patch] => Ok(RubyRequest::Exact(*major, *minor, *patch)), _ => Err(Error::InvalidVersion(original_request.to_string())), } } /// Check if this request matches a Ruby version during installation search /// /// This is used by the installer when searching for existing Ruby installations. pub(crate) fn matches(&self, version: &semver::Version, toolchain: Option<&Path>) -> bool { match self { Self::Any => true, Self::Exact(maj, min, patch) => { version.major == *maj && version.minor == *min && version.patch == *patch } Self::MajorMinor(maj, min) => version.major == *maj && version.minor == *min, Self::Major(maj) => version.major == *maj, // FIXME: consider resolving symlinks and normalizing paths before comparison Self::Path(path) => toolchain.is_some_and(|t| t == path), Self::Range(req, _) => req.matches(version), } } /// Check if this request is satisfied by the given Ruby installation /// /// This is used at runtime to verify an installation meets the requirements. pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { self.matches( &install_info.language_version, Some(&install_info.toolchain), ) } } #[cfg(test)] mod tests { use super::*; use crate::config::Language; use rustc_hash::FxHashSet; #[test] fn test_parse_ruby_request() { // Empty/default assert_eq!(RubyRequest::from_str("").unwrap(), RubyRequest::Any); // Exact versions assert_eq!( RubyRequest::from_str("3.3.6").unwrap(), RubyRequest::Exact(3, 3, 6) ); assert_eq!( RubyRequest::from_str("ruby-3.3.6").unwrap(), RubyRequest::Exact(3, 3, 6) ); // Major.minor assert_eq!( RubyRequest::from_str("3.3").unwrap(), RubyRequest::MajorMinor(3, 3) ); assert_eq!( RubyRequest::from_str("ruby-3.3").unwrap(), RubyRequest::MajorMinor(3, 3) ); // Major only assert_eq!(RubyRequest::from_str("3").unwrap(), RubyRequest::Major(3)); assert_eq!( RubyRequest::from_str("ruby-3").unwrap(), RubyRequest::Major(3) ); // Semver range assert!(matches!( RubyRequest::from_str(">=3.2, <4.0").unwrap(), RubyRequest::Range(_, _) )); assert!(RubyRequest::from_str("ruby>=3.2, <4.0").is_err()); } #[test] fn test_version_matching() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; let mut install_info = InstallInfo::new(Language::Ruby, FxHashSet::default(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(3, 3, 6)) .with_toolchain(PathBuf::from("/usr/bin/ruby")); assert!(RubyRequest::Any.satisfied_by(&install_info)); assert!(RubyRequest::Exact(3, 3, 6).satisfied_by(&install_info)); assert!(RubyRequest::MajorMinor(3, 3).satisfied_by(&install_info)); assert!(RubyRequest::Major(3).satisfied_by(&install_info)); assert!(!RubyRequest::Exact(3, 3, 7).satisfied_by(&install_info)); assert!(!RubyRequest::Exact(3, 2, 6).satisfied_by(&install_info)); // Test path matching assert!(RubyRequest::Path(PathBuf::from("/usr/bin/ruby")).satisfied_by(&install_info)); assert!(!RubyRequest::Path(PathBuf::from("/usr/bin/ruby3.2")).satisfied_by(&install_info)); // Test range matching let req = semver::VersionReq::parse(">=3.2, <4.0")?; assert!( RubyRequest::Range(req.clone(), ">=3.2, <4.0".to_string()).satisfied_by(&install_info) ); let temp_dir = tempfile::tempdir()?; let mut install_info = InstallInfo::new(Language::Ruby, FxHashSet::default(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(3, 1, 0)) .with_toolchain(PathBuf::from("/usr/bin/ruby3.1")); assert!(!RubyRequest::Range(req, ">=3.2, <4.0".to_string()).satisfied_by(&install_info)); Ok(()) } } ================================================ FILE: crates/prek/src/languages/rust/installer.rs ================================================ use std::fmt::Display; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use itertools::Itertools; use prek_consts::env_vars::EnvVars; use semver::Version; use tracing::{debug, trace}; use crate::fs::LockedFile; use crate::languages::rust::RustRequest; use crate::languages::rust::rustup::{Rustup, ToolchainInfo}; use crate::languages::rust::version::{Channel, RustVersion}; use crate::process::Cmd; pub(crate) struct RustResult { toolchain: PathBuf, version: RustVersion, } impl Display for RustResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}@{}", self.toolchain.display(), *self.version)?; Ok(()) } } impl RustResult { pub(crate) fn from_dir(dir: &Path) -> Self { Self { toolchain: dir.to_path_buf(), version: RustVersion::default(), } } pub(crate) fn toolchain(&self) -> &Path { &self.toolchain } pub(crate) fn version(&self) -> &RustVersion { &self.version } pub(crate) fn with_version(mut self, version: RustVersion) -> Self { self.version = version; self } pub(crate) async fn fill_version(mut self) -> Result { let rustc = self .toolchain .join("bin") .join("rustc") .with_extension(std::env::consts::EXE_EXTENSION); let output = Cmd::new(rustc, "rustc --version") .arg("--version") .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") .check(true) .output() .await?; // e.g. "rustc 1.70.0 (90c541806 2023-05-31)" let version_str = str::from_utf8(&output.stdout)?; let version_str = version_str .split_ascii_whitespace() .nth(1) .with_context(|| format!("Failed to parse Rust version from output: {version_str}"))?; let version = Version::parse(version_str)?; let version = RustVersion::from_path(&version, &self.toolchain); self.version = version; Ok(self) } } pub(crate) struct RustInstaller { rustup: Rustup, } impl RustInstaller { pub(crate) fn new(rustup: Rustup) -> Self { Self { rustup } } pub(crate) async fn install( &self, request: &RustRequest, allows_download: bool, ) -> Result { let rustup_home = self.rustup.rustup_home(); fs_err::tokio::create_dir_all(rustup_home).await?; let _lock = LockedFile::acquire(rustup_home.join(".lock"), "rustup").await?; // Check installed if let Ok(rust) = self.find_installed(request).await { trace!(%rust, "Found installed rust"); return Ok(rust); } // Check system rust if let Some(rust) = self.find_system_rust(request).await? { trace!(%rust, "Using system rust"); return Ok(rust); } if !allows_download { anyhow::bail!("No suitable system Rust version found and downloads are disabled"); } // Install new toolchain let toolchain = self.resolve_version(request).await?; self.download(&toolchain).await } async fn find_installed(&self, request: &RustRequest) -> Result { let toolchains: Vec = self.rustup.list_installed_toolchains().await?; let installed = toolchains .into_iter() .sorted_unstable_by(|a, b| b.version.cmp(&a.version)); installed .into_iter() .find_map(|info| { let matches = request.matches(&info.version, Some(&info.path)); if matches { trace!(name = %info.name, "Found matching installed rust"); Some(RustResult::from_dir(&info.path).with_version(info.version)) } else { trace!(name = %info.name, "Installed rust does not match request"); None } }) .context("No installed rust version matches the request") } async fn find_system_rust(&self, rust_request: &RustRequest) -> Result> { let toolchains: Vec = self.rustup.list_system_toolchains().await?; let installed = toolchains .into_iter() .sorted_unstable_by(|a, b| b.version.cmp(&a.version)); for info in installed { let matches = rust_request.matches(&info.version, Some(&info.path)); if matches { trace!(name = %info.name, "Found matching system rust"); let rust = RustResult::from_dir(&info.path).with_version(info.version); return Ok(Some(rust)); } trace!(name = %info.name, "System rust does not match request"); } debug!( ?rust_request, "No system rust matches the requested version" ); Ok(None) } async fn resolve_version(&self, req: &RustRequest) -> Result { match req { RustRequest::Any => Ok(RustVersion::from_channel(Channel::Stable)), RustRequest::Channel(ch) => Ok(RustVersion::from_channel(*ch)), RustRequest::Major(_) | RustRequest::MajorMinor(_, _) | RustRequest::MajorMinorPatch(_, _, _) | RustRequest::Range(_, _) => { let output = crate::git::git_cmd("list rust tags")? .arg("ls-remote") .arg("--tags") .arg("https://github.com/rust-lang/rust") .output() .await? .stdout; let versions: Vec = str::from_utf8(&output)? .lines() .filter_map(|line| { let tag = line.split('\t').nth(1)?; let tag = tag.strip_prefix("refs/tags/")?; Version::parse(tag) .ok() .map(|v| RustVersion::from_version(&v)) }) .sorted_unstable_by(|a, b| b.cmp(a)) .collect(); let version = versions .into_iter() .find(|version| req.matches(version, None)) .with_context(|| format!("Version `{req}` not found on remote"))?; Ok(version) } } } async fn download(&self, toolchain: &RustVersion) -> Result { let toolchain = toolchain.to_toolchain_name(); debug!(%toolchain, "Installing Rust toolchain"); let toolchain_dir = self .rustup .install_toolchain(&toolchain) .await .context("Failed to install Rust toolchain")?; let rust = RustResult::from_dir(&toolchain_dir).fill_version().await?; Ok(rust) } } ================================================ FILE: crates/prek/src/languages/rust/mod.rs ================================================ mod installer; #[allow(clippy::module_inception)] mod rust; mod rustup; mod version; pub(crate) use rust::Rust; pub(crate) use version::RustRequest; ================================================ FILE: crates/prek/src/languages/rust/rust.rs ================================================ use std::env::consts::EXE_EXTENSION; use std::ffi::OsStr; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::str::FromStr; use std::sync::Arc; use anyhow::{Context, bail}; use itertools::{Either, Itertools}; use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::languages::rust::RustRequest; use crate::languages::rust::installer::RustInstaller; use crate::languages::rust::rustup::Rustup; use crate::languages::rust::version::EXTRA_KEY_CHANNEL; use crate::languages::version::LanguageRequest; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::{CacheBucket, Store, ToolBucket}; fn format_cargo_dependency(dep: &str) -> String { let (name, version) = dep.split_once(':').unwrap_or((dep, "")); if version.is_empty() { format!("{name}@*") } else { format!("{name}@{version}") } } #[derive(Debug, Eq, PartialEq)] enum CargoCliDependency { Crate { name: String, version: Option, }, Git { url: String, tag: Option, package: Option, }, } impl FromStr for CargoCliDependency { type Err = anyhow::Error; fn from_str(s: &str) -> Result { let is_url = s.starts_with("http://") || s.starts_with("https://"); if is_url { let scheme_end = s .find("://") .map(|idx| idx + 3) .with_context(|| format!("Invalid git URL `{s}`"))?; let rest = &s[scheme_end..]; let parts: Vec<&str> = rest.rsplitn(3, ':').collect(); let (url_without_scheme, tag, package) = match parts.as_slice() { [url] => (*url, None, None), [tag, url] => { if tag.is_empty() { bail!( "Git CLI dependency `{s}` contains an empty tag; use `cli:`, `cli::`, or `cli:::`" ); } (*url, Some(*tag), None) } [package, tag, url] => { if package.is_empty() { bail!( "Git CLI dependency `{s}` must specify a non-empty package when using `cli:::`" ); } let tag = if tag.is_empty() { None } else { Some(*tag) }; (*url, tag, Some(*package)) } _ => unreachable!(), }; let url = format!("{}{}", &s[..scheme_end], url_without_scheme); Ok(CargoCliDependency::Git { url, tag: tag.map(ToString::to_string), package: package.map(ToString::to_string), }) } else { let (name, version) = if let Some((pkg, ver)) = s.rsplit_once(':') { (pkg.to_string(), Some(ver.to_string())) } else { (s.to_string(), None) }; Ok(CargoCliDependency::Crate { name, version }) } } } impl CargoCliDependency { fn to_cargo_args(&self) -> Vec<&str> { let mut args: Vec<&str> = Vec::with_capacity(2); match self { CargoCliDependency::Crate { name, version } => { args.push(name); if let Some(version) = version { args.push("--version"); args.push(version); } } CargoCliDependency::Git { url, tag, package } => { args.push("--git"); args.push(url); if let Some(tag) = tag { args.push("--tag"); args.push(tag); } if let Some(package) = package { args.push(package); } } } args } } /// Find the package directory that produces the given binary. /// Returns (`package_dir`, `package_name`, `is_workspace`). async fn find_package_dir( repo: &Path, binary_name: &str, cargo: Option<&Path>, cargo_home: Option<&Path>, new_path: Option<&OsStr>, ) -> anyhow::Result> { let cargo = cargo.unwrap_or(Path::new("cargo")); let mut cmd = Cmd::new(cargo, "cargo metadata"); if let Some(new_path) = new_path { cmd.env(EnvVars::PATH, new_path); } if let Some(cargo_home) = cargo_home { cmd.env(EnvVars::CARGO_HOME, cargo_home); } let output = cmd .arg("metadata") .arg("--format-version") .arg("1") .arg("--no-deps") .arg("--manifest-path") .arg(repo.join("Cargo.toml")) .output() .await?; let stdout = str::from_utf8(&output.stdout)? .lines() .find(|line| line.starts_with('{')) .ok_or(cargo_metadata::Error::NoJson)?; let metadata: cargo_metadata::Metadata = serde_json::from_str(stdout).context("Failed to parse cargo metadata output")?; // Search all workspace packages for one that produces this binary for package_id in &metadata.workspace_members { let package = metadata .packages .iter() .find(|p| &p.id == package_id) .with_context(|| format!("Package not found in metadata for id: {package_id}"))?; if package_produces_binary(package, binary_name) { let package_dir = package .manifest_path .parent() .expect("manifest should have parent") .as_std_path() .to_path_buf(); // It's a workspace if either: // - there are multiple members, OR // - the package is not at the workspace root let is_workspace = metadata.workspace_members.len() > 1 || package_dir != metadata.workspace_root.as_std_path(); return Ok(Some((package_dir, package.name.to_string(), is_workspace))); } } Ok(None) } /// Check if two names match, accounting for hyphen/underscore normalization. fn names_match(a: &str, b: &str) -> bool { a == b || a.replace('-', "_") == b.replace('-', "_") } /// Check if a package produces a binary with the given name. fn package_produces_binary(package: &cargo_metadata::Package, binary_name: &str) -> bool { package .targets .iter() .filter(|t| t.is_bin()) .any(|t| names_match(&t.name, binary_name)) } /// Copy executable binaries from a release directory to a destination bin directory. async fn copy_binaries(release_dir: &Path, dest_bin_dir: &Path) -> anyhow::Result<()> { let mut entries = fs_err::tokio::read_dir(release_dir).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); let file_type = entry.file_type().await?; // Copy executable files (not directories, not .d files, etc.) if file_type.is_file() { if let Some(ext) = path.extension() { // Skip non-binary files like .d, .rlib, etc. if ext == "d" || ext == "rlib" || ext == "rmeta" { continue; } } // On Unix, check if it's executable; on Windows, check for .exe #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let meta = entry.metadata().await?; if meta.permissions().mode() & 0o111 != 0 { let dest = dest_bin_dir.join(entry.file_name()); fs_err::tokio::copy(&path, &dest).await?; } } #[cfg(windows)] { if path.extension().is_some_and(|e| e == "exe") { let dest = dest_bin_dir.join(entry.file_name()); fs_err::tokio::copy(&path, &dest).await?; } } } } Ok(()) } async fn install_local_project( hook_binary: &str, repo_path: &Path, info: &InstallInfo, lib_deps: &[&String], cargo: &Path, cargo_home: &Path, new_path: &OsStr, ) -> anyhow::Result<()> { // Find the specific package directory for this hook's binary let (package_dir, package_name, is_workspace) = match find_package_dir( repo_path, hook_binary, Some(cargo), Some(cargo_home), Some(new_path), ) .await { Err(e) => { return Err(e.context("Failed to find package directory using cargo metadata")); } Ok(Some((package_dir, package_name, is_workspace))) => { debug!( "Found package `{}` for binary `{}` in repo `{}` at `{}`", package_name, hook_binary, repo_path.display(), package_dir.display(), ); (package_dir, package_name, is_workspace) } Ok(None) => { debug!( "Binary `{}` not found in cargo metadata for repo `{}`, falling back to repo root", hook_binary, repo_path.display(), ); (repo_path.to_path_buf(), String::new(), false) } }; if lib_deps.is_empty() { // For packages without lib deps, use `cargo install` directly Cmd::new(cargo, "install local") .args(["install", "--bins", "--root"]) .arg(&info.env_path) .args(["--path", ".", "--locked"]) .current_dir(&package_dir) .env(EnvVars::PATH, new_path) .env(EnvVars::CARGO_HOME, cargo_home) .remove_git_envs() .check(true) .output() .await?; } else { // For packages with lib deps, copy manifest, modify, build and copy binaries let manifest_dir = info.env_path.join("manifest"); fs_err::tokio::create_dir_all(&manifest_dir).await?; // Copy Cargo.toml let src_manifest = package_dir.join("Cargo.toml"); let dst_manifest = manifest_dir.join("Cargo.toml"); fs_err::tokio::copy(&src_manifest, &dst_manifest).await?; // Copy Cargo.lock if it exists (check both package dir and repo root for workspaces) let lock_locations = if is_workspace { vec![repo_path.join("Cargo.lock"), package_dir.join("Cargo.lock")] } else { vec![package_dir.join("Cargo.lock")] }; for lock_path in lock_locations { if lock_path.exists() { fs_err::tokio::copy(&lock_path, manifest_dir.join("Cargo.lock")).await?; break; } } // Copy src directory (cargo add needs it to exist for path validation) let src_dir = package_dir.join("src"); if src_dir.exists() { let dst_src = manifest_dir.join("src"); fs_err::tokio::create_dir_all(&dst_src).await?; let mut entries = fs_err::tokio::read_dir(&src_dir).await?; while let Some(entry) = entries.next_entry().await? { if entry.file_type().await?.is_file() { fs_err::tokio::copy(entry.path(), dst_src.join(entry.file_name())).await?; } } } // Run cargo add on the copied manifest let mut cmd = Cmd::new(cargo, "add dependencies"); cmd.arg("add"); for dep in lib_deps { cmd.arg(format_cargo_dependency(dep.as_str())); } cmd.current_dir(&manifest_dir) .env(EnvVars::PATH, new_path) .env(EnvVars::CARGO_HOME, cargo_home) .remove_git_envs() .check(true) .output() .await?; // Build using cargo build with --manifest-path pointing to modified manifest // but source files come from original package_dir let target_dir = info.env_path.join("target"); let mut cmd = Cmd::new(cargo, "build local with deps"); cmd.args(["build", "--bins", "--release"]) .arg("--manifest-path") .arg(&dst_manifest) .arg("--target-dir") .arg(&target_dir); // For workspace members, explicitly specify the package if is_workspace && !package_name.is_empty() { cmd.args(["--package", &package_name]); } cmd.current_dir(&package_dir) .env(EnvVars::PATH, new_path) .env(EnvVars::CARGO_HOME, cargo_home) .remove_git_envs() .check(true) .output() .await?; // Copy compiled binaries to the bin directory copy_binaries(&target_dir.join("release"), &bin_dir(&info.env_path)).await?; // Clean up manifest and target directories fs_err::tokio::remove_dir_all(&manifest_dir).await?; fs_err::tokio::remove_dir_all(&target_dir).await?; } Ok(()) } async fn install_cli_dependency( cli_dep: &str, info: &InstallInfo, cargo: &Path, cargo_home: &Path, new_path: &OsStr, ) -> anyhow::Result<()> { let dep = CargoCliDependency::from_str(cli_dep)?; let mut cmd = Cmd::new(cargo, "install cli dep"); cmd.args(["install", "--bins", "--root"]) .arg(&info.env_path) .args(dep.to_cargo_args()) .arg("--locked"); cmd.env(EnvVars::PATH, new_path) .env(EnvVars::CARGO_HOME, cargo_home) .remove_git_envs() .check(true) .output() .await?; Ok(()) } #[derive(Debug, Copy, Clone)] pub(crate) struct Rust; impl LanguageImpl for Rust { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> anyhow::Result { let progress = reporter.on_install_start(&hook); // 1. Install Rust let cargo_home = store.cache_path(CacheBucket::Cargo); let rustup_dir = store.tools_path(ToolBucket::Rustup); let rustup = Rustup::install(store, &rustup_dir).await?; let installer = RustInstaller::new(rustup); let (version, allows_download) = match &hook.language_request { LanguageRequest::Any { system_only } => (&RustRequest::Any, !system_only), LanguageRequest::Rust(version) => (version, true), _ => unreachable!(), }; let rust = installer .install(version, allows_download) .await .context("Failed to install rust")?; let rustc_bin = bin_dir(rust.toolchain()); let cargo = rustc_bin.join("cargo").with_extension(EXE_EXTENSION); // Add toolchain bin to PATH, for cargo to use correct rustc let new_path = prepend_paths(&[&rustc_bin]).context("Failed to join PATH")?; let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; info.with_toolchain(rust.toolchain().to_path_buf()) .with_language_version(rust.version().deref().clone()); // Store the channel name for cache matching match version { RustRequest::Channel(channel) => { info.with_extra(EXTRA_KEY_CHANNEL, &channel.to_string()); } RustRequest::Any => { // Any resolves to "stable" in resolve_version info.with_extra(EXTRA_KEY_CHANNEL, "stable"); } _ => {} } // 2. Create environment fs_err::tokio::create_dir_all(bin_dir(&info.env_path)).await?; // 3. Install dependencies // Split dependencies by cli: prefix let (cli_deps, lib_deps): (Vec<_>, Vec<_>) = hook.additional_dependencies.iter().partition_map(|dep| { if let Some(stripped) = dep.strip_prefix("cli:") { Either::Left(stripped) } else { Either::Right(dep) } }); // Use the hook entry as the binary name to find the package, this could be improved by allowing an explicit binary name in the hook config. let hook_entry = hook.entry.split()?; let hook_bin = &hook_entry[0]; // Install library dependencies and local project if let Some(repo) = hook.repo_path() { install_local_project( hook_bin, repo, &info, &lib_deps, &cargo, &cargo_home, &new_path, ) .await?; } // Install CLI dependencies for cli_dep in cli_deps { install_cli_dependency(cli_dep, &info, &cargo, &cargo_home, &new_path).await?; } info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, _info: &InstallInfo) -> anyhow::Result<()> { Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], store: &Store, reporter: &HookRunReporter, ) -> anyhow::Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let env_dir = hook.env_path().expect("Rust hook must have env path"); let info = hook.install_info().expect("Rust hook must be installed"); let rust_bin = bin_dir(env_dir); let cargo_home = store.cache_path(CacheBucket::Cargo); let rustc_bin = bin_dir(&info.toolchain); let new_path = prepend_paths(&[&rust_bin, &rustc_bin]).context("Failed to join PATH")?; let entry = hook.entry.resolve(Some(&new_path))?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "rust hook") .current_dir(hook.work_dir()) .args(&entry[1..]) .env(EnvVars::PATH, &new_path) .env(EnvVars::CARGO_HOME, &cargo_home) .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") .envs(&hook.env) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } pub(crate) fn bin_dir(env_path: &Path) -> PathBuf { env_path.join("bin") } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; async fn write_file(path: &Path, content: &str) { if let Some(parent) = path.parent() { fs_err::tokio::create_dir_all(parent).await.unwrap(); } fs_err::tokio::write(path, content).await.unwrap(); } #[tokio::test] async fn test_find_package_dir_single_package() { let temp = TempDir::new().unwrap(); let cargo_toml = r#" [package] name = "my-tool" version = "0.1.0" edition = "2021" "#; write_file(&temp.path().join("Cargo.toml"), cargo_toml).await; write_file(&temp.path().join("src/main.rs"), "fn main() {}").await; let (path, pkg_name, is_workspace) = find_package_dir(temp.path(), "my-tool", None, None, None) .await .unwrap() .unwrap(); assert_eq!(path, temp.path()); assert_eq!(pkg_name, "my-tool"); assert!(!is_workspace); } #[tokio::test] async fn test_find_package_dir_single_package_underscore_normalization() { let temp = TempDir::new().unwrap(); let cargo_toml = r#" [package] name = "my-tool" version = "0.1.0" edition = "2021" "#; write_file(&temp.path().join("Cargo.toml"), cargo_toml).await; write_file(&temp.path().join("src/main.rs"), "fn main() {}").await; // Should match with underscores instead of hyphens let (path, _pkg, is_workspace) = find_package_dir(temp.path(), "my_tool", None, None, None) .await .unwrap() .unwrap(); assert_eq!(path, temp.path()); assert!(!is_workspace); } #[tokio::test] async fn test_find_package_dir_workspace_with_root_package() { let temp = TempDir::new().unwrap(); let cargo_toml = r#" [package] name = "cargo-deny" version = "0.18.5" edition = "2021" [workspace] members = ["subcrate"] "#; write_file(&temp.path().join("Cargo.toml"), cargo_toml).await; write_file(&temp.path().join("src/main.rs"), "fn main() {}").await; // Create subcrate with a lib.rs let subcrate_toml = r#" [package] name = "subcrate" version = "0.1.0" edition = "2021" "#; write_file(&temp.path().join("subcrate/Cargo.toml"), subcrate_toml).await; write_file(&temp.path().join("subcrate/src/lib.rs"), "").await; let (path, pkg_name, is_workspace) = find_package_dir(temp.path(), "cargo-deny", None, None, None) .await .unwrap() .unwrap(); assert_eq!(path, temp.path()); assert_eq!(pkg_name, "cargo-deny"); assert!(is_workspace); } #[tokio::test] async fn test_find_package_dir_workspace_member() { let temp = TempDir::new().unwrap(); let cargo_toml = r#" [workspace] members = ["cli", "lib"] "#; write_file(&temp.path().join("Cargo.toml"), cargo_toml).await; let cli_toml = r#" [package] name = "my-cli" version = "0.1.0" edition = "2021" "#; write_file(&temp.path().join("cli/Cargo.toml"), cli_toml).await; write_file(&temp.path().join("cli/src/main.rs"), "fn main() {}").await; let lib_toml = r#" [package] name = "my-lib" version = "0.1.0" edition = "2021" "#; write_file(&temp.path().join("lib/Cargo.toml"), lib_toml).await; write_file(&temp.path().join("lib/src/lib.rs"), "").await; let (path, pkg_name, is_workspace) = find_package_dir(temp.path(), "my-cli", None, None, None) .await .unwrap() .unwrap(); assert_eq!(path, temp.path().join("cli")); assert_eq!(pkg_name, "my-cli"); assert!(is_workspace); } #[tokio::test] async fn test_find_package_dir_by_bin_name() { let temp = TempDir::new().unwrap(); let cargo_toml = r#" [workspace] members = ["crates/typos-cli"] "#; write_file(&temp.path().join("Cargo.toml"), cargo_toml).await; // Package is typos-cli but binary is typos let cli_toml = r#" [package] name = "typos-cli" version = "0.1.0" edition = "2021" [[bin]] name = "typos" path = "src/main.rs" "#; write_file(&temp.path().join("crates/typos-cli/Cargo.toml"), cli_toml).await; write_file( &temp.path().join("crates/typos-cli/src/main.rs"), "fn main() {}", ) .await; // Should find by binary name, return package name let (path, pkg_name, is_workspace) = find_package_dir(temp.path(), "typos", None, None, None) .await .unwrap() .unwrap(); assert_eq!(path, temp.path().join("crates/typos-cli")); assert_eq!(pkg_name, "typos-cli"); assert!(is_workspace); } #[tokio::test] async fn test_find_package_dir_by_src_bin_file() { let temp = TempDir::new().unwrap(); let cargo_toml = r#" [package] name = "my-pkg" version = "0.1.0" edition = "2021" "#; write_file(&temp.path().join("Cargo.toml"), cargo_toml).await; write_file(&temp.path().join("src/bin/my-tool.rs"), "fn main() {}").await; // Need a lib.rs or main.rs for the package itself write_file(&temp.path().join("src/lib.rs"), "").await; let (path, _pkg, is_workspace) = find_package_dir(temp.path(), "my-tool", None, None, None) .await .unwrap() .unwrap(); assert_eq!(path, temp.path()); assert!(!is_workspace); } #[tokio::test] async fn test_find_package_dir_virtual_workspace_nested_member() { let temp = TempDir::new().unwrap(); let cargo_toml = r#" [workspace] members = ["crates/cli"] "#; write_file(&temp.path().join("Cargo.toml"), cargo_toml).await; let cli_toml = r#" [package] name = "virtual-cli" version = "0.1.0" edition = "2021" "#; write_file(&temp.path().join("crates/cli/Cargo.toml"), cli_toml).await; write_file(&temp.path().join("crates/cli/src/main.rs"), "fn main() {}").await; let (path, pkg_name, is_workspace) = find_package_dir(temp.path(), "virtual-cli", None, None, None) .await .unwrap() .unwrap(); assert_eq!(path, temp.path().join("crates/cli")); assert_eq!(pkg_name, "virtual-cli"); assert!(is_workspace); } #[tokio::test] async fn test_find_package_dir_virtual_workspace_glob_members() { let temp = TempDir::new().unwrap(); let cargo_toml = r#" [workspace] members = ["crates/*"] "#; write_file(&temp.path().join("Cargo.toml"), cargo_toml).await; let cli_toml = r#" [package] name = "my-cli" version = "0.1.0" edition = "2021" "#; write_file(&temp.path().join("crates/cli/Cargo.toml"), cli_toml).await; write_file(&temp.path().join("crates/cli/src/main.rs"), "fn main() {}").await; let lib_toml = r#" [package] name = "my-lib" version = "0.1.0" edition = "2021" "#; write_file(&temp.path().join("crates/lib/Cargo.toml"), lib_toml).await; write_file(&temp.path().join("crates/lib/src/lib.rs"), "").await; let (path, pkg_name, is_workspace) = find_package_dir(temp.path(), "my-cli", None, None, None) .await .unwrap() .unwrap(); assert_eq!(path, temp.path().join("crates/cli")); assert_eq!(pkg_name, "my-cli"); assert!(is_workspace); // my-lib is a library (no binary), so searching for it should fail let result = find_package_dir(temp.path(), "my-lib", None, None, None) .await .unwrap(); assert!(result.is_none()); } #[tokio::test] async fn test_find_package_dir_no_cargo_toml() { let temp = TempDir::new().unwrap(); let result = find_package_dir(temp.path(), "anything", None, None, None).await; assert!(result.is_err()); // cargo metadata gives a different error message assert!(result.unwrap_err().to_string().contains("cargo metadata")); } #[tokio::test] async fn test_find_package_dir_workspace_binary_not_found() { let temp = TempDir::new().unwrap(); let cargo_toml = r#" [workspace] members = ["cli"] "#; write_file(&temp.path().join("Cargo.toml"), cargo_toml).await; let cli_toml = r#" [package] name = "some-other-tool" version = "0.1.0" edition = "2021" "#; write_file(&temp.path().join("cli/Cargo.toml"), cli_toml).await; write_file(&temp.path().join("cli/src/main.rs"), "fn main() {}").await; let result = find_package_dir(temp.path(), "nonexistent-binary", None, None, None) .await .unwrap(); assert!(result.is_none()); } #[test] fn test_format_cargo_dependency() { assert_eq!(format_cargo_dependency("serde"), "serde@*"); assert_eq!(format_cargo_dependency("serde:1.0"), "serde@1.0"); assert_eq!(format_cargo_dependency("tokio:1.0.0"), "tokio@1.0.0"); } #[test] fn test_parse_cargo_cli_dependency_crate_forms() { assert_eq!( CargoCliDependency::from_str("typos-cli").unwrap(), CargoCliDependency::Crate { name: "typos-cli".to_string(), version: None, } ); assert_eq!( CargoCliDependency::from_str("typos-cli:1.0").unwrap(), CargoCliDependency::Crate { name: "typos-cli".to_string(), version: Some("1.0".to_string()) } ); } #[test] fn test_parse_cargo_cli_dependency_git_valid_forms() { let cases = [ ( "https://github.com/fish-shell/fish-shell", CargoCliDependency::Git { url: "https://github.com/fish-shell/fish-shell".to_string(), tag: None, package: None, }, ), ( "https://github.com/fish-shell/fish-shell:v4.5.0", CargoCliDependency::Git { url: "https://github.com/fish-shell/fish-shell".to_string(), tag: Some("v4.5.0".to_string()), package: None, }, ), ( "https://github.com/fish-shell/fish-shell::fish", CargoCliDependency::Git { url: "https://github.com/fish-shell/fish-shell".to_string(), tag: None, package: Some("fish".to_string()), }, ), ( "https://github.com/fish-shell/fish-shell:v4.5.0:fish", CargoCliDependency::Git { url: "https://github.com/fish-shell/fish-shell".to_string(), tag: Some("v4.5.0".to_string()), package: Some("fish".to_string()), }, ), ]; for (input, expected) in cases { assert_eq!(CargoCliDependency::from_str(input).unwrap(), expected); } } #[test] fn test_parse_cargo_cli_dependency_git_invalid_forms() { let invalid_cases = [ "https://github.com/fish-shell/fish-shell:", "https://github.com/fish-shell/fish-shell:v4.5.0:", "https://github.com/fish-shell/fish-shell::", ]; for input in invalid_cases { assert!( CargoCliDependency::from_str(input).is_err(), "input: {input}" ); } } #[test] fn test_format_cargo_cli_dependency() { let cases = [ ("typos-cli", vec!["typos-cli"]), ("typos-cli:1.0", vec!["typos-cli", "--version", "1.0"]), ( "https://github.com/fish-shell/fish-shell", vec!["--git", "https://github.com/fish-shell/fish-shell"], ), ( "https://github.com/fish-shell/fish-shell:v4.5.0", vec![ "--git", "https://github.com/fish-shell/fish-shell", "--tag", "v4.5.0", ], ), ( "https://github.com/fish-shell/fish-shell::fish", vec!["--git", "https://github.com/fish-shell/fish-shell", "fish"], ), ( "https://github.com/fish-shell/fish-shell:v4.5.0:fish", vec![ "--git", "https://github.com/fish-shell/fish-shell", "--tag", "v4.5.0", "fish", ], ), ]; for (input, expected) in cases { let dep = CargoCliDependency::from_str(input).unwrap(); assert_eq!(dep.to_cargo_args(), expected, "input: {input}"); } } } ================================================ FILE: crates/prek/src/languages/rust/rustup.rs ================================================ use std::env::consts::EXE_EXTENSION; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use anyhow::{Context, Result}; use futures::StreamExt; use prek_consts::env_vars::EnvVars; use semver::Version; use target_lexicon::HOST; use tracing::{debug, trace, warn}; use crate::fs::LockedFile; use crate::http::REQWEST_CLIENT; use crate::languages::rust::version::RustVersion; use crate::process::Cmd; use crate::store::Store; #[derive(Clone)] pub(crate) struct Rustup { bin: PathBuf, rustup_home: PathBuf, } pub(crate) struct ToolchainInfo { pub(crate) name: String, pub(crate) path: PathBuf, pub(crate) version: RustVersion, } static RUSTUP_BINARY_NAME: LazyLock = LazyLock::new(|| { EnvVars::var(EnvVars::PREK_INTERNAL__RUSTUP_BINARY_NAME) .unwrap_or_else(|_| "rustup".to_string()) }); impl Rustup { pub(crate) fn rustup_home(&self) -> &Path { &self.rustup_home } /// Install rustup if not already installed. pub(crate) async fn install(store: &Store, rustup_home: &Path) -> Result { // 1) Check system installed `rustup` if let Ok(rustup_path) = which::which(&*RUSTUP_BINARY_NAME) { trace!("Using system installed rustup at {}", rustup_path.display()); return Ok(Self { bin: rustup_path, rustup_home: rustup_home.to_path_buf(), }); } // 2) Check if already installed in store let rustup_path = rustup_home.join("rustup").with_extension(EXE_EXTENSION); if rustup_path.is_file() { trace!("Using managed rustup at {}", rustup_path.display()); return Ok(Self { bin: rustup_path, rustup_home: rustup_home.to_path_buf(), }); } // 3) Install rustup fs_err::tokio::create_dir_all(&rustup_home).await?; let _lock = LockedFile::acquire(rustup_home.join(".lock"), "rustup").await?; if rustup_path.is_file() { trace!("Using managed rustup at {}", rustup_path.display()); return Ok(Self { bin: rustup_path, rustup_home: rustup_home.to_path_buf(), }); } Self::download(store, rustup_home) .await .context("Failed to install rustup") } async fn download(store: &Store, rustup_home: &Path) -> Result { let triple = HOST.to_string(); let filename = if cfg!(windows) { "rustup-init.exe" } else { "rustup-init" }; let url = format!("https://static.rust-lang.org/rustup/dist/{triple}/{filename}"); // Save "rustup-init" as "rustup", this is what "rustup-init" does when setting up. let target = rustup_home.join("rustup").with_extension(EXE_EXTENSION); let temp_dir = tempfile::tempdir_in(store.scratch_path())?; debug!(url = %url, temp_dir = ?temp_dir.path(), "Downloading"); let tmp_target = temp_dir.path().join(filename); let response = REQWEST_CLIENT .get(&url) .send() .await .with_context(|| format!("Failed to download file from {url}"))?; if !response.status().is_success() { anyhow::bail!( "Failed to download file from {}: {}", url, response.status() ); } let bytes = response.bytes().await?; fs_err::tokio::write(&tmp_target, bytes).await?; make_executable(&tmp_target)?; // Move to final location if target.exists() { debug!(path = %target.display(), "Removing existing rustup"); fs_err::tokio::remove_file(&target).await?; } debug!(path = %target.display(), "Installing rustup"); fs_err::tokio::rename(&tmp_target, &target).await?; Ok(Self { bin: target, rustup_home: rustup_home.to_path_buf(), }) } pub(crate) async fn install_toolchain(&self, toolchain: &str) -> Result { let output = Cmd::new(&self.bin, "rustup toolchain install") .env(EnvVars::RUSTUP_HOME, &self.rustup_home) .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") .arg("toolchain") .arg("install") .arg("--no-self-update") .arg("--profile") .arg("minimal") .arg(toolchain) .check(true) .output() .await .with_context(|| format!("Failed to install rust toolchain {toolchain}"))?; // Parse installed toolchain name from output let stdout = String::from_utf8_lossy(&output.stdout); let installed_name = stdout .lines() .find_map(|line| { let line = line.trim(); let (name, _) = line.split_once(" installed")?; let name = name.trim(); if name.is_empty() { None } else { Some(name.to_string()) } }) .with_context(|| { format!( "Unable to detect installed toolchain name from rustup output for `{toolchain}`" ) })?; Ok(self.rustup_home.join("toolchains").join(installed_name)) } /// List installed toolchains managed by prek. pub(crate) async fn list_installed_toolchains(&self) -> Result> { let output = Cmd::new(&self.bin, "rustup list toolchains") .arg("toolchain") .arg("list") .arg("-v") .env(EnvVars::RUSTUP_HOME, &self.rustup_home) .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") .check(true) .output() .await .context("Failed to list installed toolchains")?; let entries: Vec<(String, PathBuf)> = str::from_utf8(&output.stdout)? .lines() .filter_map(parse_toolchain_line) .collect(); let infos: Vec = futures::stream::iter(entries) .map(async move |(name, path)| toolchain_info(name, path).await) .buffer_unordered(8) .filter_map(async move |result| match result { Ok(info) => Some(info), Err(e) => { warn!("Skipping invalid toolchain: {e:#}"); None } }) .collect() .await; Ok(infos) } /// List system-installed Rust toolchains. pub(crate) async fn list_system_toolchains(&self) -> Result> { let output = Cmd::new(&self.bin, "rustup toolchain list") .arg("toolchain") .arg("list") .arg("-v") .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") .check(true) .output() .await .context("Failed to list system toolchains")?; let entries: Vec<(String, PathBuf)> = str::from_utf8(&output.stdout)? .lines() .filter_map(parse_toolchain_line) .collect(); let infos: Vec = futures::stream::iter(entries) .map(async move |(name, path)| toolchain_info(name, path).await) .buffer_unordered(8) .filter_map(async move |result| match result { Ok(info) => Some(info), Err(e) => { warn!("Skipping invalid toolchain: {e:#}"); None } }) .collect() .await; Ok(infos) } } fn parse_toolchain_line(line: &str) -> Option<(String, PathBuf)> { // Typical formats: // "stable-aarch64-apple-darwin (default) /Users/me/.rustup/toolchains/stable-aarch64-apple-darwin" // "nightly-x86_64-unknown-linux-gnu /home/me/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu" let parts: Vec<_> = line.split_whitespace().collect(); let name = (*parts.first()?).to_string(); let path = parts.last()?; let path = PathBuf::from(path); if path.exists() { Some((name, path)) } else { None } } async fn toolchain_info(name: String, toolchain_dir: PathBuf) -> Result { let rustc = toolchain_dir .join("bin") .join("rustc") .with_extension(EXE_EXTENSION); let output = Cmd::new(&rustc, "rustc version") .arg("--version") .check(true) .output() .await .with_context(|| format!("Failed to read version from {}", rustc.display()))?; let version_str = str::from_utf8(&output.stdout)? .split_whitespace() .nth(1) .context("Failed to parse rustc --version output")?; let version = Version::parse(version_str)?; let version = RustVersion::from_path(&version, &toolchain_dir); Ok(ToolchainInfo { name, path: toolchain_dir, version, }) } fn make_executable(path: &Path) -> std::io::Result<()> { #[allow(clippy::unnecessary_wraps)] #[cfg(windows)] fn inner(_: &Path) -> std::io::Result<()> { Ok(()) } #[cfg(not(windows))] fn inner(path: &Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; let metadata = fs_err::metadata(path)?; let mut perms = metadata.permissions(); let mode = perms.mode(); let new_mode = (mode & !0o777) | 0o755; // Check if permissions are ok already if mode == new_mode { return Ok(()); } perms.set_mode(new_mode); fs_err::set_permissions(path, perms) } inner(path) } ================================================ FILE: crates/prek/src/languages/rust/version.rs ================================================ use std::fmt::Display; use std::ops::Deref; use std::path::Path; use std::str::FromStr; use crate::hook::InstallInfo; use crate::languages::version::{Error, try_into_u64_slice}; #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub(crate) enum Channel { Stable, Beta, Nightly, } impl FromStr for Channel { type Err = (); fn from_str(s: &str) -> Result { match s { "stable" => Ok(Channel::Stable), "beta" => Ok(Channel::Beta), "nightly" => Ok(Channel::Nightly), _ => Err(()), } } } impl Display for Channel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let channel_str = match self { Channel::Stable => "stable", Channel::Beta => "beta", Channel::Nightly => "nightly", }; write!(f, "{channel_str}") } } #[derive(Debug, Clone)] pub(crate) struct RustVersion { version: semver::Version, channel: Option, } impl Default for RustVersion { fn default() -> Self { Self { version: semver::Version::new(0, 0, 0), channel: None, } } } impl Deref for RustVersion { type Target = semver::Version; fn deref(&self) -> &Self::Target { &self.version } } impl RustVersion { pub(crate) fn from_version(version: &semver::Version) -> Self { Self { version: version.clone(), channel: None, } } pub(crate) fn from_channel(channel: Channel) -> Self { Self { version: semver::Version::new(0, 0, 0), channel: Some(channel), } } pub(crate) fn from_path(version: &semver::Version, path: &Path) -> Self { let toolchain_str = path .file_name() .and_then(|os_str| os_str.to_str()) .unwrap_or_default(); let path = toolchain_str.to_lowercase(); let channel = if path.starts_with("nightly") { Some(Channel::Nightly) } else if path.starts_with("beta") { Some(Channel::Beta) } else if path.starts_with("stable") { Some(Channel::Stable) } else { None }; Self { version: version.clone(), channel, } } pub(crate) fn to_toolchain_name(&self) -> String { if let Some(channel) = &self.channel { channel.to_string() } else { format!( "{}.{}.{}", self.version.major, self.version.minor, self.version.patch ) } } } /// `language_version` field of rust can be one of the following: /// `default` /// `system` /// `stable` /// `nightly` /// `beta` /// `1.70` or `1.70.0` /// `>= 1.70, < 1.72` #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) enum RustRequest { Any, Channel(Channel), Major(u64), MajorMinor(u64, u64), MajorMinorPatch(u64, u64, u64), Range(semver::VersionReq, String), } impl FromStr for RustRequest { type Err = Error; fn from_str(s: &str) -> Result { if s.is_empty() { return Ok(RustRequest::Any); } // Check for channel names if let Ok(channel) = Channel::from_str(s) { return Ok(RustRequest::Channel(channel)); } // Try parsing as version numbers Self::parse_version_numbers(s, s).or_else(|_| { semver::VersionReq::parse(s) .map(|version_req| RustRequest::Range(version_req, s.into())) .map_err(|_| Error::InvalidVersion(s.to_string())) }) } } impl Display for RustRequest { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { RustRequest::Any => write!(f, "any"), RustRequest::Channel(channel) => write!(f, "{channel}"), RustRequest::Major(major) => write!(f, "{major}"), RustRequest::MajorMinor(major, minor) => write!(f, "{major}.{minor}"), RustRequest::MajorMinorPatch(major, minor, patch) => { write!(f, "{major}.{minor}.{patch}") } RustRequest::Range(_, range_str) => write!(f, "{range_str}"), } } } pub(crate) const EXTRA_KEY_CHANNEL: &str = "channel"; impl RustRequest { pub(crate) fn is_any(&self) -> bool { matches!(self, RustRequest::Any) } fn parse_version_numbers( version_str: &str, original_request: &str, ) -> Result { let parts = try_into_u64_slice(version_str) .map_err(|_| Error::InvalidVersion(original_request.to_string()))?; match parts.as_slice() { [major] => Ok(RustRequest::Major(*major)), [major, minor] => Ok(RustRequest::MajorMinor(*major, *minor)), [major, minor, patch] => Ok(RustRequest::MajorMinorPatch(*major, *minor, *patch)), _ => Err(Error::InvalidVersion(original_request.to_string())), } } pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { match self { RustRequest::Any => { // Any request accepts any valid installation, or specifically "stable" install_info .get_extra(EXTRA_KEY_CHANNEL) .is_some_and(|ch| ch == "stable") || install_info.language_version.major > 0 } RustRequest::Channel(requested_channel) => { let channel = install_info .get_extra(EXTRA_KEY_CHANNEL) .and_then(|ch| Channel::from_str(ch).ok()); channel.as_ref().is_some_and(|ch| ch == requested_channel) } _ => { let version = &install_info.language_version; self.matches( &RustVersion::from_version(version), Some(install_info.toolchain.as_ref()), ) } } } pub(crate) fn matches(&self, version: &RustVersion, _toolchain: Option<&Path>) -> bool { match self { RustRequest::Any => true, RustRequest::Channel(requested_channel) => version .channel .as_ref() .is_some_and(|ch| ch == requested_channel), RustRequest::Major(major) => version.version.major == *major, RustRequest::MajorMinor(major, minor) => { version.version.major == *major && version.version.minor == *minor } RustRequest::MajorMinorPatch(major, minor, patch) => { version.version.major == *major && version.version.minor == *minor && version.version.patch == *patch } RustRequest::Range(req, _) => req.matches(&version.version), } } } #[cfg(test)] mod tests { use super::*; use crate::config::Language; use crate::hook::InstallInfo; use rustc_hash::FxHashSet; use std::path::PathBuf; use std::str::FromStr; #[test] fn test_request_from_str() -> anyhow::Result<()> { assert_eq!(RustRequest::from_str("")?, RustRequest::Any); assert_eq!( RustRequest::from_str("stable")?, RustRequest::Channel(Channel::Stable) ); assert_eq!( RustRequest::from_str("beta")?, RustRequest::Channel(Channel::Beta) ); assert_eq!( RustRequest::from_str("nightly")?, RustRequest::Channel(Channel::Nightly) ); assert_eq!(RustRequest::from_str("1")?, RustRequest::Major(1)); assert_eq!( RustRequest::from_str("1.70")?, RustRequest::MajorMinor(1, 70) ); assert_eq!( RustRequest::from_str("1.70.1")?, RustRequest::MajorMinorPatch(1, 70, 1) ); let range_str = ">=1.70, <1.72"; assert_eq!( RustRequest::from_str(range_str)?, RustRequest::Range(semver::VersionReq::parse(range_str)?, range_str.into()) ); Ok(()) } #[test] fn test_invalid_requests() { assert!(RustRequest::from_str("unknown-channel").is_err()); assert!(RustRequest::from_str("1.2.3.4").is_err()); assert!(RustRequest::from_str("1.2.a").is_err()); assert!(RustRequest::from_str("/non/existent/path/to/rust").is_err()); } #[test] fn test_request_matches() -> anyhow::Result<()> { let version = RustVersion::from_path( &semver::Version::new(1, 71, 0), Path::new("/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu"), ); let other_version = RustVersion::from_version(&semver::Version::new(1, 72, 1)); assert!(RustRequest::Any.matches(&version, None)); assert!(RustRequest::Channel(Channel::Stable).matches(&version, None)); assert!(!RustRequest::Channel(Channel::Stable).matches(&other_version, None)); assert!(RustRequest::Major(1).matches(&version, None)); assert!(!RustRequest::Major(2).matches(&version, None)); assert!(RustRequest::MajorMinor(1, 71).matches(&version, None)); assert!(!RustRequest::MajorMinor(1, 72).matches(&version, None)); assert!(RustRequest::MajorMinorPatch(1, 71, 0).matches(&version, None)); assert!(!RustRequest::MajorMinorPatch(1, 71, 1).matches(&version, None)); let req = semver::VersionReq::parse(">=1.70, <1.72")?; assert!(RustRequest::Range(req.clone(), ">=1.70, <1.72".into()).matches(&version, None)); assert!(!RustRequest::Range(req, ">=1.70, <1.72".into()).matches(&other_version, None)); Ok(()) } #[test] fn test_request_satisfied_by_install_info() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; let toolchain_path = temp_dir.path().join("rust-toolchain"); std::fs::write(&toolchain_path, b"")?; let mut install_info = InstallInfo::new(Language::Rust, FxHashSet::default(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(1, 71, 0)) .with_toolchain(toolchain_path.clone()); assert!(RustRequest::Any.satisfied_by(&install_info)); assert!(RustRequest::Major(1).satisfied_by(&install_info)); assert!(RustRequest::MajorMinor(1, 71).satisfied_by(&install_info)); assert!(RustRequest::MajorMinorPatch(1, 71, 0).satisfied_by(&install_info)); assert!(!RustRequest::MajorMinorPatch(1, 71, 1).satisfied_by(&install_info)); let req = RustRequest::Range( semver::VersionReq::parse(">=1.70, <1.72")?, ">=1.70, <1.72".into(), ); assert!(req.satisfied_by(&install_info)); let req = RustRequest::Range(semver::VersionReq::parse(">=1.72")?, ">=1.72".into()); assert!(!req.satisfied_by(&install_info)); Ok(()) } #[test] fn test_satisfied_by_channel() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; let mut install_info = InstallInfo::new(Language::Rust, FxHashSet::default(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(1, 75, 0)) .with_toolchain(PathBuf::from("/some/path")) .with_extra(EXTRA_KEY_CHANNEL, "stable"); // Channel request should match when extra is set assert!(RustRequest::Channel(Channel::Stable).satisfied_by(&install_info)); assert!(!RustRequest::Channel(Channel::Nightly).satisfied_by(&install_info)); assert!(!RustRequest::Channel(Channel::Beta).satisfied_by(&install_info)); Ok(()) } #[test] fn test_satisfied_by_any_with_stable_channel() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; let mut install_info = InstallInfo::new(Language::Rust, FxHashSet::default(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(1, 75, 0)) .with_toolchain(PathBuf::from("/some/path")) .with_extra("rust_channel", "stable"); // Any request should match stable channel assert!(RustRequest::Any.satisfied_by(&install_info)); Ok(()) } #[test] fn test_satisfied_by_any_without_channel() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; let mut install_info = InstallInfo::new(Language::Rust, FxHashSet::default(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(1, 75, 0)) .with_toolchain(PathBuf::from("/some/path")); // No channel set - should still match Any if version > 0 assert!(RustRequest::Any.satisfied_by(&install_info)); Ok(()) } } ================================================ FILE: crates/prek/src/languages/script.rs ================================================ use std::path::Path; use std::process::Stdio; use std::sync::Arc; use anyhow::Result; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::InstalledHook; use crate::hook::{Hook, InstallInfo}; use crate::languages::{LanguageImpl, resolve_command}; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] pub(crate) struct Script; impl LanguageImpl for Script { async fn install( &self, hook: Arc, _store: &Store, _reporter: &HookInstallReporter, ) -> Result { Ok(InstalledHook::NoNeedInstall(hook)) } async fn check_health(&self, _info: &InstallInfo) -> Result<()> { Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { // For `language: script`, the `entry[0]` is a script path. // For remote hooks, the path is relative to the repo root. // For local hooks, the path is relative to the current working directory. let progress = reporter.on_run_start(hook, filenames.len()); let repo_path = hook.repo_path().unwrap_or(hook.work_dir()); let mut split = hook.entry.split()?; let cmd = repo_path.join(&split[0]); split[0] = cmd.to_string_lossy().to_string(); let entry = resolve_command(split, None); let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "run script command") .current_dir(hook.work_dir()) .envs(&hook.env) .args(&entry[1..]) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); // Collect results let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } ================================================ FILE: crates/prek/src/languages/swift.rs ================================================ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use semver::Version; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] pub(crate) struct Swift; pub(crate) struct SwiftInfo { pub(crate) version: Version, pub(crate) executable: PathBuf, } pub(crate) async fn query_swift_info() -> Result { // Find swift executable let executable = which::which("swift").context("Swift not found on PATH")?; // macOS: "swift-driver version: X.Y.Z Apple Swift version X.Y.Z ..." // Linux/Windows: "Swift version X.Y.Z ..." let stdout = Cmd::new("swift", "get swift version") .arg("--version") .check(true) .output() .await? .stdout; let output = String::from_utf8_lossy(&stdout); let version = parse_swift_version(&output).context("Failed to parse Swift version")?; Ok(SwiftInfo { version, executable, }) } /// Normalize version string to semver format (e.g., "5.10" -> "5.10.0"). /// Some Swift toolchains report versions without a patch component. fn normalize_version(version_str: &str) -> String { // Strip any pre-release suffix (e.g., "6.0-dev" -> "6.0") let version_str = version_str.split('-').next().unwrap_or(version_str); if version_str.matches('.').count() == 1 { format!("{version_str}.0") } else { version_str.to_string() } } fn parse_swift_version(output: &str) -> Option { for line in output.lines() { // Try Apple Swift format (macOS) - may appear mid-line if let Some(idx) = line.find("Apple Swift version ") { let rest = &line[idx + "Apple Swift version ".len()..]; if let Some(version_str) = rest.split_whitespace().next() { if let Ok(version) = normalize_version(version_str).parse() { return Some(version); } } } // Try plain Swift format (Linux) - at start of line if let Some(rest) = line.strip_prefix("Swift version ") { let version_str = rest.split_whitespace().next()?; return normalize_version(version_str).parse().ok(); } } None } fn build_dir(env_path: &Path) -> PathBuf { env_path.join(".build") } const BIN_PATH_KEY: &str = "swift_bin_path"; impl LanguageImpl for Swift { async fn install( &self, hook: Arc, store: &Store, reporter: &HookInstallReporter, ) -> Result { let progress = reporter.on_install_start(&hook); let mut info = InstallInfo::new( hook.language, hook.env_key_dependencies().clone(), &store.hooks_dir(), )?; debug!(%hook, target = %info.env_path.display(), "Installing Swift environment"); // Query swift info let swift_info = query_swift_info() .await .context("Failed to query Swift info")?; // Build if repo has Package.swift if let Some(repo_path) = hook.repo_path() { if repo_path.join("Package.swift").exists() { debug!(%hook, "Building Swift package"); let build_path = build_dir(&info.env_path); Cmd::new("swift", "swift build") .arg("build") .arg("-c") .arg("release") .arg("--package-path") .arg(repo_path) .arg("--build-path") .arg(&build_path) .check(true) .output() .await .context("Failed to build Swift package")?; // Get the actual bin path (includes target triple, e.g., .build/arm64-apple-macosx/release) let bin_path_output = Cmd::new("swift", "get bin path") .arg("build") .arg("-c") .arg("release") .arg("--package-path") .arg(repo_path) .arg("--build-path") .arg(&build_path) .arg("--show-bin-path") .check(true) .output() .await .context("Failed to get Swift bin path")?; let bin_path = String::from_utf8_lossy(&bin_path_output.stdout) .trim() .to_string(); debug!(%hook, %bin_path, "Swift bin path"); info.with_extra(BIN_PATH_KEY, &bin_path); } else { debug!(%hook, "No Package.swift found, skipping build"); } } info.with_toolchain(swift_info.executable) .with_language_version(swift_info.version); info.persist_env_path(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, info: Arc::new(info), }) } async fn check_health(&self, info: &InstallInfo) -> Result<()> { // Verify swift still exists at the stored path if !info.toolchain.exists() { anyhow::bail!( "Swift executable no longer exists at: {}", info.toolchain.display() ); } Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); // Get bin path from install info if a package was built let new_path = if let Some(bin_path) = hook.install_info().and_then(|i| i.get_extra(BIN_PATH_KEY)) { prepend_paths(&[Path::new(bin_path)]).context("Failed to join PATH")? } else { EnvVars::var_os(EnvVars::PATH).unwrap_or_default() }; let entry = hook.entry.resolve(Some(&new_path))?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "swift hook") .current_dir(hook.work_dir()) .args(&entry[1..]) .env(EnvVars::PATH, &new_path) .envs(&hook.env) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } #[cfg(test)] mod tests { use super::parse_swift_version; #[test] fn test_parse_macos_format() { // macOS: "swift-driver version: ... Apple Swift version X.Y.Z ..." let output = "swift-driver version: 1.115.0 Apple Swift version 6.1.2 (swiftlang-6.1.2.1.1 clang-1700.0.13.1)"; let version = parse_swift_version(output).unwrap(); assert_eq!(version.major, 6); assert_eq!(version.minor, 1); assert_eq!(version.patch, 2); } #[test] fn test_parse_linux_format() { // Linux/Windows: "Swift version X.Y.Z ..." let output = "Swift version 6.1.2 (swift-6.1.2-RELEASE)"; let version = parse_swift_version(output).unwrap(); assert_eq!(version.major, 6); assert_eq!(version.minor, 1); assert_eq!(version.patch, 2); } #[test] fn test_parse_multiline_output() { // macOS output includes target on second line let output = r"swift-driver version: 1.115.0 Apple Swift version 6.1.2 (swiftlang-6.1.2.1.1 clang-1700.0.13.1) Target: arm64-apple-macosx15.0"; let version = parse_swift_version(output).unwrap(); assert_eq!(version.major, 6); assert_eq!(version.minor, 1); assert_eq!(version.patch, 2); } #[test] fn test_parse_linux_multiline() { // Linux output includes target on second line let output = r"Swift version 6.1.2 (swift-6.1.2-RELEASE) Target: x86_64-unknown-linux-gnu"; let version = parse_swift_version(output).unwrap(); assert_eq!(version.major, 6); assert_eq!(version.minor, 1); assert_eq!(version.patch, 2); } #[test] fn test_parse_invalid_output() { assert!(parse_swift_version("").is_none()); assert!(parse_swift_version("not a version string").is_none()); assert!(parse_swift_version("version 6.1.2").is_none()); // Missing "Swift" } #[test] fn test_parse_version_without_patch() { // Some toolchains report versions without a patch number let output = "swift-driver version: 1.115.0 Apple Swift version 6.1 (swiftlang-6.1.0.0.1 clang-1700.0.13.1)"; let version = parse_swift_version(output).unwrap(); assert_eq!(version.major, 6); assert_eq!(version.minor, 1); assert_eq!(version.patch, 0); // Normalized to .0 // Linux format without patch let output = "Swift version 6.1 (swift-6.1-RELEASE)"; let version = parse_swift_version(output).unwrap(); assert_eq!(version.major, 6); assert_eq!(version.minor, 1); assert_eq!(version.patch, 0); } #[test] fn test_parse_dev_version() { // Development/nightly versions have -dev suffix let output = "Swift version 6.2-dev (LLVM abcdef, Swift 123456)"; let version = parse_swift_version(output).unwrap(); assert_eq!(version.major, 6); assert_eq!(version.minor, 2); assert_eq!(version.patch, 0); } } ================================================ FILE: crates/prek/src/languages/system.rs ================================================ use std::path::Path; use std::process::Stdio; use std::sync::Arc; use anyhow::Result; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] pub(crate) struct System; impl LanguageImpl for System { async fn install( &self, hook: Arc, _store: &Store, _reporter: &HookInstallReporter, ) -> Result { Ok(InstalledHook::NoNeedInstall(hook)) } async fn check_health(&self, _info: &InstallInfo) -> Result<()> { Ok(()) } async fn run( &self, hook: &InstalledHook, filenames: &[&Path], _store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); let entry = hook.entry.resolve(None)?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "run system command") .current_dir(hook.work_dir()) .envs(&hook.env) .args(&entry[1..]) .args(&hook.args) .args(batch) .check(false) .stdin(Stdio::null()) .pty_output() .await?; reporter.on_run_progress(progress, batch.len() as u64); output.stdout.extend(output.stderr); let code = output.status.code().unwrap_or(1); anyhow::Ok((code, output.stdout)) }; let results = run_by_batch(hook, filenames, &entry, run).await?; reporter.on_run_complete(progress); // Collect results let mut combined_status = 0; let mut combined_output = Vec::new(); for (code, output) in results { combined_status |= code; combined_output.extend(output); } Ok((combined_status, combined_output)) } } ================================================ FILE: crates/prek/src/languages/version.rs ================================================ use std::str::FromStr; use crate::config::Language; use crate::hook::InstallInfo; use crate::languages::bun::BunRequest; use crate::languages::deno::DenoRequest; use crate::languages::golang::GoRequest; use crate::languages::node::NodeRequest; use crate::languages::python::PythonRequest; use crate::languages::ruby::RubyRequest; use crate::languages::rust::RustRequest; #[derive(thiserror::Error, Debug)] pub(crate) enum Error { #[error("Invalid `language_version` value: `{0}`")] InvalidVersion(String), } #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) enum LanguageRequest { Any { system_only: bool }, Bun(BunRequest), Deno(DenoRequest), Golang(GoRequest), Ruby(RubyRequest), Node(NodeRequest), Python(PythonRequest), Rust(RustRequest), // TODO: all other languages default to semver for now. Semver(SemverRequest), } impl LanguageRequest { pub(crate) fn is_any(&self) -> bool { match self { LanguageRequest::Any { .. } => true, LanguageRequest::Bun(req) => req.is_any(), LanguageRequest::Deno(req) => req.is_any(), LanguageRequest::Golang(req) => req.is_any(), LanguageRequest::Node(req) => req.is_any(), LanguageRequest::Python(req) => req.is_any(), LanguageRequest::Ruby(req) => req.is_any(), LanguageRequest::Rust(req) => req.is_any(), LanguageRequest::Semver(_) => false, } } /// Returns true if this request allows downloading a version. /// /// Currently, only `system` disallows downloading. In the future, /// we may add more specific version requests that also disallow downloading. /// For example `language_version: 3.12; system_only`. pub(crate) fn allows_download(&self) -> bool { match self { LanguageRequest::Any { system_only } => !system_only, _ => true, } } pub(crate) fn parse(lang: Language, request: &str) -> Result { // `pre-commit` support these values in `language_version`: // - `default`: substituted by language `get_default_version` function // In `get_default_version`, if a system version is available, it will return `system`. // For Python, it will find from sys.executable, `pythonX.Y`, or versions `py` can find. // Otherwise, it will still return `default`. // - `system`: use current system installed version // - Python version passed down to `virtualenv`, e.g. `python`, `python3`, `python3.8` // - Node.js version passed down to `nodeenv` // - Rust version passed down to `rustup` if request == "default" || request.is_empty() { return Ok(LanguageRequest::Any { system_only: false }); } if request == "system" { return Ok(LanguageRequest::Any { system_only: true }); } Ok(match lang { Language::Bun => Self::Bun(request.parse()?), Language::Deno => Self::Deno(request.parse()?), Language::Golang => Self::Golang(request.parse()?), Language::Node => Self::Node(request.parse()?), Language::Python => Self::Python(request.parse()?), Language::Ruby => Self::Ruby(request.parse()?), Language::Rust => Self::Rust(request.parse()?), _ => Self::Semver(request.parse()?), }) } pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { match self { LanguageRequest::Any { .. } => true, LanguageRequest::Bun(req) => req.satisfied_by(install_info), LanguageRequest::Deno(req) => req.satisfied_by(install_info), LanguageRequest::Golang(req) => req.satisfied_by(install_info), LanguageRequest::Node(req) => req.satisfied_by(install_info), LanguageRequest::Python(req) => req.satisfied_by(install_info), LanguageRequest::Ruby(req) => req.satisfied_by(install_info), LanguageRequest::Rust(req) => req.satisfied_by(install_info), LanguageRequest::Semver(req) => req.satisfied_by(install_info), } } } #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) struct SemverRequest(semver::VersionReq); impl FromStr for SemverRequest { type Err = Error; fn from_str(request: &str) -> Result { semver::VersionReq::parse(request) .map(SemverRequest) .map_err(|_| Error::InvalidVersion(request.to_string())) } } impl SemverRequest { fn satisfied_by(&self, install_info: &InstallInfo) -> bool { self.0.matches(&install_info.language_version) } } pub(crate) fn try_into_u64_slice(version: &str) -> Result, std::num::ParseIntError> { version .split('.') .map(str::parse::) .collect::, _>>() } ================================================ FILE: crates/prek/src/main.rs ================================================ use std::fmt::Write; use std::path::PathBuf; use std::process::ExitCode; use std::str::FromStr; use std::sync::Mutex; use anstream::{ColorChoice, StripStream, eprintln}; use anyhow::{Context, Result}; use clap::{CommandFactory, Parser}; use clap_complete::CompleteEnv; use owo_colors::OwoColorize; use prek_consts::env_vars::EnvVars; use tracing::debug; use tracing::level_filters::LevelFilter; use tracing_subscriber::filter::Directive; use tracing_subscriber::fmt::format::FmtSpan; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{EnvFilter, Layer}; use crate::cleanup::cleanup; use crate::cli::{ CacheCommand, CacheNamespace, Cli, Command, ExitStatus, UtilCommand, UtilNamespace, }; #[cfg(feature = "self-update")] use crate::cli::{SelfCommand, SelfNamespace, SelfUpdateArgs}; use crate::printer::Printer; use crate::run::USE_COLOR; use crate::store::Store; mod archive; mod cleanup; mod cli; mod config; mod fs; mod git; mod hook; mod hooks; mod http; mod install_source; mod languages; mod printer; mod process; #[cfg(all(unix, feature = "profiler"))] mod profiler; #[cfg(unix)] mod resource_limit; mod run; #[cfg(feature = "schemars")] mod schema; mod store; mod version; mod warnings; mod workspace; mod yaml; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub(crate) enum Level { /// Suppress all tracing output by default (overridable by `RUST_LOG`). #[default] Default, /// Show verbose messages. Verbose, /// Show debug messages by default (overridable by `RUST_LOG`). Debug, /// Show trace messages by default (overridable by `RUST_LOG`). Trace, /// Show trace messages for all crates by default (overridable by `RUST_LOG`). TraceAll, } enum LogFile { Default, Path(PathBuf), Disabled, } impl LogFile { fn from_args(log_file: Option, no_log_file: bool) -> Self { if no_log_file { Self::Disabled } else if let Some(path) = log_file { Self::Path(path) } else { Self::Default } } fn is_disabled(&self) -> bool { matches!(self, Self::Disabled) } } fn setup_logging(level: Level, log_file: LogFile, store: &Store) -> Result<()> { let directive = match level { Level::Default | Level::Verbose => LevelFilter::OFF.into(), Level::Debug => Directive::from_str("prek=debug")?, Level::Trace => Directive::from_str("prek=trace")?, Level::TraceAll => Directive::from_str("trace")?, }; let stderr_filter = EnvFilter::builder() .with_default_directive(directive) .from_env() .context("Invalid RUST_LOG directive")?; let stderr_format = tracing_subscriber::fmt::format() .with_target(false) .with_ansi(*USE_COLOR); let stderr_layer = tracing_subscriber::fmt::layer() .with_span_events(FmtSpan::CLOSE) .event_format(stderr_format) .with_writer(anstream::stderr) .with_filter(stderr_filter); let registry = tracing_subscriber::registry().with(stderr_layer); if log_file.is_disabled() { registry.init(); } else { let log_file_path = match log_file { LogFile::Default => store.log_file(), LogFile::Path(path) => path, LogFile::Disabled => unreachable!(), }; let log_file = fs_err::OpenOptions::new() .create(true) .write(true) .truncate(true) .open(log_file_path) .context("Failed to open log file")?; let log_file = Mutex::new(StripStream::new(log_file.into_file())); let file_format = tracing_subscriber::fmt::format() .with_target(false) .with_ansi(false); let file_layer = tracing_subscriber::fmt::layer() .with_span_events(FmtSpan::CLOSE) .event_format(file_format) .with_writer(log_file) .with_filter(EnvFilter::new("prek=trace")); registry.with(file_layer).init(); } Ok(()) } async fn run(cli: Cli) -> Result { // Enabled ANSI colors on Windows. let _ = anstyle_query::windows::enable_ansi_colors(); ColorChoice::write_global(cli.globals.color.into()); let store = Store::from_settings()?; let log_file = LogFile::from_args(cli.globals.log_file.clone(), cli.globals.no_log_file); setup_logging( match cli.globals.verbose { 0 => Level::Default, 1 => Level::Verbose, 2 => Level::Debug, 3 => Level::Trace, _ => Level::TraceAll, }, log_file, &store, )?; let printer = if cli.globals.quiet == 1 { Printer::Quiet } else if cli.globals.quiet > 1 { Printer::Silent } else if cli.globals.verbose > 1 { Printer::Verbose } else if cli.globals.no_progress { Printer::NoProgress } else { Printer::Default }; if cli.globals.quiet > 0 { warnings::disable(); } else { warnings::enable(); } debug!("prek: {}", version::version()); #[cfg(unix)] match resource_limit::adjust_open_file_limit() { Ok(_) | Err(resource_limit::OpenFileLimitError::AlreadySufficient { .. }) => {} Err(err) => { tracing::warn!("Failed to adjust open file limit: {err}"); } } // If `GIT_DIR` is set, prek may be running from a git hook. // Git exports `GIT_DIR` but *not* `GIT_WORK_TREE`. Without `GIT_WORK_TREE`, git // treats the current working directory as the working tree. If prek changes the current // working directory (with `--cd`), git commands run by prek may behave unexpectedly. // // To make git behavior stable, we set `GIT_WORK_TREE` ourselves to where prek is run from. // If `GIT_WORK_TREE` is already set, we leave it alone. // If `GIT_DIR` is not set, we let git discover `.git` after an optional `cd`. // See: https://www.spinics.net/lists/git/msg374197.html // https://github.com/pre-commit/pre-commit/issues/2295 if EnvVars::is_set(EnvVars::GIT_DIR) && !EnvVars::is_set(EnvVars::GIT_WORK_TREE) { let cwd = std::env::current_dir().context("Failed to get current directory")?; debug!("Setting {} to `{}`", EnvVars::GIT_WORK_TREE, cwd.display()); unsafe { std::env::set_var(EnvVars::GIT_WORK_TREE, cwd) } } if let Some(dir) = cli.globals.cd.as_ref() { debug!("Changing current directory to: `{}`", dir.display()); std::env::set_current_dir(dir)?; } debug!("Args: {:?}", std::env::args().collect::>()); macro_rules! show_settings { ($arg:expr) => { if cli.globals.show_settings { writeln!(printer.stdout(), "{:#?}", $arg)?; return Ok(ExitStatus::Success); } }; ($arg:expr, false) => { if cli.globals.show_settings { writeln!(printer.stdout(), "{:#?}", $arg)?; } }; } show_settings!(cli.globals, false); let command = cli .command .unwrap_or_else(|| Command::Run(Box::new(cli.run_args))); match command { Command::Install(args) => { show_settings!(args); cli::install( &store, cli.globals.config, args.includes, args.skips, args.hook_types, args.prepare_hooks, args.overwrite, args.allow_missing_config, cli.globals.refresh, cli.globals.quiet, cli.globals.verbose, cli.globals.no_progress, printer, args.git_dir.as_deref(), ) .await } Command::PrepareHooks(args) => { cli::prepare_hooks( &store, cli.globals.config, args.includes, args.skips, cli.globals.refresh, printer, ) .await } Command::Uninstall(args) => { show_settings!(args); cli::uninstall(cli.globals.config, args.hook_types, args.all, printer).await } Command::Run(args) => { show_settings!(args); cli::run( &store, cli.globals.config, args.includes, args.skips, args.stage, args.from_ref, args.to_ref, args.all_files, args.files, args.directory, args.last_commit, args.show_diff_on_failure, args.fail_fast, args.dry_run, cli.globals.refresh, args.extra, cli.globals.verbose > 0, printer, ) .await } Command::List(args) => { show_settings!(args); cli::list( &store, cli.globals.config, args.includes, args.skips, args.hook_stage, args.language, args.output_format, cli.globals.refresh, cli.globals.verbose > 0, printer, ) .await } Command::HookImpl(args) => { show_settings!(args); cli::hook_impl( &store, cli.globals.config, args.includes, args.skips, args.hook_type, args.hook_dir, args.skip_on_missing_config, args.script_version, args.args, printer, ) .await } Command::Cache(CacheNamespace { command: cache_command, }) => match cache_command { CacheCommand::Clean => cli::cache_clean(&store, printer), CacheCommand::Dir => { writeln!( printer.stdout_important(), "{}", store.path().display().cyan() )?; Ok(ExitStatus::Success) } CacheCommand::GC(args) => { cli::cache_gc(&store, args.dry_run, cli.globals.verbose > 0, printer).await } CacheCommand::Size(cli::SizeArgs { human }) => cli::cache_size(&store, human, printer), }, Command::Clean => cli::cache_clean(&store, printer), Command::GC(args) => { cli::cache_gc(&store, args.dry_run, cli.globals.verbose > 0, printer).await } Command::ValidateConfig(args) => { show_settings!(args); cli::validate_configs(args.configs, printer) } Command::ValidateManifest(args) => { show_settings!(args); cli::validate_manifest(args.manifests, printer) } Command::SampleConfig(args) => cli::sample_config(args.file.into(), args.format, printer), Command::AutoUpdate(args) => { cli::auto_update( &store, cli.globals.config, args.repo, args.bleeding_edge, args.freeze, args.jobs, args.dry_run, args.cooldown_days, printer, ) .await } Command::TryRepo(args) => { show_settings!(args); cli::try_repo( cli.globals.config, args.repo, args.rev, args.run_args, cli.globals.refresh, cli.globals.verbose > 0, printer, ) .await } Command::Util(UtilNamespace { command }) => match command { UtilCommand::Identify(args) => { show_settings!(args); cli::identify(&args.paths, args.output_format, printer) } UtilCommand::ListBuiltins(args) => { show_settings!(args); cli::list_builtins(args.output_format, cli.globals.verbose > 0, printer) } UtilCommand::InitTemplateDir(args) => { show_settings!(args); cli::init_template_dir( &store, args.directory, cli.globals.config, args.hook_types, args.no_allow_missing_config, cli.globals.refresh, cli.globals.quiet, cli.globals.verbose, cli.globals.no_progress, printer, ) .await } UtilCommand::YamlToToml(args) => { show_settings!(args); cli::yaml_to_toml(args.input, args.output, args.force, printer) } UtilCommand::GenerateShellCompletion(args) => { show_settings!(args); let mut command = Cli::command(); let bin_name = command .get_bin_name() .unwrap_or_else(|| command.get_name()) .to_owned(); clap_complete::generate(args.shell, &mut command, bin_name, &mut std::io::stdout()); Ok(ExitStatus::Success) } }, #[cfg(feature = "self-update")] Command::Self_(SelfNamespace { command: SelfCommand::Update(SelfUpdateArgs { target_version, token, }), }) => cli::self_update(target_version, token, printer).await, #[cfg(not(feature = "self-update"))] Command::Self_(_) => { use crate::install_source::InstallSource; let msg = InstallSource::detect() .map(|s| { format!( "prek was installed via {} and cannot self-update. To update, run `{}`", s.description(), s.update_instructions() ) }) .unwrap_or_else(|| { "prek was installed via an external package manager and cannot self-update. \ Please use your package manager to update prek." .into() }); anyhow::bail!("{msg}"); } Command::InitTemplateDir(args) => { show_settings!(args); cli::init_template_dir( &store, args.directory, cli.globals.config, args.hook_types, args.no_allow_missing_config, cli.globals.refresh, cli.globals.quiet, cli.globals.verbose, cli.globals.no_progress, printer, ) .await } } } fn main() -> ExitCode { CompleteEnv::with_factory(Cli::command).complete(); ctrlc::set_handler(move || { cleanup(); #[allow(clippy::exit, clippy::cast_possible_wrap)] std::process::exit(if cfg!(windows) { 0xC000_013A_u32 as i32 } else { 130 }); }) .expect("Error setting Ctrl-C handler"); let cli = match Cli::try_parse() { Ok(cli) => cli, Err(err) => err.exit(), }; #[cfg(all(unix, feature = "profiler"))] let _profiler_guard = profiler::start_profiling(); let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("Failed to create tokio runtime"); let result = runtime.block_on(Box::pin(run(cli))); runtime.shutdown_background(); // Report the profiler if the feature is enabled #[cfg(all(unix, feature = "profiler"))] profiler::finish_profiling(_profiler_guard); match result { Ok(code) => code.into(), Err(err) => { let mut causes = err.chain(); eprintln!("{}: {}", "error".red().bold(), causes.next().unwrap()); for err in causes { eprintln!(" {}: {}", "caused by".red().bold(), err); } ExitStatus::Error.into() } } } ================================================ FILE: crates/prek/src/printer.rs ================================================ // MIT License // // Copyright (c) 2023 Astral Software Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. use anstream::{eprint, print}; use indicatif::ProgressDrawTarget; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Printer { /// A printer that suppresses all output. Silent, /// A printer that suppresses most output, but preserves "important" stdout. Quiet, /// A printer that prints to standard streams (e.g., stdout). Default, /// A printer that prints all output, including debug messages. Verbose, /// A printer that prints to standard streams, excluding all progress outputs NoProgress, } impl Printer { /// Return the [`ProgressDrawTarget`] for this printer. pub fn target(self) -> ProgressDrawTarget { match self { Self::Silent => ProgressDrawTarget::hidden(), Self::Quiet => ProgressDrawTarget::hidden(), Self::Default => ProgressDrawTarget::stderr(), // Confusingly, hide the progress bar when in verbose mode. // Otherwise, it gets interleaved with debug messages. Self::Verbose => ProgressDrawTarget::hidden(), Self::NoProgress => ProgressDrawTarget::hidden(), } } /// Return the [`Stdout`] for this printer. pub(crate) fn stdout_important(self) -> Stdout { match self { Self::Silent => Stdout::Disabled, Self::Quiet => Stdout::Enabled, Self::Default => Stdout::Enabled, Self::Verbose => Stdout::Enabled, Self::NoProgress => Stdout::Enabled, } } /// Return the [`Stdout`] for this printer. pub(crate) fn stdout(self) -> Stdout { match self { Self::Silent => Stdout::Disabled, Self::Quiet => Stdout::Disabled, Self::Default => Stdout::Enabled, Self::Verbose => Stdout::Enabled, Self::NoProgress => Stdout::Enabled, } } /// Return the [`Stderr`] for this printer. pub(crate) fn stderr(self) -> Stderr { match self { Self::Silent => Stderr::Disabled, Self::Quiet => Stderr::Disabled, Self::Default => Stderr::Enabled, Self::Verbose => Stderr::Enabled, Self::NoProgress => Stderr::Enabled, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Stdout { Enabled, Disabled, } impl std::fmt::Write for Stdout { fn write_str(&mut self, s: &str) -> std::fmt::Result { match self { Self::Enabled => { #[allow(clippy::print_stdout, clippy::ignored_unit_patterns)] { print!("{s}"); } } Self::Disabled => {} } Ok(()) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Stderr { Enabled, Disabled, } impl std::fmt::Write for Stderr { fn write_str(&mut self, s: &str) -> std::fmt::Result { match self { Self::Enabled => { #[allow(clippy::print_stderr, clippy::ignored_unit_patterns)] { eprint!("{s}"); } } Self::Disabled => {} } Ok(()) } } ================================================ FILE: crates/prek/src/process.rs ================================================ // Copyright (c) 2023 Axo Developer Co. // // Permission is hereby granted, free of charge, to any // person obtaining a copy of this software and associated // documentation files (the "Software"), to deal in the // Software without restriction, including without // limitation the rights to use, copy, modify, merge, // publish, distribute, sublicense, and/or sell copies of // the Software, and to permit persons to whom the Software // is furnished to do so, subject to the following // conditions: // // The above copyright notice and this permission notice // shall be included in all copies or substantial portions // of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF // ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED // TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A // PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT // SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY // CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR // IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. /// Adapt [axoprocess] to use [`tokio::process::Process`] instead of [`std::process::Command`]. use std::ffi::OsStr; use std::fmt::Display; use std::path::Path; use std::process::Output; use std::process::{CommandArgs, CommandEnvs, ExitStatus, Stdio}; use std::sync::LazyLock; use owo_colors::OwoColorize; use prek_consts::env_vars::EnvVars; use thiserror::Error; use tracing::trace; use crate::git::GIT; static LOG_TRUNCATE_LIMIT: LazyLock = LazyLock::new(|| { EnvVars::var(EnvVars::PREK_LOG_TRUNCATE_LIMIT) .ok() .and_then(|limit| limit.parse::().ok()) .filter(|limit| *limit > 0) .unwrap_or(120) }); /// An error from executing a Command #[derive(Debug, Error)] pub enum Error { /// The command fundamentally failed to execute (usually means it didn't exist) #[error("Run command `{summary}` failed")] Exec { /// Summary of what the Command was trying to do summary: String, /// What failed #[source] cause: std::io::Error, }, #[error("Command `{summary}` exited with an error:\n{error}")] Status { summary: String, error: StatusError }, #[cfg(not(windows))] #[error("Failed to open pty")] Pty(#[from] prek_pty::Error), #[error("Failed to setup subprocess for pty")] PtySetup(#[from] std::io::Error), } /// The command ran but signaled some kind of error condition /// (assuming the exit code is used for that) #[derive(Debug)] pub struct StatusError { pub status: ExitStatus, pub output: Option, } impl Display for StatusError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { writeln!(f, "\n{}\n{}", "[status]".red(), self.status)?; if let Some(output) = &self.output { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let stdout = stdout .split('\n') .filter_map(|line| { let line = line.trim(); if line.is_empty() { None } else { Some(line) } }) .collect::>(); let stderr = stderr .split('\n') .filter_map(|line| { let line = line.trim(); if line.is_empty() { None } else { Some(line) } }) .collect::>(); if !stdout.is_empty() { writeln!(f, "\n{}\n{}", "[stdout]".red(), stdout.join("\n"))?; } if !stderr.is_empty() { writeln!(f, "\n{}\n{}", "[stderr]".red(), stderr.join("\n"))?; } } Ok(()) } } /// A fancier Command, see the crate's top-level docs! pub struct Cmd { /// The inner Command, in case you need to access it pub inner: tokio::process::Command, summary: String, check_status: bool, } /// Constructors impl Cmd { /// Create a new Command with an additional "summary" of what this is trying to do pub fn new(command: impl AsRef, summary: impl Into) -> Self { let inner = tokio::process::Command::new(command); Self { summary: summary.into(), inner, check_status: true, } } } /// Builder APIs impl Cmd { /// Pipe stdout into stderr /// /// This is useful for cases where you want your program to livestream /// the output of a command to give your user realtime feedback, but the command /// randomly writes some things to stdout, and you don't want your own stdout tainted. pub fn stdout_to_stderr(&mut self) -> &mut Self { self.inner.stdout(std::io::stderr()); self } /// Set whether `Status::success` should be checked after executions /// (except `spawn`, which doesn't yet have a Status to check). /// /// Defaults to `true`. /// /// If true, an Err will be produced by those execution commands. /// /// Executions which produce status will pass them to [`Cmd::maybe_check_status`][], /// which uses this setting. pub fn check(&mut self, checked: bool) -> &mut Self { self.check_status = checked; self } } /// Execution APIs impl Cmd { /// Equivalent to [`Cmd::status`][], /// but doesn't bother returning the actual status code (because it's captured in the Result) pub async fn run(&mut self) -> Result<(), Error> { self.status().await?; Ok(()) } /// Equivalent to [`std::process::Command::spawn`][], /// but logged and with the error wrapped. pub fn spawn(&mut self) -> Result { self.log_command(); self.inner.spawn().map_err(|cause| Error::Exec { summary: self.summary.clone(), cause, }) } /// Equivalent to [`std::process::Command::output`][], /// but logged, with the error wrapped, and status checked (by default) pub async fn output(&mut self) -> Result { self.log_command(); let output = self.inner.output().await.map_err(|cause| Error::Exec { summary: self.summary.clone(), cause, })?; self.maybe_check_output(&output)?; Ok(output) } #[cfg(windows)] pub async fn pty_output(&mut self) -> Result { self.output().await } #[cfg(not(windows))] pub async fn pty_output(&mut self) -> Result { // If color is not used, fallback to piped output. if !*crate::run::USE_COLOR { return self.output().await; } self.pty_output_inner().await } #[cfg(not(windows))] async fn pty_output_inner(&mut self) -> Result { use tokio::io::AsyncReadExt; let (mut pty, pts) = prek_pty::open()?; let (_, stdout, stderr) = pts.setup_subprocess()?; self.inner.stdin(Stdio::null()); self.inner.stdout(stdout); self.inner.stderr(stderr); // We run some commands under a PTY so they behave like they do in an interactive terminal // (colors, progress bars, etc.). However, this is still a *pseudo*-terminal and it doesn't // necessarily provide a full/accurate terminal environment. // // Some libraries (for example Go's termenv) send OSC/CSI queries and wait for a response // from the terminal. Our PTY doesn't emulate those responses, so they can block on a // timeout if the program insists on probing capabilities. // // Previously, we tried to work around this by setting `TERM=dumb` in the environment, // but that caused other issues (for example, some programs (e.g cargo), disable color entirely when they see `TERM=dumb`, // even if the output is actually a terminal that supports color). // // We intentionally do not make the child a session leader/foreground process group here. // When we did, termenv detected it as foreground and ran OSC probes, which then hung. let mut child = self.spawn()?; let mut stdout = Vec::new(); let mut buffer = [0u8; 4096]; let status = loop { tokio::select! { read_result = pty.read(&mut buffer) => { match read_result { Ok(0) => { // EOF from PTY, child should be done break child.wait().await?; } Ok(n) => { stdout.extend_from_slice(&buffer[..n]); } Err(e) => { // PTY error, try to get child status if let Ok(Some(status)) = child.try_wait() { break status; } return Err(Error::PtySetup(e)); } } } status = child.wait() => { let status = status?; drain_ready_pty(&mut pty, &mut stdout, &mut buffer).await?; break status; } } }; child.stdin.take(); child.stdout.take(); child.stderr.take(); let output = Output { status, stdout, stderr: Vec::new(), }; self.maybe_check_output(&output)?; Ok(output) } /// Equivalent to [`std::process::Command::status`][] /// but logged, with the error wrapped, and status checked (by default) pub async fn status(&mut self) -> Result { self.log_command(); let status = self.inner.status().await.map_err(|cause| Error::Exec { summary: self.summary.clone(), cause, })?; self.maybe_check_status(status)?; Ok(status) } } #[cfg(not(windows))] async fn drain_ready_pty( pty: &mut prek_pty::Pty, stdout: &mut Vec, buffer: &mut [u8; 4096], ) -> Result<(), Error> { use tokio::io::AsyncReadExt; use tokio::time::{Duration, timeout}; loop { match timeout(Duration::from_millis(20), pty.read(buffer)).await { Ok(Ok(0)) => return Ok(()), Ok(Ok(n)) => stdout.extend_from_slice(&buffer[..n]), Err(_) => return Ok(()), Ok(Err(err)) if err.kind() == std::io::ErrorKind::WouldBlock => return Ok(()), Ok(Err(err)) => return Err(Error::PtySetup(err)), } } } /// Transparently forwarded [`std::process::Command`][] APIs impl Cmd { /// Forwards to [`std::process::Command::arg`][] pub fn arg>(&mut self, arg: S) -> &mut Self { self.inner.arg(arg); self } /// Forwards to [`std::process::Command::args`][] pub fn args(&mut self, args: I) -> &mut Self where I: IntoIterator, S: AsRef, { self.inner.args(args); self } /// Forwards to [`std::process::Command::env`][] pub fn env(&mut self, key: K, val: V) -> &mut Self where K: AsRef, V: AsRef, { self.inner.env(key, val); self } /// Forwards to [`std::process::Command::envs`][] pub fn envs(&mut self, vars: I) -> &mut Self where I: IntoIterator, K: AsRef, V: AsRef, { self.inner.envs(vars); self } /// Forwards to [`std::process::Command::env_remove`][] pub fn env_remove>(&mut self, key: K) -> &mut Self { self.inner.env_remove(key); self } /// Forwards to [`std::process::Command::env_clear`][] pub fn env_clear(&mut self) -> &mut Self { self.inner.env_clear(); self } /// Forwards to [`std::process::Command::current_dir`][] pub fn current_dir>(&mut self, dir: P) -> &mut Self { self.inner.current_dir(dir); self } /// Forwards to [`std::process::Command::stdin`][] pub fn stdin>(&mut self, cfg: T) -> &mut Self { self.inner.stdin(cfg); self } /// Forwards to [`std::process::Command::stdout`][] pub fn stdout>(&mut self, cfg: T) -> &mut Self { self.inner.stdout(cfg); self } /// Forwards to [`std::process::Command::stderr`][] pub fn stderr>(&mut self, cfg: T) -> &mut Self { self.inner.stderr(cfg); self } /// Forwards to [`std::process::Command::get_program`][] pub fn get_program(&self) -> &OsStr { self.inner.as_std().get_program() } /// Forwards to [`std::process::Command::get_args`][] pub fn get_args(&self) -> CommandArgs<'_> { self.inner.as_std().get_args() } /// Forwards to [`std::process::Command::get_envs`][] pub fn get_envs(&self) -> CommandEnvs<'_> { self.inner.as_std().get_envs() } /// Forwards to [`std::process::Command::get_current_dir`][] pub fn get_current_dir(&self) -> Option<&Path> { self.inner.as_std().get_current_dir() } /// Remove some git-specific environment variables to make git commands isolated. pub fn remove_git_envs(&mut self) -> &mut Self { for (key, _) in crate::git::GIT_ENV_TO_REMOVE.iter() { self.inner.env_remove(key); } self } } /// Diagnostic APIs (used internally, but available for yourself) impl Cmd { /// Check `Status::success`, producing a contextual Error if it's `false`. pub fn check_status(&self, status: ExitStatus) -> Result<(), Error> { if status.success() { Ok(()) } else { Err(Error::Status { summary: self.summary.clone(), error: StatusError { status, output: None, }, }) } } pub fn check_output(&self, output: &Output) -> Result<(), Error> { if output.status.success() { Ok(()) } else { Err(Error::Status { summary: self.summary.clone(), error: StatusError { status: output.status, output: Some(output.clone()), }, }) } } /// Invoke [`Cmd::check_status`][] if [`Cmd::check`][] is `true` /// (defaults to `true`). pub fn maybe_check_status(&self, status: ExitStatus) -> Result<(), Error> { if self.check_status { self.check_status(status)?; } Ok(()) } /// Invoke [`Cmd::check_status`][] if [`Cmd::check`][] is `true` /// (defaults to `true`). pub fn maybe_check_output(&self, output: &Output) -> Result<(), Error> { if self.check_status { self.check_output(output)?; } Ok(()) } /// Log the current Command using the method specified by [`Cmd::log`][] /// (defaults to [`tracing::info!`][]). pub fn log_command(&self) { trace!("Executing `{self}`"); } } /// Returns the number of arguments to skip. fn skip_args(cmd: &OsStr, cur: &OsStr, next: Option<&&OsStr>) -> usize { if GIT.as_ref().is_ok_and(|git| cmd == git) { if cur == "-c" { if let Some(flag) = next { let flag = flag.as_encoded_bytes(); if flag.starts_with(b"core.useBuiltinFSMonitor") || flag.starts_with(b"protocol.version") { return 2; } } } else if cur == "--no-ext-diff" || cur == "--no-textconv" || cur == "--ignore-submodules" || cur == "--no-color" { return 1; } } 0 } /// Simplified Command Debug output, with args truncated if they're too long. impl Display for Cmd { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if let Some(cwd) = self.get_current_dir() { write!(f, "cd {} && ", cwd.to_string_lossy())?; } let program = self.get_program(); let mut args = self.get_args().peekable(); write!(f, "{}", program.to_string_lossy())?; if args.peek().is_some_and(|arg| *arg == program) { args.next(); // Skip the program if it's repeated } let mut len = 0; while let Some(arg) = args.next() { let skip = skip_args(program, arg, args.peek()); if skip > 0 { for _ in 1..skip { args.next(); } continue; } write!(f, " {}", arg.to_string_lossy())?; len += arg.len() + 1; if len > *LOG_TRUNCATE_LIMIT { write!(f, " [...]",)?; break; } } Ok(()) } } #[cfg(all(test, not(windows)))] mod tests { use super::Cmd; #[tokio::test] async fn pty_output_captures_trailing_output_after_fast_exit() { for _ in 0..20 { let output = Cmd::new("/bin/sh", "pty trailing output test") .arg("-c") .arg("printf 'FINAL\\n'") .check(false) .pty_output_inner() .await .expect("pty command should succeed"); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n"); assert_eq!(stdout, "FINAL\n"); assert!(output.stderr.is_empty()); } } } ================================================ FILE: crates/prek/src/profiler.rs ================================================ use tracing::error; /// Creates a profiler guard and returns it. pub(crate) fn start_profiling() -> Option> { match pprof::ProfilerGuardBuilder::default() .frequency(1000) .blocklist(&["libc", "libgcc", "pthread", "vdso"]) .build() { Ok(guard) => Some(guard), Err(e) => { error!("Failed to build profiler guard: {e}"); None } } } /// Reports the profiling results. pub(crate) fn finish_profiling(profiler_guard: Option) { match profiler_guard .expect("Failed to retrieve profiler guard") .report() .build() { Ok(report) => { let random = rand::random::(); let file = fs_err::File::create(format!( "{}.{random}.flamegraph.svg", env!("CARGO_PKG_NAME"), )) .expect("Failed to create flamegraph file"); if let Err(e) = report.flamegraph(file) { error!("failed to create flamegraph file: {e}"); } } Err(e) => { error!("Failed to build profiler report: {e}"); } } } ================================================ FILE: crates/prek/src/resource_limit.rs ================================================ // MIT License // Copyright (c) 2025 Astral Software Inc. // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. //! Helper for adjusting Unix resource limits. //! //! Linux has a historically low default limit of 1024 open file descriptors per process. //! macOS also defaults to a low soft limit (typically 256), though its hard limit is much //! higher. On modern multi-core machines, these low defaults can cause "too many open files" //! errors because uv infers concurrency limits from CPU count and may schedule more concurrent //! work than the default file descriptor limit allows. //! //! This module attempts to raise the soft limit to the hard limit at startup to avoid these //! errors without requiring users to manually configure their shell's `ulimit` settings. //! The raised limit is inherited by child processes, which is important for commands like //! `uv run` that spawn Python interpreters. //! //! See: use rustix::io::Errno; use rustix::process::{Resource, Rlimit, getrlimit, setrlimit}; use thiserror::Error; /// Errors that can occur when adjusting resource limits. #[derive(Debug, Error)] pub enum OpenFileLimitError { #[error("Soft limit ({current:?}) already meets the target ({target})")] AlreadySufficient { current: Option, target: u64 }, #[error("Failed to raise open file limit from {current:?} to {target}: {source}")] SetLimitFailed { current: Option, target: u64, source: Errno, }, } /// Maximum file descriptor limit to request. /// /// We cap at 0x100000 (1,048,576) to match the typical Linux default (`/proc/sys/fs/nr_open`) /// and to avoid issues with extremely high limits. /// /// `OpenJDK` uses this same cap because: /// /// 1. Some code breaks if `RLIMIT_NOFILE` exceeds `i32::MAX` (despite the type being `u64`) /// 2. Code that iterates over all possible FDs, e.g., to close them, can timeout /// /// See: /// See: /// const MAX_NOFILE_LIMIT: u64 = 0x0010_0000; /// Attempt to raise the open file descriptor limit to the maximum allowed. /// /// This function tries to set the soft limit to `min(hard_limit, 0x100000)`. If the operation /// fails, it returns an error since the default limits may still be sufficient for the /// current workload. /// /// Returns [`Ok`] with the new soft limit on successful adjustment, or an appropriate /// [`OpenFileLimitError`] if adjustment failed. /// /// Note that `rustix::process::Rlimit` represents unlimited values as `None`. pub fn adjust_open_file_limit() -> Result { let rlimit = getrlimit(Resource::Nofile); let soft = rlimit.current; let hard = rlimit.maximum; // Cap the target limit to avoid issues with extremely high values. // If hard is unlimited, use MAX_NOFILE_LIMIT. let target = hard.unwrap_or(MAX_NOFILE_LIMIT).min(MAX_NOFILE_LIMIT); if soft.is_none() || soft.is_some_and(|soft| soft >= target) { return Err(OpenFileLimitError::AlreadySufficient { current: soft, target, }); } // Try to raise the soft limit to the target. setrlimit( Resource::Nofile, Rlimit { current: Some(target), maximum: hard, }, ) .map_err(|err| OpenFileLimitError::SetLimitFailed { current: soft, target, source: err, })?; Ok(target) } ================================================ FILE: crates/prek/src/run.rs ================================================ use std::cmp::max; use std::ffi::OsStr; use std::path::Path; use std::sync::LazyLock; use anstream::ColorChoice; use futures::{StreamExt, TryStreamExt}; use prek_consts::env_vars::EnvVars; use rustc_hash::FxHashMap; use tracing::trace; use crate::config::PassFilenames; use crate::hook::Hook; use crate::warn_user; pub(crate) static USE_COLOR: LazyLock = LazyLock::new(|| match anstream::Stderr::choice(&std::io::stderr()) { ColorChoice::Always | ColorChoice::AlwaysAnsi => true, ColorChoice::Never => false, // We just asked anstream for a choice, that can't be auto ColorChoice::Auto => unreachable!(), }); fn resolve_concurrency(no_concurrency: bool, max_concurrency: Option<&str>, cpu: usize) -> usize { if no_concurrency { return 1; } if let Some(v) = max_concurrency { if let Ok(cap) = v.parse::() { return cap.max(1); } warn_user!( "Invalid value for {}: {v:?}, using default ({cpu})", EnvVars::PREK_MAX_CONCURRENCY, ); } cpu } pub(crate) static CONCURRENCY: LazyLock = LazyLock::new(|| { let cpu = std::thread::available_parallelism() .map(std::num::NonZero::get) .unwrap_or(1); resolve_concurrency( EnvVars::is_set(EnvVars::PREK_NO_CONCURRENCY), EnvVars::var(EnvVars::PREK_MAX_CONCURRENCY).ok().as_deref(), cpu, ) }); fn target_concurrency(serial: bool) -> usize { if serial { 1 } else { *CONCURRENCY } } /// Iterator that yields partitions of filenames that fit within the maximum command line length. struct Partitions<'a> { filenames: &'a [&'a Path], current_index: usize, max_per_batch: usize, remaining_arg_length: usize, } /// We make a conservative guess for the size of a single pointer (64-bit) here /// in order to support scenarios where a 32-bit binary is launching a 64-bit /// binary. const POINTER_SIZE_CONSERVATIVE: usize = 8; /// POSIX requires that we leave 2048 bytes of space so that the child processes /// can have room to set their own environment variables. const ARG_HEADROOM: usize = 2048; // Adapted from https://github.com/sharkdp/argmax /// Required size for a single KEY=VAR environment variable string and the /// corresponding pointer in envp**. fn environment_variable_size>(key: O, value: O) -> usize { POINTER_SIZE_CONSERVATIVE // size for the pointer in envp** + key.as_ref().len() // size for the variable name + 1 // size for the '=' sign + value.as_ref().len() // size for the value + 1 // terminating NULL } /// Required size to store a single ARG argument and the corresponding /// pointer in argv**. fn arg_size>(arg: O) -> usize { POINTER_SIZE_CONSERVATIVE // size for the pointer in argv** + arg.as_ref().len() // size for argument string + 1 // terminating NULL } #[cfg(unix)] static ARG_MAX: LazyLock = LazyLock::new(|| { let arg_max = unsafe { libc::sysconf(libc::_SC_ARG_MAX) }; if arg_max <= 0 { 1 << 12 } else { usize::try_from(arg_max).expect("SC_ARG_MAX too large") } }); #[cfg(unix)] static PAGE_SIZE: LazyLock = LazyLock::new(|| { let page_size = unsafe { libc::sysconf(libc::_SC_PAGE_SIZE) }; if page_size < 4096 { 4096 } else { usize::try_from(page_size).expect("SC_PAGE_SIZE too large") } }); // https://www.in-ulm.de/~mascheck/various/argmax/ // https://cgit.git.savannah.gnu.org/cgit/findutils.git/tree/xargs/xargs.c // https://github.com/rust-lang/rust/issues/40384 // https://github.com/uutils/findutils/blob/af48c151fe9b29cb7d25471b5388013ca15748ba/src/xargs/mod.rs#L177 // https://github.com/sharkdp/argmax fn platform_max_cli_length() -> usize { #[cfg(unix)] { let mut arg_max = *ARG_MAX; // Assume arguments are counted with the granularity of a single page, // so allow a one page cushion to account for rounding up arg_max -= *PAGE_SIZE; // POSIX recommends an additional 2048 bytes of headroom arg_max -= ARG_HEADROOM; arg_max.clamp(1 << 12, 1 << 20) } #[cfg(windows)] { (1 << 15) - ARG_HEADROOM // UNICODE_STRING max - headroom } #[cfg(not(any(unix, windows)))] { 1 << 12 } } fn env_size(override_envs: &FxHashMap) -> usize { std::env::vars_os() .map(|(key, value)| { if key .to_str() .map(|key| override_envs.contains_key(key)) .unwrap_or(false) { // key is in override_envs; add it later. 0 } else { environment_variable_size(&key, &value) } }) .sum::() + override_envs .iter() .map(|(key, value)| environment_variable_size(key, value)) .sum::() } impl<'a> Partitions<'a> { fn split( hook: &'a Hook, entry: &'a [String], filenames: &'a [&'a Path], concurrency: usize, ) -> anyhow::Result { let max_per_batch = match hook.pass_filenames { PassFilenames::Limited(n) => n.get(), _ => max(4, filenames.len().div_ceil(concurrency)), }; let mut arg_max = platform_max_cli_length(); let cmd = Path::new(&entry[0]); if cfg!(windows) && cmd.extension().is_some_and(|ext| { ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat") }) { // Reduce max length for batch files on Windows due to cmd.exe limitations. // 1024 is additionally subtracted to give headroom for further // expansion inside the batch file. arg_max = 8192 - 1024; } else if cfg!(unix) { // We have to share space with the environment variables arg_max -= env_size(&hook.env); // Account for the terminating NULL entry arg_max -= POINTER_SIZE_CONSERVATIVE; } let args_size = entry .iter() .chain(hook.args.iter()) .map(arg_size) .sum::() + POINTER_SIZE_CONSERVATIVE; // terminating NULL if args_size >= arg_max { anyhow::bail!( "Command line length ({args_size} bytes) exceeds platform limit ({arg_max} bytes). \nhint: Shorten the hook `entry`/`args` or wrap the command in a script to reduce command-line length.", ); } arg_max -= args_size; Ok(Self { filenames, current_index: 0, max_per_batch, remaining_arg_length: arg_max, }) } } impl<'a> Iterator for Partitions<'a> { type Item = &'a [&'a Path]; fn next(&mut self) -> Option { // Handle empty filenames case if self.filenames.is_empty() && self.current_index == 0 { self.current_index = 1; return Some(&[]); } if self.current_index >= self.filenames.len() { return None; } let start_index = self.current_index; let mut remaining_length = self.remaining_arg_length; while self.current_index < self.filenames.len() { let filename = self.filenames[self.current_index]; let length = arg_size(filename); if length > remaining_length || self.current_index - start_index >= self.max_per_batch { break; } remaining_length -= length; self.current_index += 1; } if self.current_index == start_index { // If we couldn't add even a single file to this batch, it means the file // is too long to fit in the command line by itself. let filename = self.filenames[self.current_index]; let length = arg_size(filename); panic!( "Filename `{}` is too long ({length} bytes) to fit in command line (remaining {remaining_length} bytes).", filename.display(), ); } else { Some(&self.filenames[start_index..self.current_index]) } } } pub(crate) async fn run_by_batch( hook: &Hook, filenames: &[&Path], entry: &[String], run: F, ) -> anyhow::Result> where F: for<'a> AsyncFn(&'a [&'a Path]) -> anyhow::Result, T: Send + 'static, { let concurrency = target_concurrency(hook.require_serial); // Split files into batches let partitions = Partitions::split(hook, entry, filenames, concurrency)?; trace!( total_files = filenames.len(), concurrency = concurrency, "Running {}", hook.id, ); #[allow(clippy::redundant_closure)] let results: Vec<_> = futures::stream::iter(partitions) .map(|batch| run(batch)) .buffered(concurrency) .try_collect() .await?; Ok(results) } #[cfg(test)] mod tests { use super::*; use std::path::{Path, PathBuf}; /// Helper to create a Partitions iterator for testing. /// This bypasses the Hook requirement by directly constructing the struct. fn create_test_partitions<'a>( filenames: &'a [&'a Path], remaining_arg_length: usize, max_per_batch: usize, ) -> Partitions<'a> { Partitions { filenames, current_index: 0, remaining_arg_length, max_per_batch, } } #[test] fn test_partitions_normal_filenames() { let file1 = PathBuf::from("file1.txt"); let file2 = PathBuf::from("file2.txt"); let file3 = PathBuf::from("file3.txt"); let filenames: Vec<&Path> = vec![&file1, &file2, &file3]; let partitions = create_test_partitions(&filenames, 4096, 10); let total_files: usize = partitions.map(<[&Path]>::len).sum(); // All files should have been processed (no panic) assert_eq!(total_files, 3); } #[test] fn test_partitions_empty_filenames() { let filenames: Vec<&Path> = vec![]; let mut partitions = create_test_partitions(&filenames, 4096, 10); // Should return empty slice once, then None let batch = partitions.next(); assert!(batch.is_some()); assert_eq!(batch.unwrap().len(), 0); let batch = partitions.next(); assert!(batch.is_none()); } #[test] #[should_panic(expected = "is too long")] fn test_partitions_long_filename_in_middle_panics() { let file1 = PathBuf::from("file1.txt"); let long_name = "a".repeat(5000); let long_file = PathBuf::from(&long_name); let file3 = PathBuf::from("file3.txt"); let filenames: Vec<&Path> = vec![&file1, &long_file, &file3]; let mut partitions = create_test_partitions(&filenames, 1000, 10); // First batch should succeed with file1 let batch1 = partitions.next(); assert!(batch1.is_some()); // Second batch should panic on the long filename // This ensures we don't silently skip file3 partitions.next(); } #[test] fn test_partitions_respects_max_per_batch() { // Create many small files let files: Vec = (0..100) .map(|i| PathBuf::from(format!("f{i}.txt"))) .collect(); let file_refs: Vec<&Path> = files.iter().map(PathBuf::as_path).collect(); let partitions = create_test_partitions(&file_refs, 100_000, 25); let all_batches: Vec<_> = partitions.map(<[&Path]>::len).collect(); // Should have multiple batches due to max_per_batch assert!(all_batches.len() >= 4); // All files should have been processed let total_files: usize = all_batches.iter().sum(); assert_eq!(total_files, 100); } #[test] fn test_resolve_concurrency_defaults_to_cpu() { assert_eq!(resolve_concurrency(false, None, 16), 16); } #[test] fn test_resolve_concurrency_max_caps_value() { assert_eq!(resolve_concurrency(false, Some("4"), 16), 4); } #[test] fn test_resolve_concurrency_max_above_cpu() { assert_eq!(resolve_concurrency(false, Some("32"), 8), 32); } #[test] fn test_resolve_concurrency_max_zero_floors_to_one() { assert_eq!(resolve_concurrency(false, Some("0"), 16), 1); } #[test] fn test_resolve_concurrency_max_invalid_falls_back() { assert_eq!(resolve_concurrency(false, Some("abc"), 16), 16); } #[test] fn test_resolve_concurrency_max_empty_falls_back() { assert_eq!(resolve_concurrency(false, Some(""), 16), 16); } #[test] fn test_resolve_concurrency_no_concurrency() { assert_eq!(resolve_concurrency(true, None, 16), 1); } #[test] fn test_resolve_concurrency_no_concurrency_overrides_max() { assert_eq!(resolve_concurrency(true, Some("8"), 16), 1); } #[test] fn test_partitions_respects_cli_length_limit() { // Create files that will exceed CLI length limit let files: Vec = (0..10) .map(|i| PathBuf::from(format!("file{i}.txt"))) .collect(); let file_refs: Vec<&Path> = files.iter().map(PathBuf::as_path).collect(); // Set a small max_cli_length to force multiple batches let partitions = create_test_partitions(&file_refs, 100, 100); let all_batches: Vec<_> = partitions.map(<[&Path]>::len).collect(); // Should have multiple batches due to CLI length limit assert!(all_batches.len() > 1); // All files should have been processed let total_files: usize = all_batches.iter().sum(); assert_eq!(total_files, 10); } } ================================================ FILE: crates/prek/src/schema.rs ================================================ use crate::config::{ BuiltinHook, BuiltinRepo, FilePattern, LocalRepo, MetaHook, MetaRepo, PassFilenames, RemoteHook, RemoteRepo, Repo, Stage, Stages, }; use std::borrow::Cow; #[derive(Debug, Clone)] struct RemoveNullTypes; impl schemars::transform::Transform for RemoveNullTypes { fn transform(&mut self, schema: &mut schemars::Schema) { strip_null_acceptance(schema); schemars::transform::transform_subschemas(self, schema); } } fn strip_null_acceptance(schema: &mut schemars::Schema) { use serde_json::Value; let Some(obj) = schema.as_object_mut() else { return; }; const ANNOTATION_KEYS: &[&str] = &["title", "description", "default", "examples"]; // After stripping nullability, `default: null` is invalid for most schemas and can // trigger editor warnings. Treat it as "no default". if obj.get("default").is_some_and(Value::is_null) { obj.remove("default"); } // Remove `null` from `type`. if let Some(ty) = obj.get_mut("type") { match ty { Value::String(s) if s == "null" => { *schema = schemars::json_schema!(false); return; } Value::Array(arr) => { arr.retain(|v| v != "null"); match arr.len() { 0 => { *schema = schemars::json_schema!(false); return; } 1 => { if let Some(Value::String(single)) = arr.pop() { *ty = Value::String(single); } } _ => {} } } _ => {} } } // Remove explicit `null` schemas from combinators. for key in ["anyOf", "oneOf", "allOf"] { let Some(Value::Array(arr)) = obj.get_mut(key) else { continue; }; arr.retain(|sub| { let Some(sub_obj) = sub.as_object() else { return true; }; match sub_obj.get("type") { Some(Value::String(s)) if s == "null" => false, Some(Value::Array(types)) if types.iter().all(|t| t == "null") => false, _ => true, } }); if arr.is_empty() { *schema = schemars::json_schema!(false); return; } // If the combinator has only one subschema left, collapse it. if arr.len() == 1 { let only = arr[0].clone(); // Preserve common annotations from the original wrapper schema. let mut annotations = Vec::new(); for k in ANNOTATION_KEYS { if let Some(v) = obj.get(*k).cloned() { if *k == "default" && v.is_null() { continue; } annotations.push(((*k).to_string(), v)); } } let Ok(only_schema) = serde_json::from_value::(only) else { return; }; *schema = only_schema; if let Some(new_obj) = schema.as_object_mut() { for (k, v) in annotations { new_obj.entry(k).or_insert(v); } } return; } } // If a schema explicitly matches only `null`, block it. if obj.get("const").is_some_and(Value::is_null) { *schema = schemars::json_schema!(false); return; } if let Some(Value::Array(values)) = obj.get("enum") { if !values.is_empty() && values.iter().all(Value::is_null) { *schema = schemars::json_schema!(false); } } } impl schemars::JsonSchema for Stages { fn inline_schema() -> bool { true } fn schema_name() -> Cow<'static, str> { Cow::Borrowed("Stages") } fn json_schema(generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { let stage_schema = generator.subschema_for::(); schemars::json_schema!({ "type": "array", "items": stage_schema, "uniqueItems": true, }) } } impl schemars::JsonSchema for FilePattern { fn schema_name() -> Cow<'static, str> { Cow::Borrowed("FilePattern") } fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { schemars::json_schema!({ "description": "A file pattern, either a regex or glob pattern(s).", "oneOf": [ { "type": "string", "description": "A regular expression pattern.", }, { "type": "object", "properties": { "glob": { "oneOf": [ { "type": "string", "description": "A glob pattern.", }, { "type": "array", "items": { "type": "string", }, "description": "A list of glob patterns.", } ] } }, "required": ["glob"], } ], }) } } impl schemars::JsonSchema for PassFilenames { fn schema_name() -> std::borrow::Cow<'static, str> { std::borrow::Cow::Borrowed("PassFilenames") } fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { schemars::json_schema!({ "description": "Whether to pass filenames to the hook. \ `true` passes all matching filenames (default), \ `false` passes none, and \ a positive integer limits each invocation to at most that many filenames.", "oneOf": [ {"type": "boolean"}, {"type": "integer", "exclusiveMinimum": 0} ] }) } } fn predefined_hook_schema( schema_gen: &mut schemars::SchemaGenerator, description: &str, id_schema: schemars::Schema, ) -> schemars::Schema { let mut schema = ::json_schema(schema_gen); let root = schema.ensure_object(); root.insert("description".to_string(), serde_json::json!(description)); root.insert("required".to_string(), serde_json::json!(["id"])); let properties = root .get_mut("properties") .and_then(serde_json::Value::as_object_mut); if let Some(properties) = properties { properties.insert("id".to_string(), id_schema.into()); properties.insert( "language".to_string(), serde_json::json!({ "type": "string", "enum": ["system"], "description": "Language must be `system` for predefined hooks (or omitted)." }), ); // `entry` is not allowed for predefined hooks. properties.insert( "entry".to_string(), serde_json::json!({ "const": false, "description": "Entry is not allowed for predefined hooks.", }), ); } schema } impl schemars::JsonSchema for MetaHook { fn schema_name() -> Cow<'static, str> { Cow::Borrowed("MetaHook") } fn json_schema(schema_gen: &mut schemars::SchemaGenerator) -> schemars::Schema { use crate::hooks::MetaHooks; let id_schema = schema_gen.subschema_for::(); predefined_hook_schema(schema_gen, "A meta hook predefined in prek.", id_schema) } } impl schemars::JsonSchema for BuiltinHook { fn schema_name() -> Cow<'static, str> { Cow::Borrowed("BuiltinHook") } fn json_schema(r#gen: &mut schemars::SchemaGenerator) -> schemars::Schema { use crate::hooks::BuiltinHooks; let id_schema = r#gen.subschema_for::(); predefined_hook_schema(r#gen, "A builtin hook predefined in prek.", id_schema) } } pub(crate) fn schema_repo_local( _gen: &mut schemars::generate::SchemaGenerator, ) -> schemars::Schema { schemars::json_schema!({ "type": "string", "const": "local", "description": "Must be `local`.", }) } pub(crate) fn schema_repo_meta(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { schemars::json_schema!({ "type": "string", "const": "meta", "description": "Must be `meta`.", }) } pub(crate) fn schema_repo_builtin( _gen: &mut schemars::generate::SchemaGenerator, ) -> schemars::Schema { schemars::json_schema!({ "type": "string", "const": "builtin", "description": "Must be `builtin`.", }) } pub(crate) fn schema_repo_remote( _gen: &mut schemars::generate::SchemaGenerator, ) -> schemars::Schema { schemars::json_schema!({ "type": "string", "not": { "enum": ["local", "meta", "builtin"], }, "description": "Remote repository location. Must not be `local`, `meta`, or `builtin`.", }) } impl schemars::JsonSchema for Repo { fn schema_name() -> Cow<'static, str> { Cow::Borrowed("Repo") } fn json_schema(r#gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { let remote_schema = r#gen.subschema_for::(); let local_schema = r#gen.subschema_for::(); let meta_schema = r#gen.subschema_for::(); let builtin_schema = r#gen.subschema_for::(); schemars::json_schema!({ "type": "object", "description": "A repository of hooks, which can be remote, local, meta, or builtin.", "oneOf": [ remote_schema, local_schema, meta_schema, builtin_schema, ], "additionalProperties": true, }) } } #[cfg(unix)] #[cfg(all(test, feature = "schemars"))] mod _gen { use crate::config::Config; use anyhow::bail; use prek_consts::env_vars::EnvVars; use pretty_assertions::StrComparison; use std::path::PathBuf; const ROOT_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../"); enum Mode { /// Update the content. Write, /// Don't write to the file, check if the file is up-to-date and error if not. Check, /// Write the generated help to stdout. DryRun, } fn generate() -> String { let settings = schemars::generate::SchemaSettings::draft07() .with_transform(schemars::transform::RestrictFormats::default()) .with_transform(super::RemoveNullTypes); let generator = schemars::SchemaGenerator::new(settings); let schema = generator.into_root_schema_for::(); serde_json::to_string_pretty(&schema).unwrap() + "\n" } #[test] fn generate_json_schema() -> anyhow::Result<()> { let mode = if EnvVars::is_set(EnvVars::PREK_GENERATE) { Mode::Write } else { Mode::Check }; let schema_string = generate(); let filename = "prek.schema.json"; let schema_path = PathBuf::from(ROOT_DIR).join(filename); match mode { Mode::DryRun => { anstream::println!("{schema_string}"); } Mode::Check => match fs_err::read_to_string(schema_path) { Ok(current) => { if current == schema_string { anstream::println!("Up-to-date: {filename}"); } else { let comparison = StrComparison::new(¤t, &schema_string); bail!( "{filename} changed, please run `mise run generate` to update:\n{comparison}" ); } } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { bail!("{filename} not found, please run `mise run generate` to generate"); } Err(err) => { bail!("{filename} changed, please run `mise run generate` to update:\n{err}"); } }, Mode::Write => match fs_err::read_to_string(&schema_path) { Ok(current) => { if current == schema_string { anstream::println!("Up-to-date: {filename}"); } else { anstream::println!("Updating: {filename}"); fs_err::write(schema_path, schema_string.as_bytes())?; } } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { anstream::println!("Updating: {filename}"); fs_err::write(schema_path, schema_string.as_bytes())?; } Err(err) => { bail!("{filename} changed, please run `mise run generate` to update:\n{err}"); } }, } Ok(()) } } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__language_version.snap ================================================ --- source: crates/prek/src/config.rs expression: result --- Ok( Config { repos: [ Local( LocalRepo { repo: "local", hooks: [ LocalHook { id: "hook-1", name: "hook 1", entry: "echo hello world", language: System, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: Some( "default", ), log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, LocalHook { id: "hook-2", name: "hook 2", entry: "echo hello world", language: System, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: Some( "system", ), log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, LocalHook { id: "hook-3", name: "hook 3", entry: "echo hello world", language: System, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: Some( "3.8", ), log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: None, fail_fast: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, }, ) ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__meta_hooks-5.snap ================================================ --- source: crates/prek/src/config.rs expression: result --- Config { repos: [ Meta( MetaRepo { repo: "meta", hooks: [ MetaHook { id: "check-hooks-apply", name: "Check hooks apply", priority: None, options: HookOptions { alias: None, files: Some( Glob( GlobPatterns { patterns: [ "prek.toml", ".pre-commit-config.yaml", ".pre-commit-config.yml", ], .. }, ), ), exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, MetaHook { id: "check-useless-excludes", name: "Check useless excludes", priority: None, options: HookOptions { alias: None, files: Some( Glob( GlobPatterns { patterns: [ "prek.toml", ".pre-commit-config.yaml", ".pre-commit-config.yml", ], .. }, ), ), exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, MetaHook { id: "identity", name: "identity", priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: Some( true, ), minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: None, fail_fast: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__numeric_rev_is_parsed_as_string.snap ================================================ --- source: crates/prek/src/config.rs expression: config --- Config { repos: [ Remote( RemoteRepo { repo: "https://github.com/pre-commit/mirrors-mypy", rev: "1.0", hooks: [ RemoteHook { id: "mypy", name: None, entry: None, language: None, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: None, fail_fast: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__parse_hooks-3.snap ================================================ --- source: crates/prek/src/config.rs expression: result --- Config { repos: [ Local( LocalRepo { repo: "local", hooks: [ LocalHook { id: "cargo-fmt", name: "cargo fmt", entry: "cargo fmt", language: Rust, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: None, fail_fast: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__parse_repos-2.snap ================================================ --- source: crates/prek/src/config.rs expression: result --- Config { repos: [ Local( LocalRepo { repo: LocalRepoLocation, hooks: [ LocalHook { id: "cargo-fmt", name: "cargo fmt", entry: "cargo fmt", language: System, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: Some( [ "rust", ], ), types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: { "unknown_field": String("some_value"), }, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: None, fail_fast: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__parse_repos-3.snap ================================================ --- source: crates/prek/src/config.rs expression: result --- Config { repos: [ Local( LocalRepo { repo: "local", hooks: [ LocalHook { id: "cargo-fmt", name: "cargo fmt", entry: "cargo fmt", language: System, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: Some( [ "rust", ], ), types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: { "unknown_field": String("some_value"), }, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: None, fail_fast: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__parse_repos-4.snap ================================================ --- source: crates/prek/src/config.rs expression: result --- Config { repos: [ Remote( RemoteRepo { repo: "https://github.com/crate-ci/typos", rev: "v1.0.0", hooks: [ RemoteHook { id: "typos", name: None, entry: None, language: None, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: None, fail_fast: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__parse_repos-6.snap ================================================ --- source: crates/prek/src/config.rs expression: result --- Config { repos: [ Remote( RemoteRepo { repo: "https://github.com/crate-ci/typos", rev: "v1.0.0", hooks: [ RemoteHook { id: "typos", name: None, entry: None, language: None, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: None, fail_fast: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__parse_repos.snap ================================================ --- source: crates/prek/src/config.rs expression: result --- Config { repos: [ Local( LocalRepo { repo: "local", hooks: [ LocalHook { id: "cargo-fmt", name: "cargo fmt", entry: "cargo fmt --", language: System, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: None, fail_fast: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__read_config_with_merge_keys.snap ================================================ --- source: crates/prek/src/config.rs expression: config --- Config { repos: [ Local( LocalRepo { repo: "local", hooks: [ LocalHook { id: "mypy-local", name: "Local mypy", entry: "python tools/pre_commit/mypy.py 0 \"local\"", language: Python, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: Some( [ "pyi", "python", ], ), exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, LocalHook { id: "mypy-3.10", name: "Mypy 3.10", entry: "python tools/pre_commit/mypy.py 1 \"3.10\"", language: Python, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: Some( [ "pyi", "python", ], ), exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: None, fail_fast: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__read_config_with_nested_merge_keys.snap ================================================ --- source: crates/prek/src/config.rs expression: config --- Config { repos: [ Local( LocalRepo { repo: "local", hooks: [ LocalHook { id: "test-yaml", name: "Test YAML compatibility", entry: "prek --help", language: System, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: Some( None, ), description: None, language_version: None, log_file: None, require_serial: Some( true, ), stages: Some( Some( { PreCommit, }, ), ), verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: None, fail_fast: None, minimum_prek_version: None, orphan: None, _unused_keys: { "local": Object { "language": String("system"), "pass_filenames": Bool(false), "require_serial": Bool(true), }, "local-commit": Object { "stages": Array [ String("pre-commit"), ], "language": String("system"), "pass_filenames": Bool(false), "require_serial": Bool(true), }, }, } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__read_manifest.snap ================================================ --- source: crates/prek/src/config.rs expression: manifest --- Manifest { hooks: [ ManifestHook { id: "pip-compile", name: "pip-compile", entry: "uv pip compile", language: Python, options: HookOptions { alias: None, files: Some( Regex( ^requirements\.(in|txt)$, ), ), exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: Some( [], ), args: Some( [], ), env: None, always_run: None, fail_fast: None, pass_filenames: Some( None, ), description: Some( "Automatically run 'uv pip compile' on your requirements", ), language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: { "minimum_pre_commit_version": String("2.9.2"), }, }, }, ManifestHook { id: "uv-lock", name: "uv-lock", entry: "uv lock", language: Python, options: HookOptions { alias: None, files: Some( Regex( ^(uv\.lock|pyproject\.toml|uv\.toml)$, ), ), exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: Some( [], ), args: Some( [], ), env: None, always_run: None, fail_fast: None, pass_filenames: Some( None, ), description: Some( "Automatically run 'uv lock' on your project dependencies", ), language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: { "minimum_pre_commit_version": String("2.9.2"), }, }, }, ManifestHook { id: "uv-export", name: "uv-export", entry: "uv export", language: Python, options: HookOptions { alias: None, files: Some( Regex( ^uv\.lock$, ), ), exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: Some( [], ), args: Some( [ "--frozen", "--output-file=requirements.txt", ], ), env: None, always_run: None, fail_fast: None, pass_filenames: Some( None, ), description: Some( "Automatically run 'uv export' on your project dependencies", ), language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: { "minimum_pre_commit_version": String("2.9.2"), }, }, }, ], } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__read_toml_config.snap ================================================ --- source: crates/prek/src/config.rs expression: config --- Config { repos: [ Local( LocalRepo { repo: "local", hooks: [ LocalHook { id: "cargo-fmt", name: "cargo fmt", entry: "cargo fmt --", language: System, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), Remote( RemoteRepo { repo: "https://github.com/pre-commit/pre-commit-hooks", rev: "v6.0.0", hooks: [ RemoteHook { id: "trailing-whitespace", name: None, entry: None, language: None, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, RemoteHook { id: "end-of-file-fixer", name: None, entry: None, language: None, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: Some( [ "--fix", "crlf", ], ), env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: None, fail_fast: Some( true, ), minimum_prek_version: None, orphan: None, _unused_keys: {}, } ================================================ FILE: crates/prek/src/snapshots/prek__config__tests__read_yaml_config.snap ================================================ --- source: crates/prek/src/config.rs expression: config --- Config { repos: [ Remote( RemoteRepo { repo: "https://github.com/abravalheri/validate-pyproject", rev: "v0.20.2", hooks: [ RemoteHook { id: "validate-pyproject", name: None, entry: None, language: None, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), Remote( RemoteRepo { repo: "https://github.com/crate-ci/typos", rev: "v1.26.0", hooks: [ RemoteHook { id: "typos", name: None, entry: None, language: None, priority: Some( 10, ), options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), Local( LocalRepo { repo: "local", hooks: [ LocalHook { id: "cargo-fmt", name: "cargo fmt", entry: "cargo fmt --", language: System, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: Some( [ "rust", ], ), types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: Some( None, ), description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), Local( LocalRepo { repo: "local", hooks: [ LocalHook { id: "cargo-dev-generate-all", name: "cargo dev generate-all", entry: "cargo dev generate-all", language: System, priority: None, options: HookOptions { alias: None, files: Some( Regex( ^crates/(uv-cli|uv-settings)/, ), ), exclude: None, types: Some( [ "rust", ], ), types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: Some( None, ), description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), Remote( RemoteRepo { repo: "https://github.com/pre-commit/mirrors-prettier", rev: "v3.1.0", hooks: [ RemoteHook { id: "prettier", name: None, entry: None, language: None, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: Some( [ "json5", "yaml", ], ), exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), Remote( RemoteRepo { repo: "https://github.com/astral-sh/ruff-pre-commit", rev: "v0.6.9", hooks: [ RemoteHook { id: "ruff-format", name: None, entry: None, language: None, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: None, env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, RemoteHook { id: "ruff", name: None, entry: None, language: None, priority: None, options: HookOptions { alias: None, files: None, exclude: None, types: None, types_or: None, exclude_types: None, additional_dependencies: None, args: Some( [ "--fix", "--exit-non-zero-on-fix", ], ), env: None, always_run: None, fail_fast: None, pass_filenames: None, description: None, language_version: None, log_file: None, require_serial: None, stages: None, verbose: None, minimum_prek_version: None, _unused_keys: {}, }, }, ], _unused_keys: {}, }, ), ], default_install_hook_types: None, default_language_version: None, default_stages: None, files: None, exclude: Some( Regex( (?x)^( .*/(snapshots)/.*| )$ , ), ), fail_fast: Some( true, ), minimum_prek_version: None, orphan: None, _unused_keys: {}, } ================================================ FILE: crates/prek/src/store.rs ================================================ use std::hash::{DefaultHasher, Hash, Hasher}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Result; use etcetera::BaseStrategy; use futures::StreamExt; use rustc_hash::{FxHashMap, FxHashSet}; use thiserror::Error; use tracing::{debug, warn}; use prek_consts::env_vars::EnvVars; use crate::config::RemoteRepo; use crate::fs::LockedFile; use crate::git::{self, TerminalPrompt}; use crate::hook::InstallInfo; use crate::run::CONCURRENCY; use crate::warn_user; use crate::workspace::{HookInitReporter, WorkspaceCache}; struct PendingClone<'a> { repo: &'a RemoteRepo, } enum FirstClonePass<'a> { Ready { repo: &'a RemoteRepo, temp: tempfile::TempDir, progress: Option, }, AuthFailed { repo: &'a RemoteRepo, error: git::Error, progress: Option, }, } #[derive(Debug, Error)] pub enum Error { #[error("Home directory not found")] HomeNotFound, #[error(transparent)] Io(#[from] std::io::Error), #[error("Failed to clone repo `{repo}`")] CloneRepo { repo: String, #[source] error: git::Error, }, #[error(transparent)] Serde(#[from] serde_json::Error), } /// Expand a path starting with `~` to the user's home directory. fn expand_tilde(path: PathBuf) -> PathBuf { if let Ok(stripped) = path.strip_prefix("~") { if let Some(home) = std::env::home_dir() { return home.join(stripped); } } path } pub(crate) const REPO_MARKER: &str = ".prek-repo.json"; /// A store for managing repos. #[derive(Debug)] pub struct Store { path: PathBuf, } impl Store { pub(crate) fn from_path(path: impl Into) -> Self { Self { path: path.into() } } /// Create a store from environment variables or default paths. pub(crate) fn from_settings() -> Result { let path = if let Some(path) = EnvVars::var_os(EnvVars::PREK_HOME) { Some(expand_tilde(PathBuf::from(path))) } else { etcetera::choose_base_strategy() .map(|path| path.cache_dir().join("prek")) .ok() }; let Some(path) = path else { return Err(Error::HomeNotFound); }; let store = Store::from_path(path).init()?; Ok(store) } pub(crate) fn path(&self) -> &Path { self.path.as_ref() } /// Initialize the store. pub(crate) fn init(self) -> Result { fs_err::create_dir_all(&self.path)?; fs_err::create_dir_all(self.repos_dir())?; fs_err::create_dir_all(self.hooks_dir())?; fs_err::create_dir_all(self.scratch_path())?; match fs_err::OpenOptions::new() .write(true) .create_new(true) .open(self.path.join("README")) { Ok(mut f) => f.write_all(b"This directory is maintained by the prek project.\nLearn more: https://github.com/j178/prek\n")?, Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => (), Err(err) => return Err(err.into()), } Ok(self) } async fn clone_repo_to_temp( &self, repo: &RemoteRepo, terminal_prompt: TerminalPrompt, ) -> Result { let temp = tempfile::tempdir_in(self.scratch_path())?; debug!( target = %temp.path().display(), %repo, ?terminal_prompt, "Cloning repo" ); git::clone_repo(&repo.repo, &repo.rev, temp.path(), terminal_prompt).await?; Ok(temp) } async fn persist_cloned_repo( &self, repo: &RemoteRepo, temp: tempfile::TempDir, ) -> Result { let target = self.repo_path(repo); // TODO: add windows retry fs_err::tokio::remove_dir_all(&target).await.ok(); fs_err::tokio::rename(temp, &target).await?; let content = serde_json::to_string_pretty(&repo)?; fs_err::tokio::write(target.join(REPO_MARKER), content).await?; Ok(target) } /// Clone remote repositories into the store. /// /// The first pass runs in parallel with terminal prompts disabled. Repositories that fail /// with an authentication error are retried afterwards, sequentially, with terminal prompts /// enabled so the user can provide credentials for one repository at a time. pub(crate) async fn clone_repos<'a>( &self, repos: impl IntoIterator, reporter: Option<&dyn HookInitReporter>, ) -> Result, Error> { #[expect(clippy::mutable_key_type)] let mut cloned = FxHashMap::default(); let mut pending = Vec::new(); for repo in repos { let target = self.repo_path(repo); if target.join(REPO_MARKER).try_exists()? { cloned.insert(repo.clone(), target); continue; } pending.push(PendingClone { repo }); } let mut auth_failed = Vec::new(); let mut tasks = futures::stream::iter(pending) .map(async |pending| { let progress = reporter.map(|reporter| reporter.on_clone_start(&format!("{}", pending.repo))); match self .clone_repo_to_temp(pending.repo, TerminalPrompt::Disabled) .await { Ok(temp) => Ok(FirstClonePass::Ready { repo: pending.repo, temp, progress, }), Err(err) if git::is_auth_error(&err) => { warn!( repo = %pending.repo.repo, ?err, "Clone failed with authentication error and terminal prompts disabled" ); Ok(FirstClonePass::AuthFailed { repo: pending.repo, error: err, progress, }) } Err(err) => Err(Error::CloneRepo { repo: pending.repo.repo.clone(), error: err, }), } }) .buffer_unordered(*CONCURRENCY); while let Some(result) = tasks.next().await { match result? { FirstClonePass::Ready { repo, temp, progress, } => { let path = self.persist_cloned_repo(repo, temp).await?; if let (Some(reporter), Some(progress)) = (reporter, progress) { reporter.on_clone_complete(progress); } cloned.insert(repo.clone(), path); } FirstClonePass::AuthFailed { repo, error, progress, } => { if let (Some(reporter), Some(progress)) = (reporter, progress) { reporter.on_clone_complete(progress); } auth_failed.push((repo, error)); } } } if EnvVars::is_under_ci() { // CI cannot answer interactive credential prompts, so surface the original auth // failure instead of attempting the prompt-enabled retry path. if let Some((repo, error)) = auth_failed.into_iter().next() { return Err(Error::CloneRepo { repo: repo.repo.clone(), error, }); } return Ok(cloned); } if !auth_failed.is_empty() { // Tear down the shared MultiProgress before warning/prompt output so progress redraws // do not overwrite terminal messages or git credential prompts. reporter.map(HookInitReporter::on_complete); } for (repo, _error) in auth_failed { warn_user!( "Authentication may be required to clone repository `{}`. Retrying with terminal prompts enabled.", repo.repo ); let temp = self .clone_repo_to_temp(repo, TerminalPrompt::Enabled) .await .map_err(|error| Error::CloneRepo { repo: repo.repo.clone(), error, })?; let path = self.persist_cloned_repo(repo, temp).await?; cloned.insert(repo.clone(), path); } Ok(cloned) } /// Clone a single remote repository into the store. pub(crate) async fn clone_repo( &self, repo: &RemoteRepo, reporter: Option<&dyn HookInitReporter>, ) -> Result { #[expect(clippy::mutable_key_type)] let cloned = self.clone_repos(std::iter::once(repo), reporter).await?; cloned.get(repo).cloned().ok_or_else(|| Error::CloneRepo { repo: repo.repo.clone(), error: git::Error::Io(std::io::Error::other("repo was not cloned")), }) } /// Returns installed hooks in the store. pub(crate) async fn installed_hooks(&self) -> Vec> { let Ok(dirs) = fs_err::read_dir(self.hooks_dir()) else { return vec![]; }; let mut tasks = futures::stream::iter(dirs) .map(async |entry| { let path = match entry { Ok(entry) => entry.path(), Err(err) => { warn!(%err, "Failed to read hook dir"); return None; } }; let info = match InstallInfo::from_env_path(&path).await { Ok(info) => info, Err(err) => { warn!(%err, path = %path.display(), "Skipping invalid installed hook"); return None; } }; Some(info) }) .buffer_unordered(*CONCURRENCY); let mut hooks = Vec::new(); while let Some(hook) = tasks.next().await { if let Some(hook) = hook { hooks.push(Arc::new(hook)); } } hooks } pub(crate) async fn lock_async(&self) -> Result { LockedFile::acquire(self.path.join(".lock"), "store").await } /// Returns the path to where a remote repo would be stored. pub(crate) fn repo_path(&self, repo: &RemoteRepo) -> PathBuf { self.repos_dir().join(Self::repo_key(repo)) } /// Returns the store key (directory name) for a remote repo. pub(crate) fn repo_key(repo: &RemoteRepo) -> String { let mut hasher = DefaultHasher::new(); repo.hash(&mut hasher); to_hex(hasher.finish()) } pub(crate) fn repos_dir(&self) -> PathBuf { self.path.join("repos") } pub(crate) fn hooks_dir(&self) -> PathBuf { self.path.join("hooks") } pub(crate) fn patches_dir(&self) -> PathBuf { self.path.join("patches") } pub(crate) fn tools_dir(&self) -> PathBuf { self.path.join("tools") } pub(crate) fn cache_dir(&self) -> PathBuf { self.path.join("cache") } /// The path to the tool directory in the store. pub(crate) fn tools_path(&self, tool: ToolBucket) -> PathBuf { self.tools_dir().join(tool.as_ref()) } pub(crate) fn cache_path(&self, tool: CacheBucket) -> PathBuf { self.cache_dir().join(tool.as_ref()) } /// Scratch path for temporary files. pub(crate) fn scratch_path(&self) -> PathBuf { self.path.join("scratch") } pub(crate) fn log_file(&self) -> PathBuf { self.path.join("prek.log") } pub(crate) fn config_tracking_file(&self) -> PathBuf { self.path.join("config-tracking.json") } /// Get all tracked config files. /// /// Seed `config-tracking.json` from the workspace discovery cache if it doesn't exist. /// This is a one-time upgrade helper: it only does work when tracking is empty. pub(crate) fn tracked_configs(&self) -> Result, Error> { let tracking_file = self.config_tracking_file(); match fs_err::read_to_string(&tracking_file) { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e.into()), Ok(content) => { let tracked = serde_json::from_str(&content).unwrap_or_else(|e| { warn!("Failed to parse config tracking file: {e}, resetting"); FxHashSet::default() }); return Ok(tracked); } } let cached = WorkspaceCache::cached_config_paths(self); if cached.is_empty() { return Ok(FxHashSet::default()); } debug!( count = cached.len(), "Bootstrapping config tracking from workspace cache" ); self.update_tracked_configs(&cached)?; Ok(cached) } /// Track new config files for GC. pub(crate) fn track_configs<'a>( &self, config_paths: impl Iterator, ) -> Result<(), Error> { let mut tracked = self.tracked_configs()?; for config_path in config_paths { tracked.insert(config_path.to_path_buf()); } let tracking_file = self.config_tracking_file(); let content = serde_json::to_string_pretty(&tracked)?; fs_err::write(&tracking_file, content)?; Ok(()) } /// Update the tracked configs file. pub(crate) fn update_tracked_configs(&self, configs: &FxHashSet) -> Result<(), Error> { let tracking_file = self.config_tracking_file(); let content = serde_json::to_string_pretty(configs)?; fs_err::write(&tracking_file, content)?; Ok(()) } } #[derive(Copy, Clone, Eq, Hash, PartialEq, strum::EnumIter, strum::AsRefStr, strum::Display)] #[strum(serialize_all = "lowercase")] pub(crate) enum ToolBucket { Uv, Python, Node, Go, Ruby, Rustup, Bun, Deno, } #[derive(Copy, Clone, Eq, Hash, PartialEq, strum::AsRefStr, strum::Display)] #[strum(serialize_all = "lowercase")] pub(crate) enum CacheBucket { Uv, Go, Python, Cargo, Deno, Prek, } /// Convert a u64 to a hex string. fn to_hex(num: u64) -> String { hex::encode(num.to_le_bytes()) } ================================================ FILE: crates/prek/src/version.rs ================================================ /* MIT License Copyright (c) 2023 Astral Software Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ // See also use std::fmt; use serde::Serialize; /// Information about the git repository where prek was built from. #[derive(Serialize)] pub(crate) struct CommitInfo { pub(crate) short_commit_hash: String, pub(crate) commit_hash: String, pub(crate) commit_date: String, pub(crate) last_tag: Option, pub(crate) commits_since_last_tag: u32, } /// prek's version. #[derive(Serialize)] pub(crate) struct VersionInfo { /// prek's version, such as "0.0.6" pub(crate) version: String, /// Information about the git commit we may have been built from. /// /// `None` if not built from a git repo or if retrieval failed. pub(crate) commit_info: Option, } impl fmt::Display for VersionInfo { /// Formatted version information: "[+] ( )" fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.version)?; if let Some(ref ci) = self.commit_info { if ci.commits_since_last_tag > 0 { write!(f, "+{}", ci.commits_since_last_tag)?; } write!(f, " ({} {})", ci.short_commit_hash, ci.commit_date)?; } Ok(()) } } impl From for clap::builder::Str { fn from(val: VersionInfo) -> Self { val.to_string().into() } } /// Returns information about prek's version. pub fn version() -> VersionInfo { // Environment variables are only read at compile-time macro_rules! option_env_str { ($name:expr) => { option_env!($name).map(|s| s.to_string()) }; } // This version is pulled from Cargo.toml and set by Cargo let version = env!("CARGO_PKG_VERSION").to_string(); // Commit info is pulled from git and set by `build.rs` let commit_info = option_env_str!("PREK_COMMIT_HASH").map(|commit_hash| CommitInfo { short_commit_hash: option_env_str!("PREK_COMMIT_SHORT_HASH").unwrap(), commit_hash, commit_date: option_env_str!("PREK_COMMIT_DATE").unwrap(), last_tag: option_env_str!("PREK_LAST_TAG"), commits_since_last_tag: option_env_str!("PREK_LAST_TAG_DISTANCE") .as_deref() .map_or(0, |value| value.parse::().unwrap_or(0)), }); VersionInfo { version, commit_info, } } ================================================ FILE: crates/prek/src/warnings.rs ================================================ // MIT License // // Copyright (c) 2023 Astral Software Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. use std::collections::HashSet; use std::sync::atomic::AtomicBool; use std::sync::{LazyLock, Mutex}; // macro hygiene: The user might not have direct dependencies on those crates #[doc(hidden)] pub use anstream; #[doc(hidden)] pub use owo_colors; /// Whether user-facing warnings are enabled. pub static ENABLED: AtomicBool = AtomicBool::new(false); /// Enable user-facing warnings. pub fn enable() { ENABLED.store(true, std::sync::atomic::Ordering::SeqCst); } /// Disable user-facing warnings. pub fn disable() { ENABLED.store(false, std::sync::atomic::Ordering::SeqCst); } /// Warn a user, if warnings are enabled. #[macro_export] macro_rules! warn_user { ($($arg:tt)*) => { use $crate::warnings::anstream::eprintln; use $crate::warnings::owo_colors::OwoColorize; if $crate::warnings::ENABLED.load(std::sync::atomic::Ordering::SeqCst) { let message = format!("{}", format_args!($($arg)*)); let formatted = message.bold(); eprintln!("{}{} {formatted}", "warning".yellow().bold(), ":".bold()); } }; } pub static WARNINGS: LazyLock>> = LazyLock::new(Mutex::default); /// Warn a user once, if warnings are enabled, with uniqueness determined by the content of the /// message. #[macro_export] macro_rules! warn_user_once { ($($arg:tt)*) => { use $crate::warnings::anstream::eprintln; use $crate::warnings::owo_colors::OwoColorize; if $crate::warnings::ENABLED.load(std::sync::atomic::Ordering::SeqCst) { if let Ok(mut states) = $crate::warnings::WARNINGS.lock() { let message = format!("{}", format_args!($($arg)*)); if states.insert(message.clone()) { eprintln!("{}{} {}", "warning".yellow().bold(), ":".bold(), message.bold()); } } } }; } ================================================ FILE: crates/prek/src/workspace.rs ================================================ use std::borrow::Cow; use std::fmt::Display; use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; use anyhow::Result; use ignore::WalkState; use itertools::zip_eq; use owo_colors::OwoColorize; use prek_consts::CONFIG_FILENAMES; use rustc_hash::{FxHashMap, FxHashSet}; use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{debug, error, instrument, trace}; use crate::cli::run::Selectors; use crate::config::{self, Config, read_config}; use crate::fs::Simplified; use crate::git::GIT_ROOT; use crate::hook::HookSpec; use crate::hook::{self, Hook, HookBuilder, Repo}; use crate::store::{CacheBucket, Store}; use crate::{git, store, warn_user}; #[derive(Error, Debug)] pub(crate) enum Error { #[error(transparent)] Config(#[from] config::Error), #[error(transparent)] Hook(#[from] hook::Error), #[error(transparent)] Git(#[from] anyhow::Error), #[error( "No `prek.toml` or `.pre-commit-config.yaml` found in the current directory or parent directories.\n\n{} If you just added one, rerun your command with the `--refresh` flag to rescan the workspace.", "hint:".yellow().bold(), )] MissingConfigFile, #[error("Hook `{hook}` not present in repo `{repo}`")] HookNotFound { hook: String, repo: String }, #[error(transparent)] Store(#[from] store::Error), } pub(crate) trait HookInitReporter { fn on_clone_start(&self, repo: &str) -> usize; fn on_clone_complete(&self, id: usize); fn on_complete(&self); } #[derive(Clone)] pub(crate) struct Project { /// The absolute path of the project directory. root: PathBuf, /// The absolute path of the configuration file. config_path: PathBuf, /// The relative path of the project directory from the git root. relative_path: PathBuf, // The order index of the project in the workspace. idx: usize, config: Config, repos: Vec>, } impl std::fmt::Debug for Project { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Project") .field("relative_path", &self.relative_path) .field("idx", &self.idx) .field("config", &self.config) .field("repos", &self.repos) .finish_non_exhaustive() } } impl Display for Project { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.is_root() { write!(f, ".") } else { write!(f, "{}", self.relative_path.display()) } } } impl PartialEq for Project { fn eq(&self, other: &Self) -> bool { self.config_path == other.config_path } } impl Eq for Project {} impl Hash for Project { fn hash(&self, state: &mut H) { self.config_path.hash(state); } } impl Project { /// Initialize a new project from the configuration file with an optional root path. /// If root is not given, it will be the parent directory of the configuration file. pub(crate) fn from_config_file( config_path: Cow<'_, Path>, root: Option, ) -> Result { debug!( path = %config_path.user_display(), "Loading project configuration" ); let mut config = read_config(&config_path)?; let size = config.repos.len(); let config_dir = config_path .parent() .expect("config file must have a parent"); // Resolve relative repo paths against the config file's directory. // This ensures paths like `../hook-repo` are resolved from where the // config file lives, not from the process's current working directory. for repo in &mut config.repos { if let config::Repo::Remote(remote) = repo { let repo_path = Path::new(&remote.repo); if !remote.repo.starts_with("http://") && !remote.repo.starts_with("https://") && repo_path.is_relative() { let resolved = config_dir.join(repo_path); if resolved.is_dir() { remote.repo = resolved.to_string_lossy().into_owned(); } } } } let root = root.unwrap_or_else(|| config_dir.to_path_buf()); Ok(Self { root, config, config_path: config_path.into_owned(), idx: 0, relative_path: PathBuf::new(), repos: Vec::with_capacity(size), }) } fn find_config(path: &Path) -> Option { for name in CONFIG_FILENAMES { let file = path.join(name); if file.is_file() { return Some(file); } } None } fn find_all_configs(path: &Path) -> Vec<(&'static str, PathBuf)> { let mut configs = Vec::new(); for &name in CONFIG_FILENAMES { let file = path.join(name); if file.is_file() { configs.push((name, file)); } } configs } /// Find the configuration file in the given path. pub(crate) fn from_directory(path: &Path) -> Result { let present = Self::find_all_configs(path); let Some((_, selected)) = present.first() else { return Err(Error::MissingConfigFile); }; if present.len() > 1 { let found = present .iter() .map(|(name, _)| format!("`{name}`")) .collect::>() .join(", "); warn_user!( "Multiple configuration files found ({found}); using `{selected}`", found = found, selected = selected.display(), ); } Self::from_config_file(Cow::Borrowed(selected), None) } /// Discover a project from the give path or search from the given path to the git root. pub(crate) fn discover(config_file: Option<&Path>, dir: &Path) -> Result { let git_root = GIT_ROOT.as_ref().map_err(|e| Error::Git(e.into()))?; if let Some(config) = config_file { return Project::from_config_file(config.into(), Some(git_root.clone())); } let workspace_root = Workspace::find_root(None, dir)?; debug!("Found project root at `{}`", workspace_root.user_display()); Project::from_directory(&workspace_root) } pub(crate) fn with_relative_path(&mut self, relative_path: PathBuf) { self.relative_path = relative_path; } fn with_idx(&mut self, idx: usize) { self.idx = idx; } pub(crate) fn config(&self) -> &Config { &self.config } /// Get the path to the configuration file. /// Must be an absolute path. pub(crate) fn config_file(&self) -> &Path { &self.config_path } /// Get the path to the project directory. pub(crate) fn path(&self) -> &Path { &self.root } /// Get the path to the project directory relative to the workspace root. /// /// Hooks will be executed in this directory and accept only files from this directory. /// In non-workspace mode (`--config `), this is empty. pub(crate) fn relative_path(&self) -> &Path { &self.relative_path } pub(crate) fn is_root(&self) -> bool { self.relative_path.as_os_str().is_empty() } pub(crate) fn depth(&self) -> usize { self.relative_path.components().count() } pub(crate) fn idx(&self) -> usize { self.idx } /// Initialize the project, cloning the repository and preparing hooks. pub(crate) async fn init_hooks( &mut self, store: &Store, reporter: Option<&dyn HookInitReporter>, ) -> Result, Error> { self.init_repos(store, reporter).await?; // TODO: avoid clone let project = Arc::new(self.clone()); let hooks = project.internal_init_hooks().await?; Ok(hooks) } /// Initialize remote repositories for the project. #[allow(clippy::mutable_key_type)] async fn init_repos( &mut self, store: &Store, reporter: Option<&dyn HookInitReporter>, ) -> Result<(), Error> { let mut seen = FxHashSet::default(); // Prepare remote repos in parallel. let remotes_iter = self.config.repos.iter().filter_map(|repo| match repo { // Deduplicate remote repos. config::Repo::Remote(repo) if seen.insert(repo) => Some(repo), _ => None, }); let cloned_repos = store.clone_repos(remotes_iter, reporter).await?; let mut remote_repos = FxHashMap::default(); for (repo_config, path) in cloned_repos { let repo = Arc::new(Repo::remote( repo_config.repo.clone(), repo_config.rev.clone(), path, )?); remote_repos.insert(repo_config, repo); } let mut repos = Vec::with_capacity(self.config.repos.len()); for repo in &self.config.repos { match repo { config::Repo::Remote(repo) => { let repo = remote_repos.get(repo).expect("repo not found"); repos.push(repo.clone()); } config::Repo::Local(repo) => { let repo = Repo::local(repo.hooks.clone()); repos.push(Arc::new(repo)); } config::Repo::Meta(repo) => { let repo = Repo::meta(repo.hooks.clone()); repos.push(Arc::new(repo)); } config::Repo::Builtin(repo) => { let repo = Repo::builtin(repo.hooks.clone()); repos.push(Arc::new(repo)); } } } self.repos = repos; Ok(()) } /// Load and prepare hooks for the project. async fn internal_init_hooks(self: Arc) -> Result, Error> { let mut hooks = Vec::new(); for (repo_config, repo) in zip_eq(self.config.repos.iter(), self.repos.iter()) { match repo_config { config::Repo::Remote(repo_config) => { for hook_config in &repo_config.hooks { // Check hook id is valid. let Some(manifest_hook) = repo.get_hook(&hook_config.id) else { return Err(Error::HookNotFound { hook: hook_config.id.clone(), repo: repo.to_string(), }); }; let mut hook_spec = manifest_hook.clone(); hook_spec.apply_remote_hook_overrides(hook_config); let builder = HookBuilder::new( self.clone(), Arc::clone(repo), hook_spec, hooks.len(), ); let hook = builder.build().await?; hooks.push(hook); } } config::Repo::Local(repo_config) => { for hook_config in &repo_config.hooks { let hook_spec = HookSpec::from(hook_config.clone()); let builder = HookBuilder::new( self.clone(), Arc::clone(repo), hook_spec, hooks.len(), ); let hook = builder.build().await?; hooks.push(hook); } } config::Repo::Meta(repo_config) => { for hook_config in &repo_config.hooks { let hook_spec = HookSpec::from(hook_config.clone()); let builder = HookBuilder::new( self.clone(), Arc::clone(repo), hook_spec, hooks.len(), ); let hook = builder.build().await?; hooks.push(hook); } } config::Repo::Builtin(repo_config) => { for hook_config in &repo_config.hooks { let hook_spec = HookSpec::from(hook_config.clone()); let builder = HookBuilder::new( self.clone(), Arc::clone(repo), hook_spec, hooks.len(), ); let hook = builder.build().await?; hooks.push(hook); } } } } Ok(hooks) } } /// Cache entry for a project configuration file #[derive(Debug, Clone, Serialize, Deserialize)] struct CachedConfigFile { /// Absolute path to the config file path: PathBuf, /// Last modification time modified: SystemTime, /// File size for quick change detection size: u64, } /// Workspace discovery cache #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct WorkspaceCache { /// Cache version for compatibility version: u32, /// Workspace root path workspace_root: PathBuf, /// Cache creation timestamp created_at: SystemTime, /// Configuration files with their metadata config_files: Vec, } impl WorkspaceCache { const CURRENT_VERSION: u32 = 1; /// Maximum cache age before forcing rediscovery (1 hour) const MAX_CACHE_AGE: u64 = 60 * 60; /// Create a new cache from workspace discovery results fn new(workspace_root: PathBuf, projects: &[Project]) -> Self { let mut config_files = Vec::new(); for project in projects { if let Ok(metadata) = std::fs::metadata(&project.config_path) { config_files.push(CachedConfigFile { path: project.config_path.clone(), modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), size: metadata.len(), }); } } Self { version: Self::CURRENT_VERSION, created_at: SystemTime::now(), workspace_root, config_files, } } /// Check if the cache is still valid fn is_valid(&self) -> bool { // Check cache age - invalidate if older than MAX_CACHE_AGE if let Ok(elapsed) = self.created_at.elapsed() { if elapsed.as_secs() > Self::MAX_CACHE_AGE { debug!( "Cache is too old ({}s > {}s), invalidating", elapsed.as_secs(), Self::MAX_CACHE_AGE ); return false; } } // Check if all config files still exist and haven't been modified for cached_file in &self.config_files { if let Ok(metadata) = std::fs::metadata(&cached_file.path) { let current_modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH); let current_size = metadata.len(); if current_modified != cached_file.modified || current_size != cached_file.size { debug!( path = %cached_file.path.display(), "Config file changed, invalidating cache" ); return false; } } else { debug!( path = %cached_file.path.display(), "Config file no longer exists, invalidating cache" ); return false; } } // Check if workspace root still exists if !self.workspace_root.exists() { debug!("Workspace root no longer exists, invalidating cache"); return false; } // Note: We don't check for newly added config files here to avoid // expensive directory traversal. New files will be detected when // the cache fails to load a project during cache restoration, // or when the cache expires due to age (every hour). true } /// Get cache file path for a workspace fn cache_path(store: &Store, workspace_root: &Path) -> PathBuf { let mut hasher = DefaultHasher::new(); workspace_root.hash(&mut hasher); let digest = hex::encode(hasher.finish().to_le_bytes()); store .cache_path(CacheBucket::Prek) .join("workspace") .join(digest) } /// Load cache from file fn load(store: &Store, workspace_root: &Path, refresh: bool) -> Option { if refresh { return None; } let cache_path = Self::cache_path(store, workspace_root); match std::fs::read_to_string(&cache_path) { Ok(content) => match serde_json::from_str::(&content) { Ok(cache) => { if cache.version == Self::CURRENT_VERSION && cache.is_valid() { Some(cache) } else { // Invalid cache, remove it let _ = std::fs::remove_file(&cache_path); None } } Err(e) => { debug!("Failed to deserialize cache: {}", e); let _ = std::fs::remove_file(&cache_path); None } }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => { debug!("Failed to read cache file: {}", e); None } } } /// Save cache to file fn save(&self, store: &Store) -> Result<()> { let cache_path = Self::cache_path(store, &self.workspace_root); // Create cache directory if it doesn't exist if let Some(parent) = cache_path.parent() { std::fs::create_dir_all(parent)?; } let content = serde_json::to_string_pretty(self)?; std::fs::write(&cache_path, content)?; Ok(()) } /// Best-effort source of config paths for bootstrapping config tracking. /// /// This is used on upgrades from older versions that didn't track configs yet. /// It reads all cached workspace discovery entries under `cache/prek/workspace/*` /// and collects any config file paths they mention. pub(crate) fn cached_config_paths(store: &Store) -> FxHashSet { let mut paths: FxHashSet = FxHashSet::default(); let workspace_cache_root = store.cache_path(CacheBucket::Prek).join("workspace"); let entries = match fs_err::read_dir(&workspace_cache_root) { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return paths, Err(err) => { debug!(path = %workspace_cache_root.display(), %err, "Failed to read workspace cache directory for tracking bootstrap"); return paths; } }; for entry in entries { let entry = match entry { Ok(entry) => entry, Err(err) => { debug!(%err, "Failed to read workspace cache entry for tracking bootstrap"); continue; } }; let path = entry.path(); if !path.is_file() { continue; } let content = match fs_err::read_to_string(&path) { Ok(content) => content, Err(err) => { debug!(path = %path.display(), %err, "Failed to read workspace cache file for tracking bootstrap"); continue; } }; let cache: WorkspaceCache = match serde_json::from_str(&content) { Ok(cache) => cache, Err(err) => { debug!(path = %path.display(), %err, "Failed to parse workspace cache file for tracking bootstrap"); continue; } }; if cache.version != WorkspaceCache::CURRENT_VERSION { continue; } for file in cache.config_files { paths.insert(file.path); } } paths } } pub(crate) struct Workspace { root: PathBuf, projects: Vec>, all_projects: Vec, } impl Workspace { /// Find the workspace root. /// `dir` must be an absolute path. pub(crate) fn find_root(config_file: Option<&Path>, dir: &Path) -> Result { let git_root = GIT_ROOT.as_ref().map_err(|e| Error::Git(e.into()))?; if config_file.is_some() { // For `--config `, the workspace root is the git root. return Ok(git_root.clone()); } // Walk from the given path up to the git root, to find the workspace root. let workspace_root = dir .ancestors() .take_while(|p| git_root.parent().map(|root| *p != root).unwrap_or(true)) .find(|p| Project::find_config(p).is_some()) .ok_or(Error::MissingConfigFile)? .to_path_buf(); debug!("Found workspace root at `{}`", workspace_root.display()); Ok(workspace_root) } /// Discover the workspace from the given workspace root. #[instrument(level = "trace", skip(store, selectors))] pub(crate) fn discover( store: &Store, root: PathBuf, config: Option, selectors: Option<&Selectors>, refresh: bool, ) -> Result { if let Some(config) = config { let project = Project::from_config_file(config.into(), Some(root.clone()))?; let arc_project = Arc::new(project.clone()); return Ok(Self { root, projects: vec![arc_project], all_projects: vec![project], }); } // Try to load from cache first let projects = if let Some(cache) = WorkspaceCache::load(store, &root, refresh) { debug!("Loaded workspace from cache"); let projects: Result, _> = cache .config_files .into_iter() .map( |config_file| match Project::from_config_file(config_file.path.into(), None) { Ok(mut project) => { let relative_path = project .config_file() .parent() .and_then(|p| p.strip_prefix(&root).ok()) .expect("Entry path should be relative to the root") .to_path_buf(); project.with_relative_path(relative_path); Ok(project) } Err(e) => { debug!("Failed to load cached project config: {}", e); Err(e) } }, ) .collect(); match projects { Ok(projects) if !projects.is_empty() => Some(projects), _ => { debug!("Cache invalid or empty, performing fresh discovery"); None } } } else { None }; let mut all_projects = if let Some(projects) = projects { projects } else { // Cache miss or invalid, perform fresh discovery debug!("Performing fresh workspace discovery"); let projects = Self::discover_fresh(&root, selectors)?; // Save to cache let cache = WorkspaceCache::new(root.clone(), &projects); if let Err(e) = cache.save(store) { debug!("Failed to save workspace cache: {}", e); } projects }; Self::sort_and_index_projects(&mut all_projects); let projects = if let Some(selectors) = selectors { let selected = all_projects .iter() .filter(|p| selectors.matches_path(p.relative_path())) .cloned() .map(Arc::new) .collect::>(); if selected.is_empty() { return Err(Error::MissingConfigFile); } selected } else { all_projects .iter() .cloned() .map(Arc::new) .collect::>() }; if projects.is_empty() { return Err(Error::MissingConfigFile); } Ok(Self { root, projects, all_projects, }) } /// Perform fresh workspace discovery without cache fn discover_fresh(root: &Path, selectors: Option<&Selectors>) -> Result, Error> { let projects = Mutex::new(Ok(Vec::new())); let git_root = GIT_ROOT.as_ref().map_err(|e| Error::Git(e.into()))?; let submodules = git::list_submodules(git_root).unwrap_or_else(|e| { error!("Failed to list git submodules: {e}"); Vec::new() }); ignore::WalkBuilder::new(root) .follow_links(false) .add_custom_ignore_filename(".prekignore") .build_parallel() .run(|| { Box::new(|result| { let Ok(entry) = result else { return WalkState::Continue; }; let Some(file_type) = entry.file_type() else { return WalkState::Continue; }; if !file_type.is_dir() { return WalkState::Continue; } // Skip cookiecutter template directories if entry.file_name().to_str().is_some_and(|filename| { filename.starts_with("{{") && filename.ends_with("}}") && filename.contains("cookiecutter") }) { trace!( path = %entry.path().user_display(), "Skipping cookiecutter template directory" ); return WalkState::Skip; } // Do not descend into git submodules if submodules .iter() .any(|submodule| entry.path().starts_with(submodule)) { trace!( path = %entry.path().user_display(), "Skipping git submodule" ); return WalkState::Skip; } match Project::from_directory(entry.path()) { Ok(mut project) => { let relative_path = entry .into_path() .strip_prefix(root) .expect("Entry path should be relative to the root") .to_path_buf(); project.with_relative_path(relative_path); if let Ok(projects) = projects.lock().unwrap().as_mut() { projects.push(project); } } Err(Error::MissingConfigFile) => {} Err(e) => { // Exit early if the path is selected if let Some(selectors) = selectors { let relative_path = entry .path() .strip_prefix(root) .expect("Entry path should be relative to the root"); if selectors.matches_path(relative_path) { *projects.lock().unwrap() = Err(e); return WalkState::Quit; } } // Otherwise, just log the error and continue error!( path = %entry.path().user_display(), "Skipping project due to error: {e}" ); return WalkState::Skip; } } WalkState::Continue }) }); let projects = projects.into_inner().unwrap()?; if projects.is_empty() { return Err(Error::MissingConfigFile); } Ok(projects) } /// Sort projects by depth and assign indices fn sort_and_index_projects(projects: &mut [Project]) { // Sort projects by their depth in the directory tree. // The deeper the project comes first. // This is useful for nested projects where we want to prefer the most specific project. projects.sort_by(|a, b| { b.depth() .cmp(&a.depth()) // If depth is the same, sort by relative path to have a deterministic order. .then_with(|| a.relative_path.cmp(&b.relative_path)) }); // Assign index to each project. for (idx, project) in projects.iter_mut().enumerate() { project.with_idx(idx); } } pub(crate) fn root(&self) -> &Path { &self.root } pub(crate) fn projects(&self) -> &[Arc] { &self.projects } pub(crate) fn all_projects(&self) -> &[Project] { &self.all_projects } /// Initialize remote repositories for all projects. async fn init_repos( &mut self, store: &Store, reporter: Option<&dyn HookInitReporter>, ) -> Result<(), Error> { #[allow(clippy::mutable_key_type)] let remote_repos = { let mut seen = FxHashSet::default(); // Prepare remote repos in parallel. let remotes_iter = self .projects .iter() .flat_map(|proj| proj.config.repos.iter()) .filter_map(|repo| match repo { // Deduplicate remote repos. config::Repo::Remote(repo) if seen.insert(repo) => Some(repo), _ => None, }); let cloned_repos = store.clone_repos(remotes_iter, reporter).await?; let mut remote_repos = FxHashMap::default(); for (repo_config, path) in cloned_repos { let repo = Arc::new(Repo::remote( repo_config.repo.clone(), repo_config.rev.clone(), path, )?); remote_repos.insert(repo_config, repo); } remote_repos }; for project in &mut self.projects { let mut repos = Vec::with_capacity(project.config.repos.len()); for repo in &project.config.repos { match repo { config::Repo::Remote(repo) => { let repo = remote_repos.get(repo).expect("repo not found"); repos.push(repo.clone()); } config::Repo::Local(repo) => { let repo = Repo::local(repo.hooks.clone()); repos.push(Arc::new(repo)); } config::Repo::Meta(repo) => { let repo = Repo::meta(repo.hooks.clone()); repos.push(Arc::new(repo)); } config::Repo::Builtin(repo) => { let repo = Repo::builtin(repo.hooks.clone()); repos.push(Arc::new(repo)); } } } Arc::get_mut(project).unwrap().repos = repos; } Ok(()) } /// Load and prepare hooks for all projects. pub(crate) async fn init_hooks( &mut self, store: &Store, reporter: Option<&dyn HookInitReporter>, ) -> Result, Error> { self.init_repos(store, reporter).await?; let mut hooks = Vec::new(); for project in &self.projects { let project_hooks = Arc::clone(project).internal_init_hooks().await?; hooks.extend(project_hooks); } reporter.map(HookInitReporter::on_complete); Ok(hooks) } /// Check if all configuration files are staged in git. pub(crate) async fn check_configs_staged(&self) -> Result<()> { let config_files = self .projects .iter() .map(|project| project.config_file()) .collect::>(); let non_staged = git::files_not_staged(&config_files).await?; let git_root = GIT_ROOT.as_ref()?; if !non_staged.is_empty() { let non_staged = non_staged .into_iter() .map(|p| git_root.join(p)) .collect::>(); match non_staged.as_slice() { [filename] => anyhow::bail!( "prek configuration file is not staged, run `{}` to stage it", format!("git add {}", filename.user_display()).cyan() ), _ => anyhow::bail!( "The following configuration files are not staged, `git add` them first:\n{}", non_staged .iter() .map(|p| format!(" {}", p.user_display())) .collect::>() .join("\n") ), } } Ok(()) } } ================================================ FILE: crates/prek/src/yaml.rs ================================================ // Licensed under the Apache License, Version 2.0 or the MIT license // , at your // option. This file may not be copied, modified, or distributed // except according to those terms. use std::fmt::Write; /// Serialize a YAML scalar while preserving the caller's quote style. pub(crate) fn serialize_yaml_scalar(value: &str, quote: &str) -> anyhow::Result { match quote { "'" => Ok(format!("'{}'", escape_single_quoted(value))), "\"" => Ok(format!("\"{}\"", escape_double_quoted(value))), _ => { if is_simple_plain(value) { Ok(value.to_owned()) } else { // Defer to serde-saphyr to select quoting/escaping for non-trivial scalars. let rendered = serde_saphyr::to_string(&value)?; Ok(rendered.trim_end_matches('\n').to_owned()) } } } } /// Fast-path: allow simple, plain scalars we want to keep unquoted. fn is_simple_plain(value: &str) -> bool { if value.is_empty() { return false; } value .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | '/' | '+' | '@')) } /// YAML single-quoted strings escape a single quote by doubling it. fn escape_single_quoted(value: &str) -> String { value.replace('\'', "''") } /// YAML double-quoted strings use backslash escapes for control characters. fn escape_double_quoted(value: &str) -> String { let mut escaped = String::with_capacity(value.len()); for ch in value.chars() { match ch { '\\' => escaped.push_str("\\\\"), '"' => escaped.push_str("\\\""), '\t' => escaped.push_str("\\t"), '\n' => escaped.push_str("\\n"), '\r' => escaped.push_str("\\r"), c if c.is_control() => { let _ = write!(escaped, "\\u{:04X}", c as u32); } c => escaped.push(c), } } escaped } #[cfg(test)] mod tests { use super::serialize_yaml_scalar; #[test] fn serialize_yaml_scalar_plain() { let rendered = serialize_yaml_scalar("v1.2.3", "").unwrap(); assert_eq!(rendered, "v1.2.3"); let rendered = serialize_yaml_scalar("v1.2.3", "'").unwrap(); assert_eq!(rendered, "'v1.2.3'"); let rendered = serialize_yaml_scalar("v1.2.3", "\"").unwrap(); assert_eq!(rendered, "\"v1.2.3\""); let rendered = serialize_yaml_scalar("123", "").unwrap(); assert_eq!(rendered, "123"); let rendered = serialize_yaml_scalar("123", "'").unwrap(); assert_eq!(rendered, "'123'"); let rendered = serialize_yaml_scalar("123", "\"").unwrap(); assert_eq!(rendered, "\"123\""); let rendered = serialize_yaml_scalar("a:b", "").unwrap(); assert_eq!(rendered, "a:b"); let rendered = serialize_yaml_scalar("a:b", "'").unwrap(); assert_eq!(rendered, "'a:b'"); let rendered = serialize_yaml_scalar("a\"b", "\"").unwrap(); assert_eq!(rendered, "\"a\\\"b\""); let rendered = serialize_yaml_scalar("a'b", "'").unwrap(); assert_eq!(rendered, "'a''b'"); let rendered = serialize_yaml_scalar("abc def", "").unwrap(); assert_eq!(rendered, "abc def"); let rendered = serialize_yaml_scalar("abc def", "'").unwrap(); assert_eq!(rendered, "'abc def'"); let rendered = serialize_yaml_scalar("abc def", "\"").unwrap(); assert_eq!(rendered, "\"abc def\""); } #[test] fn serialize_yaml_scalar_quotes_and_escapes() { let rendered = serialize_yaml_scalar("a\\b", "\"").unwrap(); assert_eq!(rendered, "\"a\\\\b\""); let rendered = serialize_yaml_scalar("a\nb", "\"").unwrap(); assert_eq!(rendered, "\"a\\nb\""); let rendered = serialize_yaml_scalar("a\tb", "\"").unwrap(); assert_eq!(rendered, "\"a\\tb\""); let rendered = serialize_yaml_scalar("a\\b", "'").unwrap(); assert_eq!(rendered, "'a\\b'"); } } ================================================ FILE: crates/prek/tests/auto_update.rs ================================================ use anyhow::Result; use assert_cmd::assert::OutputAssertExt; use assert_fs::fixture::ChildPath; use assert_fs::prelude::*; use insta::assert_snapshot; use prek_consts::{PRE_COMMIT_CONFIG_YAML, PREK_TOML}; use crate::common::{TestContext, cmd_snapshot, git_cmd}; mod common; const BASE_TIMESTAMP: u64 = 1_000_000_000; const INCREMENTING_STEP_SECS: u64 = 100; const FIXED_STEP_SECS: u64 = 0; /// Helper function to create a local git repository with hooks and incrementing timestamps. fn create_local_git_repo(context: &TestContext, repo_name: &str, tags: &[&str]) -> Result { create_local_git_repo_with_timestamps(context, repo_name, tags, INCREMENTING_STEP_SECS) } /// Like `create_local_git_repo`, but all commits and tags share a single fixed timestamp. /// Simulates mirror repos where all tags are imported simultaneously. fn create_local_git_repo_fixed_ts( context: &TestContext, repo_name: &str, tags: &[&str], ) -> Result { create_local_git_repo_with_timestamps(context, repo_name, tags, FIXED_STEP_SECS) } fn create_local_git_repo_with_timestamps( context: &TestContext, repo_name: &str, tags: &[&str], timestamp_step_secs: u64, ) -> Result { let repo_dir = context.home_dir().child(format!("test-repos/{repo_name}")); repo_dir.create_dir_all()?; git_cmd(&repo_dir) .arg("-c") .arg("init.defaultBranch=master") .arg("init") .assert() .success(); // Create .pre-commit-hooks.yaml repo_dir .child(".pre-commit-hooks.yaml") .write_str(indoc::indoc! {r#" - id: test-hook name: Test Hook entry: echo language: system - id: another-hook name: Another Hook entry: python3 -c 'print("hello")' language: python "#})?; git_cmd(&repo_dir).arg("add").arg(".").assert().success(); let mut timestamp = BASE_TIMESTAMP; git_cmd(&repo_dir) .arg("commit") .arg("-m") .arg("Initial commit") .env("GIT_AUTHOR_DATE", format!("{timestamp} +0000")) .env("GIT_COMMITTER_DATE", format!("{timestamp} +0000")) .assert() .success(); // Create tags for tag in tags { timestamp += timestamp_step_secs; git_cmd(&repo_dir) .arg("commit") .arg("-m") .arg(format!("Release {tag}")) .arg("--allow-empty") .env("GIT_AUTHOR_DATE", format!("{timestamp} +0000")) .env("GIT_COMMITTER_DATE", format!("{timestamp} +0000")) .assert() .success(); git_cmd(&repo_dir) .arg("tag") .arg(tag) .arg("-m") .arg(tag) .env("GIT_AUTHOR_DATE", format!("{timestamp} +0000")) .env("GIT_COMMITTER_DATE", format!("{timestamp} +0000")) .assert() .success(); } timestamp += timestamp_step_secs; // Add an extra commit to the tip git_cmd(&repo_dir) .arg("commit") .arg("-m") .arg("tip") .arg("--allow-empty") .env("GIT_AUTHOR_DATE", format!("{timestamp} +0000")) .env("GIT_COMMITTER_DATE", format!("{timestamp} +0000")) .assert() .success(); Ok(repo_dir.to_string_lossy().to_string()) } #[test] fn auto_update_basic() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "test-repo", &["v1.0.0", "v1.1.0", "v2.0.0"])?; context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/test-repo] updating v1.0.0 -> v2.0.0 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" repos: - repo: [HOME]/test-repos/test-repo rev: v2.0.0 hooks: - id: test-hook "#); } ); Ok(()) } #[test] fn auto_update_already_up_to_date() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "up-to-date-repo", &["v1.0.0"])?; context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/up-to-date-repo] already up to date ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" repos: - repo: [HOME]/test-repos/up-to-date-repo rev: v1.0.0 hooks: - id: test-hook "#); } ); Ok(()) } #[test] #[cfg(unix)] fn auto_update_does_not_rewrite_config_when_up_to_date() -> Result<()> { use std::time::UNIX_EPOCH; let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "up-to-date-repo-mtime", &["v1.0.0"])?; context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); let config_path = context.work_dir().child(PRE_COMMIT_CONFIG_YAML); let before_secs = std::fs::metadata(config_path.path())? .modified()? .duration_since(UNIX_EPOCH)? .as_secs(); let assert = context .auto_update() .arg("--cooldown-days") .arg("0") .assert() .success(); let stdout = String::from_utf8_lossy(&assert.get_output().stdout); assert!(stdout.contains("already up to date")); let after_secs = std::fs::metadata(config_path.path())? .modified()? .duration_since(UNIX_EPOCH)? .as_secs(); assert_eq!(after_secs, before_secs); Ok(()) } #[test] fn auto_update_multiple_repos_mixed() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo1_path = create_local_git_repo(&context, "repo1", &["v1.0.0", "v1.1.0"])?; let repo2_path = create_local_git_repo(&context, "repo2", &["v2.0.0"])?; context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook - repo: {} rev: v1.0.0 hooks: - id: same-hook - repo: {} rev: v2.0.0 hooks: - id: another-hook ", repo1_path, repo1_path, repo2_path}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/repo1] updating v1.0.0 -> v1.1.0 [[HOME]/test-repos/repo2] already up to date ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r" repos: - repo: [HOME]/test-repos/repo1 rev: v1.1.0 hooks: - id: test-hook - repo: [HOME]/test-repos/repo1 rev: v1.1.0 hooks: - id: same-hook - repo: [HOME]/test-repos/repo2 rev: v2.0.0 hooks: - id: another-hook "); } ); Ok(()) } /// Test that `auto-update` ignores the `GIT_DIR` environment variable. #[test] fn test_resolve_revision_ignores_git_dir_env_var() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "target-repo", &["v0.1.0", "v0.2.0"])?; let external_repo_path = create_local_git_repo(&context, "external-repo", &["v9.9.9"])?; context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v0.1.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); let filters = context.filters(); let mut cmd = context.auto_update(); cmd.arg("--cooldown-days") .arg("0") .env("GIT_DIR", ChildPath::new(&external_repo_path).join(".git")); cmd_snapshot!(filters.clone(), cmd, @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/target-repo] updating v0.1.0 -> v0.2.0 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" repos: - repo: [HOME]/test-repos/target-repo rev: v0.2.0 hooks: - id: test-hook "#); } ); Ok(()) } #[test] fn auto_update_specific_repos() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo1_path = create_local_git_repo(&context, "repo1", &["v1.0.0", "v1.1.0"])?; let repo2_path = create_local_git_repo(&context, "repo2", &["v2.0.0", "v2.1.0"])?; context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook - repo: {} rev: v2.0.0 hooks: - id: another-hook ", repo1_path, repo2_path}); context.git_add("."); let filters = context.filters(); // Update only repo1 cmd_snapshot!(filters.clone(), context.auto_update().arg("--repo").arg(&repo1_path).arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/repo1] updating v1.0.0 -> v1.1.0 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" repos: - repo: [HOME]/test-repos/repo1 rev: v1.1.0 hooks: - id: test-hook - repo: [HOME]/test-repos/repo2 rev: v2.0.0 hooks: - id: another-hook "#); } ); // Update both repo1 and repo2 cmd_snapshot!(filters.clone(), context.auto_update().arg("--repo").arg(&repo1_path).arg("--repo").arg(&repo2_path).arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/repo1] already up to date [[HOME]/test-repos/repo2] updating v2.0.0 -> v2.1.0 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" repos: - repo: [HOME]/test-repos/repo1 rev: v1.1.0 hooks: - id: test-hook - repo: [HOME]/test-repos/repo2 rev: v2.1.0 hooks: - id: another-hook "#); } ); Ok(()) } #[test] fn auto_update_bleeding_edge() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "bleeding-repo", &["v1.0.0"])?; context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); let filters = context .filters() .into_iter() .chain([("[a-f0-9]{40}", "[COMMIT_SHA]")]) .collect::>(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--bleeding-edge"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/bleeding-repo] updating v1.0.0 -> [COMMIT_SHA] ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" repos: - repo: [HOME]/test-repos/bleeding-repo rev: [COMMIT_SHA] hooks: - id: test-hook "#); } ); Ok(()) } #[test] fn auto_update_freeze() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "freeze-repo", &["v1.0.0", "v1.1.0"])?; // Make sure the "# frozen: v1.1.0" comment works correctly by adding a tag without dot git_cmd(&repo_path) .arg("tag") .arg("v1") .arg("-m") .arg("v1") .arg("v1.1.0^{}") .assert() .success(); context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); let filters = context .filters() .into_iter() .chain([(r" [a-f0-9]{40}", r" [COMMIT_SHA]")]) .collect::>(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--freeze").arg("--cooldown-days").arg("0"), @r" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/freeze-repo] updating v1.0.0 -> [COMMIT_SHA] ----- stderr ----- "); // Should contain frozen comment insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r##" repos: - repo: [HOME]/test-repos/freeze-repo rev: [COMMIT_SHA] # frozen: v1.1.0 hooks: - id: test-hook "##); } ); Ok(()) } #[test] fn auto_update_freeze_uses_dereferenced_commit_for_annotated_tags() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "freeze-annotated-repo", &["v1.0.0", "v1.1.0"])?; let tag_object_sha = git_cmd(&repo_path) .args(["rev-parse", "v1.1.0"]) .output()? .stdout; let tag_object_sha = str::from_utf8(&tag_object_sha)?.trim(); let commit_sha = git_cmd(&repo_path) .args(["rev-parse", "v1.1.0^{}"]) .output()? .stdout; let commit_sha = str::from_utf8(&commit_sha)?.trim(); assert_ne!( tag_object_sha, commit_sha, "sanity check failed: annotated tag object SHA should differ from commit SHA" ); context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); context .auto_update() .arg("--freeze") .arg("--cooldown-days") .arg("0") .assert() .success(); let config = context.read(PRE_COMMIT_CONFIG_YAML); assert!( config.contains(&format!("rev: {commit_sha}")), "expected config to contain the dereferenced commit SHA" ); assert!( config.contains("# frozen: v1.1.0"), "expected config to preserve the original tag in the frozen comment" ); assert!( !config.contains(tag_object_sha), "expected config to not contain the annotated tag object SHA" ); Ok(()) } #[test] fn auto_update_preserve_quote_style() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo1_path = create_local_git_repo(&context, "repo1", &["v1.0.0", "v1.1.0"])?; let repo2_path = create_local_git_repo(&context, "repo2", &["v1.0.0", "v1.1.0"])?; // Use specific formatting with comments context.write_pre_commit_config(&indoc::formatdoc! {r#" # Pre-commit configuration repos: - repo: {} # Test repository rev: v1.0.0 # No quotes hooks: - id: test-hook # Hook configuration name: Test Hook - repo: {} # Test repository rev: 'v1.0.0' # Single quotes hooks: - id: test-hook # Hook configuration name: Test Hook - repo: {} rev: "v1.0.0" # Double quotes hooks: - id: test-hook # Hook configuration name: Test Hook "#, repo1_path, repo1_path, repo2_path }); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/repo1] updating v1.0.0 -> v1.1.0 [[HOME]/test-repos/repo2] updating v1.0.0 -> v1.1.0 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" # Pre-commit configuration repos: - repo: [HOME]/test-repos/repo1 # Test repository rev: v1.1.0 # No quotes hooks: - id: test-hook # Hook configuration name: Test Hook - repo: [HOME]/test-repos/repo1 # Test repository rev: 'v1.1.0' # Single quotes hooks: - id: test-hook # Hook configuration name: Test Hook - repo: [HOME]/test-repos/repo2 rev: "v1.1.0" # Double quotes hooks: - id: test-hook # Hook configuration name: Test Hook "#); } ); Ok(()) } #[test] fn auto_update_with_existing_frozen_comment() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "frozen-repo", &["v1.0.0", "v1.1.0", "v1.2.0"])?; let commit_sha = "1234567890abcdef1234567890abcdef12345678"; context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: {} # frozen: v1.0.0 hooks: - id: test-hook ", repo_path, commit_sha}); context.git_add("."); let filters = context .filters() .into_iter() .chain([(commit_sha, "[COMMIT_SHA]")]) .collect::>(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/frozen-repo] updating [COMMIT_SHA] -> v1.2.0 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" repos: - repo: [HOME]/test-repos/frozen-repo rev: v1.2.0 hooks: - id: test-hook "#); } ); Ok(()) } #[test] fn auto_update_local_repo_ignored() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "remote-repo", &["v1.0.0", "v1.1.0"])?; context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: local hooks: - id: local-hook name: Local Hook language: system entry: echo - repo: {} rev: v1.0.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/remote-repo] updating v1.0.0 -> v1.1.0 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" repos: - repo: local hooks: - id: local-hook name: Local Hook language: system entry: echo - repo: [HOME]/test-repos/remote-repo rev: v1.1.0 hooks: - id: test-hook "#); } ); Ok(()) } #[test] fn missing_hook_ids() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "missing-hook-repo", &["v1.0.0"])?; // Remove the 'test-hook' from the hooks file ChildPath::new(&repo_path) .child(".pre-commit-hooks.yaml") .write_str(indoc::indoc! {r#" - id: another-hook name: Another Hook entry: python3 -c 'print("hello")' language: python "#})?; git_cmd(&repo_path).arg("add").arg(".").assert().success(); git_cmd(&repo_path) .arg("commit") .arg("-m") .arg("Remove test-hook") .assert() .success(); git_cmd(&repo_path) .arg("tag") .arg("v2.0.0") .arg("-m") .arg("v2.0.0") .assert() .success(); context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- [[HOME]/test-repos/missing-hook-repo] update failed: Cannot update to rev `v2.0.0`, hook is missing: test-hook "#); Ok(()) } #[test] fn auto_update_workspace() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo1_path = create_local_git_repo(&context, "workspace-repo1", &["v1.0.0", "v1.1.0", "v2.0.0"])?; let repo2_path = create_local_git_repo(&context, "workspace-repo2", &["v1.0.0", "v1.5.0"])?; let repo3_path = create_local_git_repo(&context, "workspace-repo3", &["v2.0.0"])?; context.setup_workspace( &["project-a", "project-b"], "repos: []", // Minimal valid config for root )?; context .work_dir() .child("project-a/.pre-commit-config.yaml") .write_str(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook - repo: {} rev: v1.0.0 hooks: - id: another-hook ", repo1_path, repo2_path})?; context .work_dir() .child("project-b/.pre-commit-config.yaml") .write_str(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: another-hook - repo: {} rev: v2.0.0 hooks: - id: test-hook ", repo2_path, repo3_path})?; context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/workspace-repo1] updating v1.0.0 -> v2.0.0 [[HOME]/test-repos/workspace-repo2] updating v1.0.0 -> v1.5.0 [[HOME]/test-repos/workspace-repo3] already up to date ----- stderr ----- "); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read("project-a/.pre-commit-config.yaml"), @r#" repos: - repo: [HOME]/test-repos/workspace-repo1 rev: v2.0.0 hooks: - id: test-hook - repo: [HOME]/test-repos/workspace-repo2 rev: v1.5.0 hooks: - id: another-hook "#); } ); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read("project-b/.pre-commit-config.yaml"), @r#" repos: - repo: [HOME]/test-repos/workspace-repo2 rev: v1.5.0 hooks: - id: another-hook - repo: [HOME]/test-repos/workspace-repo3 rev: v2.0.0 hooks: - id: test-hook "#); } ); Ok(()) } // When multiple tags point to the same object, prek prefers a tag that: // - contains a dot (e.g., a SemVer-like tag), and // - is most similar to the current revision, as measured by Levenshtein distance. #[test] fn prefer_similar_tags() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "remote-repo", &["v1.0.0", "v1.1.0"])?; // Add a second tag (`foo-v1.1.0`) pointing at the same commit as `v1.1.0`. // From the current `rev` (`v1.0.0`): // - `levenshtein(v1.0.0, v1.1.0) == 1` // - `levenshtein(v1.0.0, foo-v1.1.0) == 5` // Therefore, `v1.1.0` should be selected as the update target. // But if the newest SemVer-like tag (e.g v1.1.111111) were less similar than `foo-v1.1.0`, we would select `foo-v1.1.0` instead. git_cmd(&repo_path) .arg("tag") .arg("foo-v1.1.0") .arg("-m") .arg("foo-v1.1.0") .arg("v1.1.0^{}") .assert() .success(); // Add tag v1 pointing to the same commit as v1.1.0 git_cmd(&repo_path) .arg("tag") .arg("v1") .arg("-m") .arg("v1") .arg("v1.1.0^{}") .assert() .success(); context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: local hooks: - id: local-hook name: Local Hook language: system entry: echo - repo: {} rev: v1.0.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/remote-repo] updating v1.0.0 -> v1.1.0 ----- stderr ----- "); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r" repos: - repo: local hooks: - id: local-hook name: Local Hook language: system entry: echo - repo: [HOME]/test-repos/remote-repo rev: v1.1.0 hooks: - id: test-hook "); } ); Ok(()) } #[test] fn auto_update_dry_run() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "test-repo", &["v1.0.0", "v1.1.0", "v2.0.0"])?; context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--dry-run").arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/test-repo] updating v1.0.0 -> v2.0.0 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r" repos: - repo: [HOME]/test-repos/test-repo rev: v1.0.0 hooks: - id: test-hook "); } ); Ok(()) } #[test] fn quoting_float_like_version_number() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "test-repo", &["0.49", "0.50"])?; // Our serialize by default quotes this floats with single quotes, e.g., '0.49'. Use // a different quotaing style here to validate that this does not create conflicts. context.write_pre_commit_config(&indoc::formatdoc! {r#" repos: - repo: {} rev: "0.49" hooks: - id: test-hook "#, repo_path}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/test-repo] updating 0.49 -> 0.50 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" repos: - repo: [HOME]/test-repos/test-repo rev: "0.50" hooks: - id: test-hook "#); } ); Ok(()) } #[test] fn auto_update_with_invalid_config_file() -> Result<()> { let context = TestContext::new(); context.init_project(); // Write an invalid config file context .work_dir() .child(PRE_COMMIT_CONFIG_YAML) .write_str("invalid_yaml: [unclosed_list")?; let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update(), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to parse `.pre-commit-config.yaml` caused by: error: line 1 column 15: unclosed bracket '[' --> :1:15 | 1 | invalid_yaml: [unclosed_list | ^ unclosed bracket '[' "); Ok(()) } #[test] fn auto_update_toml() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "test-repo-toml", &["v1.0.0", "v1.1.0", "v2.0.0"])?; context .work_dir() .child(PREK_TOML) .write_str(&indoc::formatdoc! {r#" [[repos]] repo = "{}" rev = "v1.0.0" hooks = [ {{ id = "test-hook" }}, ] "#, repo_path.replace('\\', "/")})?; context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/test-repo-toml] updating v1.0.0 -> v2.0.0 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PREK_TOML), @r#" [[repos]] repo = "[HOME]/test-repos/test-repo-toml" rev = "v2.0.0" hooks = [ { id = "test-hook" }, ] "#); } ); Ok(()) } #[test] fn auto_update_toml_with_comment() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "test-repo-toml", &["v1.0.0", "v1.1.0", "v2.0.0"])?; context .work_dir() .child(PREK_TOML) .write_str(&indoc::formatdoc! {r#" [[repos]] repo = "{}" rev = "v1.0.0" # This is a comment hooks = [ {{ id = "test-hook" }}, ] "#, repo_path.replace('\\', "/")})?; context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/test-repo-toml] updating v1.0.0 -> v2.0.0 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PREK_TOML), @r#" [[repos]] repo = "[HOME]/test-repos/test-repo-toml" rev = "v2.0.0" # This is a comment hooks = [ { id = "test-hook" }, ] "#); } ); // "frozen: xx" comment should be removed context .work_dir() .child(PREK_TOML) .write_str(&indoc::formatdoc! {r#" [[repos]] repo = "{}" rev = "v1.0.0" # frozen: v1.0.0 hooks = [ {{ id = "test-hook" }}, ] "#, repo_path.replace('\\', "/")})?; context.git_add("."); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/test-repo-toml] updating v1.0.0 -> v2.0.0 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PREK_TOML), @r#" [[repos]] repo = "[HOME]/test-repos/test-repo-toml" rev = "v2.0.0" hooks = [ { id = "test-hook" }, ] "#); } ); Ok(()) } #[test] fn auto_update_freeze_toml() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "freeze-repo", &["v1.0.0", "v1.1.0"])?; // Make sure the "# frozen: v1.1.0" comment works correctly by adding a tag without dot git_cmd(&repo_path) .arg("tag") .arg("v1") .arg("-m") .arg("v1") .arg("v1.1.0^{}") .assert() .success(); context .work_dir() .child(PREK_TOML) .write_str(&indoc::formatdoc! {r#" [[repos]] repo = "{}" rev = "v1.0.0" hooks = [ {{ id = "test-hook" }}, ] "#, repo_path.replace('\\', "/")})?; context.git_add("."); let filters = context .filters() .into_iter() .chain([(r"[a-f0-9]{40}", r"[COMMIT_SHA]")]) .collect::>(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--freeze").arg("--cooldown-days").arg("0"), @r" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/freeze-repo] updating v1.0.0 -> [COMMIT_SHA] ----- stderr ----- "); // Should contain frozen comment insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PREK_TOML), @r#" [[repos]] repo = "[HOME]/test-repos/freeze-repo" rev = "[COMMIT_SHA]" # frozen: v1.1.0 hooks = [ { id = "test-hook" }, ] "#); } ); Ok(()) } #[test] fn auto_update_equal_timestamp_tags_picks_highest_version() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo_fixed_ts( &context, "mirror-repo", &["v1.0.0", "v1.0.1", "v1.0.2", "v1.0.3", "v1.0.4", "v1.0.5"], )?; context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.3 hooks: - id: test-hook ", repo_path}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/mirror-repo] updating v1.0.3 -> v1.0.5 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" repos: - repo: [HOME]/test-repos/mirror-repo rev: v1.0.5 hooks: - id: test-hook "#); } ); Ok(()) } // When all tags share a timestamp and some are non-semver (e.g. "latest", "stable"), // semver tags should be preferred and sorted highest-first. #[test] fn auto_update_equal_timestamp_prefers_semver_over_nonsemver() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo_fixed_ts( &context, "mixed-tags-repo", &["v1.0.0", "latest", "v2.0.0", "stable"], )?; context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/mixed-tags-repo] updating v1.0.0 -> v2.0.0 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" repos: - repo: [HOME]/test-repos/mixed-tags-repo rev: v2.0.0 hooks: - id: test-hook "#); } ); Ok(()) } // When tags span multiple timestamp groups, the newest group should be selected first. // Within an equal-timestamp group, semver tiebreaker picks the highest version. #[test] fn auto_update_mixed_timestamps_with_equal_subgroups() -> Result<()> { let context = TestContext::new(); context.init_project(); // Create base repo with v1.0.x tags at incrementing timestamps. let repo_path = create_local_git_repo(&context, "mixed-ts-repo", &["v1.0.0", "v1.0.1"])?; // Add a second group of tags sharing a single newer timestamp // (must be in the past so the cooldown filter doesn't exclude them). let newer_ts = "1500000000 +0000"; for tag in &["v2.0.1", "v2.0.0"] { git_cmd(&repo_path) .arg("commit") .arg("-m") .arg(format!("Release {tag}")) .arg("--allow-empty") .env("GIT_AUTHOR_DATE", newer_ts) .env("GIT_COMMITTER_DATE", newer_ts) .assert() .success(); git_cmd(&repo_path) .arg("tag") .arg(tag) .arg("-m") .arg(tag) .env("GIT_AUTHOR_DATE", newer_ts) .env("GIT_COMMITTER_DATE", newer_ts) .assert() .success(); } context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v1.0.0 hooks: - id: test-hook ", repo_path}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/mixed-ts-repo] updating v1.0.0 -> v2.0.1 ----- stderr ----- "#); insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" repos: - repo: [HOME]/test-repos/mixed-ts-repo rev: v2.0.1 hooks: - id: test-hook "#); } ); Ok(()) } #[test] fn auto_update_freeze_toml_with_comment() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_local_git_repo(&context, "freeze-repo", &["v1.0.0", "v1.1.0"])?; // Make sure the "# frozen: v1.1.0" comment works correctly by adding a tag without dot git_cmd(&repo_path) .arg("tag") .arg("v1") .arg("-m") .arg("v1") .arg("v1.1.0^{}") .assert() .success(); context .work_dir() .child(PREK_TOML) .write_str(&indoc::formatdoc! {r#" [[repos]] repo = "{}" # A comment above rev = "v1.0.0" # This is a comment # A comment below hooks = [ {{ id = "test-hook" }}, ] "#, repo_path.replace('\\', "/")})?; context.git_add("."); let filters = context .filters() .into_iter() .chain([(r"[a-f0-9]{40}", r"[COMMIT_SHA]")]) .collect::>(); cmd_snapshot!(filters.clone(), context.auto_update().arg("--freeze").arg("--cooldown-days").arg("0"), @r" success: true exit_code: 0 ----- stdout ----- [[HOME]/test-repos/freeze-repo] updating v1.0.0 -> [COMMIT_SHA] ----- stderr ----- "); // Should contain frozen comment insta::with_settings!( { filters => filters.clone() }, { assert_snapshot!(context.read(PREK_TOML), @r#" [[repos]] repo = "[HOME]/test-repos/freeze-repo" # A comment above rev = "[COMMIT_SHA]" # frozen: v1.1.0 # A comment below hooks = [ { id = "test-hook" }, ] "#); } ); Ok(()) } ================================================ FILE: crates/prek/tests/builtin_hooks.rs ================================================ #[cfg(unix)] use prek_consts::env_vars::EnvVars; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use anyhow::Result; use assert_fs::prelude::*; use insta::assert_snapshot; use prek_consts::PRE_COMMIT_CONFIG_YAML; use crate::common::{TestContext, cmd_snapshot}; mod common; /// Tests that `repo: builtin` hooks doesn't create hook env. #[test] fn builtin_hooks_not_create_env() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: end-of-file-fixer "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- fix end of files.........................................................Passed ----- stderr ----- "); let hooks_dir = context .home_dir() .join("hooks") .read_dir() .into_iter() .flatten() .flatten() .collect::>(); assert_eq!(hooks_dir.len(), 0); } #[test] fn builtin_hooks_unknown_hook() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: this-hook-does-not-exist "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to parse `.pre-commit-config.yaml` caused by: error: line 4 column 9: unknown builtin hook id `this-hook-does-not-exist` --> :4:9 | 2 | - repo: builtin 3 | hooks: 4 | - id: this-hook-does-not-exist | ^ unknown builtin hook id `this-hook-does-not-exist` "); } #[test] fn end_of_file_fixer_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: end-of-file-fixer "}); let cwd = context.work_dir(); // Create test files cwd.child("correct_lf.txt").write_str("Hello World\n")?; cwd.child("correct_crlf.txt").write_str("Hello World\r\n")?; cwd.child("no_newline.txt") .write_str("No trailing newline")?; cwd.child("multiple_lf.txt") .write_str("Multiple newlines\n\n\n")?; cwd.child("multiple_crlf.txt") .write_str("Multiple newlines\r\n\r\n")?; cwd.child("empty.txt").touch()?; cwd.child("only_newlines.txt").write_str("\n\n")?; cwd.child("only_win_newlines.txt").write_str("\r\n\r\n")?; context.git_add("."); // First run: hooks should fail and fix the files cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- fix end of files.........................................................Failed - hook id: end-of-file-fixer - exit code: 1 - files were modified by this hook Fixing multiple_crlf.txt Fixing only_newlines.txt Fixing only_win_newlines.txt Fixing no_newline.txt Fixing multiple_lf.txt ----- stderr ----- "); // Assert that the files have been corrected assert_snapshot!(context.read("correct_lf.txt"), @"Hello World"); assert_snapshot!(context.read("correct_crlf.txt"), @"Hello World"); assert_snapshot!(context.read("no_newline.txt"), @"No trailing newline"); assert_snapshot!(context.read("multiple_lf.txt"), @"Multiple newlines"); assert_snapshot!(context.read("multiple_crlf.txt"), @"Multiple newlines"); assert_snapshot!(context.read("empty.txt"), @""); assert_snapshot!(context.read("only_newlines.txt"), @""); assert_snapshot!(context.read("only_win_newlines.txt"), @""); context.git_add("."); // Second run: hooks should now pass. The output will be stable. cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- fix end of files.........................................................Passed ----- stderr ----- "); Ok(()) } #[test] fn check_yaml_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-yaml "}); let cwd = context.work_dir(); // Create test files cwd.child("valid.yaml").write_str("a: 1")?; cwd.child("invalid.yaml").write_str("a: b: c")?; cwd.child("duplicate.yaml").write_str("a: 1\na: 2")?; cwd.child("empty.yaml").touch()?; context.git_add("."); // First run: hooks should fail cmd_snapshot!(context.filters(), context.run(), @" success: false exit_code: 1 ----- stdout ----- check yaml...............................................................Failed - hook id: check-yaml - exit code: 1 duplicate.yaml: Failed to yaml decode (error: line 2 column 1: duplicate mapping key: a not allowed here --> :2:1 | 1 | a: 1 2 | a: 2 | ^ duplicate mapping key: a not allowed here) invalid.yaml: Failed to yaml decode (error: line 1 column 5: mapping values are not allowed in this context --> :1:5 | 1 | a: b: c | ^ mapping values are not allowed in this context) ----- stderr ----- "); // Fix the files cwd.child("invalid.yaml").write_str("a:\n b: c")?; cwd.child("duplicate.yaml").write_str("a: 1\nb: 2")?; context.git_add("."); // Second run: hooks should now pass cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check yaml...............................................................Passed ----- stderr ----- "); Ok(()) } #[test] fn check_yaml_multiple_document() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-yaml name: allow multiple documents args: [ --allow-multiple-documents ] - id: check-yaml name: disallow multiple documents "}); context .work_dir() .child("multiple.yaml") .write_str(indoc::indoc! {r" --- a: 1 --- b: 2 " })?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @" success: false exit_code: 1 ----- stdout ----- allow multiple documents.................................................Passed disallow multiple documents..............................................Failed - hook id: check-yaml - exit code: 1 multiple.yaml: Failed to yaml decode (error: line 4 column 1: only single YAML document expected but multiple found --> :4:1 | 2 | a: 1 3 | --- 4 | b: 2 | ^ only single YAML document expected but multiple found) ----- stderr ----- "); Ok(()) } #[test] fn check_json_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-json "}); let cwd = context.work_dir(); // Create test files cwd.child("valid.json").write_str(r#"{"a": 1}"#)?; cwd.child("invalid.json").write_str(r#"{"a": 1,}"#)?; cwd.child("duplicate.json") .write_str(r#"{"a": 1, "a": 2}"#)?; cwd.child("empty.json").touch()?; context.git_add("."); // First run: hooks should fail cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check json...............................................................Failed - hook id: check-json - exit code: 1 duplicate.json: Failed to json decode (duplicate key `a` at line 1 column 12) invalid.json: Failed to json decode (trailing comma at line 1 column 9) ----- stderr ----- "); // Fix the files cwd.child("invalid.json").write_str(r#"{"a": 1}"#)?; cwd.child("duplicate.json") .write_str(r#"{"a": 1, "b": 2}"#)?; context.git_add("."); // Second run: hooks should now pass cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check json...............................................................Passed ----- stderr ----- "); Ok(()) } #[test] fn mixed_line_ending_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: mixed-line-ending "}); let cwd = context.work_dir(); // Create test files cwd.child("mixed.txt") .write_str("line1\nline2\r\nline3\r\n")?; cwd.child("only_lf.txt").write_str("line1\nline2\n")?; cwd.child("only_crlf.txt").write_str("line1\r\nline2\r\n")?; cwd.child("no_endings.txt").write_str("hello world")?; cwd.child("empty.txt").touch()?; context.git_add("."); // First run: hooks should fail and fix the files cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- mixed line ending........................................................Failed - hook id: mixed-line-ending - exit code: 1 - files were modified by this hook Fixing mixed.txt ----- stderr ----- "); // Assert that the files have been corrected assert_snapshot!(context.read("mixed.txt"), @r" line1 line2 line3 "); assert_snapshot!(context.read("only_lf.txt"), @r" line1 line2 "); assert_snapshot!(context.read("only_crlf.txt"), @r" line1 line2 "); context.git_add("."); // Second run: hooks should now pass. cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- mixed line ending........................................................Passed ----- stderr ----- "); // Test with --fix=no context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: mixed-line-ending args: ['--fix=no'] "}); context .work_dir() .child("mixed.txt") .write_str("line1\nline2\r\n")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- mixed line ending........................................................Failed - hook id: mixed-line-ending - exit code: 1 mixed.txt: mixed line endings ----- stderr ----- "); assert_snapshot!(context.read("mixed.txt"), @r" line1 line2 "); // Test with --fix=crlf context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: mixed-line-ending args: ['--fix', 'crlf'] "}); context .work_dir() .child("mixed.txt") .write_str("line1\nline2\r\n")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- mixed line ending........................................................Failed - hook id: mixed-line-ending - exit code: 1 - files were modified by this hook Fixing .pre-commit-config.yaml Fixing mixed.txt Fixing only_lf.txt ----- stderr ----- "); assert_snapshot!(context.read("mixed.txt"), @r" line1 line2 "); // Test mixed args with missing value for `--fix` context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: mixed-line-ending args: ['--fix'] "}); context .work_dir() .child("mixed.txt") .write_str("line1\nline2\r\nline3\n")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to run hook `mixed-line-ending` caused by: error: a value is required for '--fix ' but none was supplied [possible values: auto, no, lf, crlf, cr] "); Ok(()) } #[test] fn check_added_large_files_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); // Create an initial commit let cwd = context.work_dir(); cwd.child("README.md").write_str("Initial commit")?; context.git_add("."); context.git_commit("Initial commit"); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-added-large-files args: ['--maxkb', '1'] "}); // Create test files cwd.child("small_file.txt").write_str("Hello World\n")?; let large_file = cwd.child("large_file.txt"); large_file.write_binary(&[0; 2048])?; // 2KB file context.git_add("."); // First run: hook should fail because of the large file cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check for added large files..............................................Failed - hook id: check-added-large-files - exit code: 1 large_file.txt (2 KB) exceeds 1 KB ----- stderr ----- "); // Commit the files context.git_add("."); context.git_commit("Add large file"); // Create a new unstaged large file let unstaged_large_file = cwd.child("unstaged_large_file.txt"); unstaged_large_file.write_binary(&[0; 2048])?; // 2KB file context.git_add("unstaged_large_file.txt"); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-added-large-files args: ['--maxkb=1', '--enforce-all'] "}); // Second run: the hook should check all files even if not staged cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r" success: false exit_code: 1 ----- stdout ----- check for added large files..............................................Failed - hook id: check-added-large-files - exit code: 1 unstaged_large_file.txt (2 KB) exceeds 1 KB large_file.txt (2 KB) exceeds 1 KB ----- stderr ----- "); context.git_rm("unstaged_large_file.txt"); context.git_clean(); // Test git-lfs integration context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-added-large-files args: ['--maxkb=1'] "}); cwd.child(".gitattributes") .write_str("*.dat filter=lfs diff=lfs merge=lfs -text")?; context.git_add(".gitattributes"); let lfs_file = cwd.child("lfs_file.dat"); lfs_file.write_binary(&[0; 2048])?; // 2KB file context.git_add("."); // Third run: hook should pass because the large file is tracked by git-lfs cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check for added large files..............................................Passed ----- stderr ----- "); Ok(()) } #[test] fn tracked_file_exceeds_large_file_limit() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-added-large-files args: ['--maxkb', '1'] "}); let cwd = context.work_dir(); // Create and commit a large file let large_file = cwd.child("large_file.txt"); large_file.write_binary(&[0; 2048])?; // 2KB file context.git_add("."); context.git_commit("Add large file"); // Modify the large file large_file.write_binary(&[0; 4096])?; // 4KB file context.git_add("."); // Run the hook: it should pass because the file is already tracked cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check for added large files..............................................Passed ----- stderr ----- "); Ok(()) } #[test] fn builtin_hooks_workspace_mode() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: meta hooks: - id: identity "}); // Subproject with built-in hooks. let app = context.work_dir().child("app"); app.create_dir_all()?; app.child(PRE_COMMIT_CONFIG_YAML) .write_str(indoc::indoc! {r" repos: - repo: meta hooks: - id: identity - repo: builtin hooks: - id: end-of-file-fixer - id: check-yaml - id: check-json - id: mixed-line-ending - id: trailing-whitespace - id: check-added-large-files args: ['--maxkb', '1'] "})?; app.child("eof_no_newline.txt") .write_str("No trailing newline")?; app.child("eof_multiple_lf.txt").write_str("Multiple\n\n")?; app.child("mixed.txt").write_str("line1\nline2\r\n")?; app.child("trailing_ws.txt") .write_str("line with trailing space \n")?; app.child("correct.txt").write_str("All good here\n")?; app.child("invalid.yaml").write_str("a: b: c")?; app.child("duplicate.yaml").write_str("a: 1\na: 2")?; app.child("empty.yaml").touch()?; app.child("invalid.json").write_str(r#"{"a": 1,}"#)?; app.child("duplicate.json") .write_str(r#"{"a": 1, "a": 2}"#)?; app.child("empty.json").touch()?; // 2KB file to trigger check-added-large-files (1 KB threshold). app.child("large.bin").write_binary(&[0u8; 2048])?; context.git_add("."); // First run: expect failures and auto-fixes where applicable. cmd_snapshot!(context.filters(), context.run(), @" success: false exit_code: 1 ----- stdout ----- Running hooks for `app`: identity.................................................................Passed - hook id: identity - duration: [TIME] correct.txt invalid.yaml empty.json duplicate.json trailing_ws.txt large.bin eof_multiple_lf.txt duplicate.yaml empty.yaml mixed.txt invalid.json .pre-commit-config.yaml eof_no_newline.txt fix end of files.........................................................Failed - hook id: end-of-file-fixer - exit code: 1 - files were modified by this hook Fixing invalid.yaml Fixing duplicate.json Fixing eof_no_newline.txt Fixing eof_multiple_lf.txt Fixing duplicate.yaml Fixing invalid.json check yaml...............................................................Failed - hook id: check-yaml - exit code: 1 duplicate.yaml: Failed to yaml decode (error: line 2 column 1: duplicate mapping key: a not allowed here --> :2:1 | 1 | a: 1 2 | a: 2 | ^ duplicate mapping key: a not allowed here) invalid.yaml: Failed to yaml decode (error: line 1 column 5: mapping values are not allowed in this context --> :1:5 | 1 | a: b: c | ^ mapping values are not allowed in this context) check json...............................................................Failed - hook id: check-json - exit code: 1 duplicate.json: Failed to json decode (duplicate key `a` at line 1 column 12) invalid.json: Failed to json decode (trailing comma at line 1 column 9) mixed line ending........................................................Failed - hook id: mixed-line-ending - exit code: 1 - files were modified by this hook Fixing mixed.txt trim trailing whitespace.................................................Failed - hook id: trailing-whitespace - exit code: 1 - files were modified by this hook Fixing trailing_ws.txt check for added large files..............................................Passed Running hooks for `.`: identity.................................................................Passed - hook id: identity - duration: [TIME] app/.pre-commit-config.yaml app/invalid.json app/duplicate.yaml app/correct.txt app/mixed.txt app/invalid.yaml app/empty.yaml app/duplicate.json app/empty.json app/large.bin app/eof_no_newline.txt .pre-commit-config.yaml app/eof_multiple_lf.txt app/trailing_ws.txt ----- stderr ----- "); // Fix YAML and JSON issues, then stage. app.child("invalid.yaml").write_str("a:\n b: c")?; app.child("duplicate.yaml").write_str("a: 1\nb: 2")?; app.child("invalid.json").write_str(r#"{"a": 1}"#)?; app.child("duplicate.json") .write_str(r#"{"a": 1, "b": 2}"#)?; context.git_add("."); // Second run: all hooks should pass. cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- Running hooks for `app`: identity.................................................................Passed - hook id: identity - duration: [TIME] correct.txt invalid.yaml empty.json duplicate.json trailing_ws.txt large.bin eof_multiple_lf.txt duplicate.yaml empty.yaml mixed.txt invalid.json .pre-commit-config.yaml eof_no_newline.txt fix end of files.........................................................Failed - hook id: end-of-file-fixer - exit code: 1 - files were modified by this hook Fixing invalid.yaml Fixing duplicate.json Fixing duplicate.yaml Fixing invalid.json check yaml...............................................................Passed check json...............................................................Passed mixed line ending........................................................Passed trim trailing whitespace.................................................Passed check for added large files..............................................Passed Running hooks for `.`: identity.................................................................Passed - hook id: identity - duration: [TIME] app/.pre-commit-config.yaml app/invalid.json app/duplicate.yaml app/correct.txt app/mixed.txt app/invalid.yaml app/empty.yaml app/duplicate.json app/empty.json app/large.bin app/eof_no_newline.txt .pre-commit-config.yaml app/eof_multiple_lf.txt app/trailing_ws.txt ----- stderr ----- "); Ok(()) } #[test] fn fix_byte_order_marker_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: fix-byte-order-marker "}); let cwd = context.work_dir(); // Create test files cwd.child("without_bom.txt").write_str("Hello, World!")?; cwd.child("with_bom.txt").write_binary(&[ 0xef, 0xbb, 0xbf, b'H', b'e', b'l', b'l', b'o', b',', b' ', b'W', b'o', b'r', b'l', b'd', b'!', ])?; cwd.child("bom_only.txt") .write_binary(&[0xef, 0xbb, 0xbf])?; cwd.child("empty.txt").touch()?; context.git_add("."); // First run: hooks should fix files with BOM cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- fix utf-8 byte order marker..............................................Failed - hook id: fix-byte-order-marker - exit code: 1 - files were modified by this hook bom_only.txt: removed byte-order marker with_bom.txt: removed byte-order marker ----- stderr ----- "); // Verify the content is correct assert_eq!(context.read("with_bom.txt"), "Hello, World!"); assert_eq!(context.read("bom_only.txt"), ""); assert_eq!(context.read("without_bom.txt"), "Hello, World!"); assert_eq!(context.read("empty.txt"), ""); context.git_add("."); // Second run: all should pass now cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- fix utf-8 byte order marker..............................................Passed ----- stderr ----- "); Ok(()) } #[test] #[cfg(unix)] fn check_symlinks_hook_unix() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-symlinks "}); let cwd = context.work_dir(); // Create test files cwd.child("regular.txt").write_str("regular file")?; cwd.child("target.txt").write_str("target content")?; // Create valid symlink std::os::unix::fs::symlink( cwd.child("target.txt").path(), cwd.child("valid_link.txt").path(), )?; // Create broken symlink std::os::unix::fs::symlink( cwd.child("nonexistent.txt").path(), cwd.child("broken_link.txt").path(), )?; context.git_add("."); // First run: should fail due to broken symlink cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check for broken symlinks................................................Failed - hook id: check-symlinks - exit code: 1 broken_link.txt: Broken symlink ----- stderr ----- "); // Remove broken symlink std::fs::remove_file(cwd.child("broken_link.txt").path())?; context.git_add("."); // Second run: should pass cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check for broken symlinks................................................Passed ----- stderr ----- "); Ok(()) } #[test] #[cfg(windows)] fn check_symlinks_hook_windows() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-symlinks "}); let cwd = context.work_dir(); // Create test files cwd.child("regular.txt").write_str("regular file")?; cwd.child("target.txt").write_str("target content")?; // Try to create valid symlink (may fail without admin/developer mode) let valid_link_result = std::os::windows::fs::symlink_file( cwd.child("target.txt").path(), cwd.child("valid_link.txt").path(), ); // Try to create broken symlink (may fail without admin/developer mode) let broken_link_result = std::os::windows::fs::symlink_file( cwd.child("nonexistent.txt").path(), cwd.child("broken_link.txt").path(), ); // Skip test if we can't create symlinks (insufficient permissions) if valid_link_result.is_err() || broken_link_result.is_err() { // Skipping test: insufficient permissions for symlink creation on Windows return Ok(()); } context.git_add("."); // First run: should fail due to broken symlink cmd_snapshot!(context.filters(), context.run(), @r#" success: false exit_code: 1 ----- stdout ----- check for broken symlinks................................................Failed - hook id: check-symlinks - exit code: 1 broken_link.txt: Broken symlink ----- stderr ----- "#); // Remove broken symlink std::fs::remove_file(cwd.child("broken_link.txt").path())?; context.git_add("."); // Second run: should pass cmd_snapshot!(context.filters(), context.run(), @r#" success: true exit_code: 0 ----- stdout ----- check for broken symlinks................................................Passed ----- stderr ----- "#); Ok(()) } #[test] fn detect_private_key_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: detect-private-key "}); let cwd = context.work_dir(); // Create test files - various private key types cwd.child("id_rsa") .write_str("-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----\n")?; cwd.child("id_dsa") .write_str("-----BEGIN DSA PRIVATE KEY-----\nAAAAA...\n-----END DSA PRIVATE KEY-----\n")?; cwd.child("id_ecdsa") .write_str("-----BEGIN EC PRIVATE KEY-----\nMHc...\n-----END EC PRIVATE KEY-----\n")?; cwd.child("id_ed25519").write_str( "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNz...\n-----END OPENSSH PRIVATE KEY-----\n", )?; cwd.child("key.ppk") .write_str("PuTTY-User-Key-File-2: ssh-rsa\nEncryption: none\n")?; cwd.child("private.asc") .write_str("-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: GnuPG...\n")?; cwd.child("ta.key").write_str( "#\n# 2048 bit OpenVPN static key\n#\n-----BEGIN OpenVPN Static key V1-----\n", )?; cwd.child("doc.txt").write_str( "Some documentation\n\nHere is a key:\n-----BEGIN RSA PRIVATE KEY-----\ndata\n", )?; cwd.child("safe1.txt") .write_str("This file talks about BEGIN_RSA_PRIVATE_KEY but doesn't contain one\n")?; cwd.child("safe2.txt") .write_str("This is just a regular file\nwith some content\n")?; cwd.child("empty.txt").touch()?; context.git_add("."); // First run: hooks should fail due to private keys cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- detect private key.......................................................Failed - hook id: detect-private-key - exit code: 1 Private key found: doc.txt Private key found: id_ecdsa Private key found: key.ppk Private key found: id_rsa Private key found: id_dsa Private key found: id_ed25519 Private key found: ta.key Private key found: private.asc ----- stderr ----- "); // Remove all private keys context.git_rm("id_rsa"); context.git_rm("id_dsa"); context.git_rm("id_ecdsa"); context.git_rm("id_ed25519"); context.git_rm("key.ppk"); context.git_rm("private.asc"); context.git_rm("ta.key"); context.git_rm("doc.txt"); context.git_clean(); context.git_add("."); // Second run: hooks should now pass cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- detect private key.......................................................Passed ----- stderr ----- "); Ok(()) } #[test] fn check_merge_conflict_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-merge-conflict args: ['--assume-in-merge'] "}); let cwd = context.work_dir(); // Create test files with conflict markers cwd.child("conflict.txt").write_str(indoc::indoc! {r" Before conflict <<<<<<< HEAD Our changes ======= Their changes >>>>>>> branch After conflict "})?; cwd.child("clean.txt").write_str("No conflicts here\n")?; cwd.child("partial_conflict.txt") .write_str(indoc::indoc! {r" Some content <<<<<<< HEAD Conflicting line "})?; context.git_add("."); // First run: hooks should fail due to conflict markers cmd_snapshot!(context.filters(), context.run(), @r#" success: false exit_code: 1 ----- stdout ----- check for merge conflicts................................................Failed - hook id: check-merge-conflict - exit code: 1 partial_conflict.txt:2: Merge conflict string "<<<<<<< " found conflict.txt:2: Merge conflict string "<<<<<<< " found conflict.txt:4: Merge conflict string "=======" found conflict.txt:6: Merge conflict string ">>>>>>> " found ----- stderr ----- "#); // Fix the files by removing conflict markers cwd.child("conflict.txt").write_str(indoc::indoc! {r" Before conflict Our changes After conflict "})?; cwd.child("partial_conflict.txt") .write_str("Some content\nResolved line\n")?; context.git_add("."); // Second run: hooks should now pass cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check for merge conflicts................................................Passed ----- stderr ----- "); Ok(()) } #[test] fn check_merge_conflict_without_assume_flag() -> Result<()> { let context = TestContext::new(); context.init_project(); // Without --assume-in-merge, hook should pass even with conflict markers // if we're not actually in a merge state context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-merge-conflict "}); let cwd = context.work_dir(); cwd.child("conflict.txt").write_str(indoc::indoc! {r" <<<<<<< HEAD Our changes ======= Their changes >>>>>>> branch "})?; context.git_add("."); // Should pass because we're not in a merge state and no --assume-in-merge flag cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check for merge conflicts................................................Passed ----- stderr ----- "); Ok(()) } #[test] fn check_xml_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-xml "}); let cwd = context.work_dir(); // Create test files cwd.child("valid.xml").write_str( r#" value "#, )?; cwd.child("invalid_unclosed.xml").write_str( r#" value "#, )?; cwd.child("invalid_mismatched.xml").write_str( r#" value "#, )?; cwd.child("multiple_roots.xml").write_str( r#" value value"#, )?; cwd.child("empty.xml").touch()?; context.git_add("."); // First run: hooks should fail cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check xml................................................................Failed - hook id: check-xml - exit code: 1 invalid_mismatched.xml: Failed to xml parse (ill-formed document: expected ``, but `` was found) empty.xml: Failed to xml parse (no element found) invalid_unclosed.xml: Failed to xml parse (ill-formed document: expected ``, but `` was found) multiple_roots.xml: Failed to xml parse (junk after document element) ----- stderr ----- "); // Fix the files cwd.child("invalid_unclosed.xml").write_str( r#" value "#, )?; cwd.child("invalid_mismatched.xml").write_str( r#" value "#, )?; cwd.child("multiple_roots.xml").write_str( r#" value value "#, )?; context.git_add("."); // Second run: hooks should now pass cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check xml................................................................Failed - hook id: check-xml - exit code: 1 empty.xml: Failed to xml parse (no element found) ----- stderr ----- "); Ok(()) } #[test] fn check_xml_with_features() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-xml "}); let cwd = context.work_dir(); // Create test files with various XML features cwd.child("with_attributes.xml").write_str( r#" value "#, )?; cwd.child("with_cdata.xml").write_str( r#" characters & symbols]]> "#, )?; cwd.child("with_comments.xml").write_str( r#" value "#, )?; cwd.child("with_doctype.xml").write_str( r#" value "#, )?; context.git_add("."); // All should pass cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check xml................................................................Passed ----- stderr ----- "); Ok(()) } #[test] fn no_commit_to_branch_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: no-commit-to-branch "}); let cwd = context.work_dir(); // Create a test file cwd.child("test.txt").write_str("Hello World")?; context.git_add("."); context.git_commit("Initial commit"); // Test 1: Try to commit to master branch (should fail) cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- don't commit to branch...................................................Failed - hook id: no-commit-to-branch - exit code: 1 You are not allowed to commit to branch 'master' ----- stderr ----- "); // Test 2: Create and switch to a feature branch (should pass) context.git_branch("feature/new-feature"); context.git_checkout("feature/new-feature"); cwd.child("feature.txt").write_str("Feature content")?; context.git_add("."); context.git_commit("Add feature"); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- don't commit to branch...................................................Passed ----- stderr ----- "); // Test 3: Try to commit to main branch (should fail) context.git_branch("main"); context.git_checkout("main"); cwd.child("main.txt").write_str("Main content")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- don't commit to branch...................................................Failed - hook id: no-commit-to-branch - exit code: 1 You are not allowed to commit to branch 'main' ----- stderr ----- "); Ok(()) } #[test] fn no_commit_to_branch_hook_with_custom_branches() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: no-commit-to-branch args: ['--branch', 'develop', '--branch', 'production'] "}); let cwd = context.work_dir(); // Create a test file cwd.child("test.txt").write_str("Hello World")?; context.git_add("."); context.git_commit("Initial commit"); // Test 1: Try to commit to master branch (should pass - not in custom list) cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- don't commit to branch...................................................Passed ----- stderr ----- "); // Test 2: Create and switch to develop branch (should fail) context.git_branch("develop"); context.git_checkout("develop"); cwd.child("develop.txt").write_str("Develop content")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- don't commit to branch...................................................Failed - hook id: no-commit-to-branch - exit code: 1 You are not allowed to commit to branch 'develop' ----- stderr ----- "); // Test 3: Create and switch to production branch (should fail) context.git_branch("production"); context.git_checkout("production"); cwd.child("production.txt") .write_str("Production content")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- don't commit to branch...................................................Failed - hook id: no-commit-to-branch - exit code: 1 You are not allowed to commit to branch 'production' ----- stderr ----- "); Ok(()) } #[test] fn no_commit_to_branch_hook_with_patterns() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: no-commit-to-branch args: ['--pattern', '^feature/.*', '--pattern', '.*-wip$'] "}); let cwd = context.work_dir(); // Create a test file cwd.child("test.txt").write_str("Hello World")?; context.git_add("."); context.git_commit("Initial commit"); // Test 1: Try to commit to master branch (should fail - If branch is not specified, branch defaults to master and main) cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- don't commit to branch...................................................Failed - hook id: no-commit-to-branch - exit code: 1 You are not allowed to commit to branch 'master' ----- stderr ----- "); // Test 2: Create and switch to feature branch (should fail - matches pattern) context.git_branch("feature/new-feature"); context.git_checkout("feature/new-feature"); cwd.child("feature.txt").write_str("Feature content")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- don't commit to branch...................................................Failed - hook id: no-commit-to-branch - exit code: 1 You are not allowed to commit to branch 'feature/new-feature' ----- stderr ----- "); // Test 3: Create and switch to wip branch (should fail - matches pattern) context.git_branch("my-branch-wip"); context.git_checkout("my-branch-wip"); cwd.child("wip.txt").write_str("WIP content")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- don't commit to branch...................................................Failed - hook id: no-commit-to-branch - exit code: 1 You are not allowed to commit to branch 'my-branch-wip' ----- stderr ----- "); // Test 4: Create and switch to normal branch (should pass - doesn't match patterns) context.git_branch("normal-branch"); context.git_checkout("normal-branch"); cwd.child("normal.txt").write_str("Normal content")?; context.git_add("."); context.git_commit("Add normal content"); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- don't commit to branch...................................................Passed ----- stderr ----- "); // Test 5: Try to run with detached head pointer status (should pass - ignore this status) context.git_checkout("HEAD~1"); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- don't commit to branch...................................................Passed ----- stderr ----- "); // Test 6: Try to commit to branch with invalid pattern (should fail - invalid pattern) context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: no-commit-to-branch args: ['--pattern', '*invalid-pattern*'] "}); context.git_branch("invalid-branch"); context.git_checkout("invalid-branch"); cwd.child("invalid.txt").write_str("Invalid content")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to run hook `no-commit-to-branch` caused by: Failed to compile regex patterns caused by: Parsing error at position 0: Target of repeat operator is invalid "); Ok(()) } #[cfg(unix)] #[test] fn check_executables_have_shebangs_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-executables-have-shebangs "}); let cwd = context.work_dir(); // Create test files cwd.child("script_with_shebang.sh") .write_str("#!/bin/bash\necho ok\n")?; cwd.child("script_without_shebang.sh") .write_str("echo missing shebang\n")?; cwd.child("not_executable.txt") .write_str("not executable\n")?; cwd.child("empty.sh").touch()?; // Mark scripts as executable std::fs::set_permissions( cwd.child("script_with_shebang.sh").path(), std::fs::Permissions::from_mode(0o755), )?; std::fs::set_permissions( cwd.child("script_without_shebang.sh").path(), std::fs::Permissions::from_mode(0o755), )?; std::fs::set_permissions( cwd.child("empty.sh").path(), std::fs::Permissions::from_mode(0o755), )?; context.git_add("."); // First run: should fail for script_without_shebang.sh and empty.sh cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check that executables have shebangs.....................................Failed - hook id: check-executables-have-shebangs - exit code: 1 empty.sh marked executable but has no (or invalid) shebang! If it isn't supposed to be executable, try: 'chmod -x empty.sh' If on Windows, you may also need to: 'git add --chmod=-x empty.sh' If it is supposed to be executable, double-check its shebang. script_without_shebang.sh marked executable but has no (or invalid) shebang! If it isn't supposed to be executable, try: 'chmod -x script_without_shebang.sh' If on Windows, you may also need to: 'git add --chmod=-x script_without_shebang.sh' If it is supposed to be executable, double-check its shebang. ----- stderr ----- "); // Fix the files: remove executable bit or add shebang cwd.child("script_without_shebang.sh") .write_str("#!/bin/sh\necho fixed\n")?; std::fs::set_permissions( cwd.child("empty.sh").path(), std::fs::Permissions::from_mode(0o644), )?; context.git_add("."); // Second run: should now pass cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check that executables have shebangs.....................................Passed ----- stderr ----- "); Ok(()) } #[cfg(windows)] #[test] fn check_executables_have_shebangs_win() -> Result<()> { use crate::common::git_cmd; let context = TestContext::new(); context.init_project(); let repo_path = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-executables-have-shebangs "}); let cwd = context.work_dir(); cwd.child("win_script_with_shebang.sh") .write_str("#!/bin/bash\necho ok\n")?; cwd.child("win_script_without_shebang.sh") .write_str("missing shebang\n")?; context.git_add("."); git_cmd(repo_path) .args(["update-index", "--chmod=+x", "win_script_with_shebang.sh"]) .status()?; git_cmd(repo_path) .args([ "update-index", "--chmod=+x", "win_script_without_shebang.sh", ]) .status()?; cmd_snapshot!(context.filters(), context.run(), @r#" success: false exit_code: 1 ----- stdout ----- check that executables have shebangs.....................................Failed - hook id: check-executables-have-shebangs - exit code: 1 win_script_without_shebang.sh marked executable but has no (or invalid) shebang! If it isn't supposed to be executable, try: 'chmod -x win_script_without_shebang.sh' If on Windows, you may also need to: 'git add --chmod=-x win_script_without_shebang.sh' If it is supposed to be executable, double-check its shebang. ----- stderr ----- "#); Ok(()) } #[cfg(unix)] #[test] fn check_executables_have_shebangs_various_cases() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-executables-have-shebangs "}); let cwd = context.work_dir(); // Create test files cwd.child("partial_shebang.sh") .write_str("#\necho partial\n")?; cwd.child("shebang_with_space.sh") .write_str("#! /bin/bash\necho ok\n")?; cwd.child("non_executable.txt") .write_str("not executable\n")?; cwd.child("whitespace.sh").write_str(" \n")?; cwd.child("invalid_shebang.sh") .write_str("##!/bin/bash\necho bad\n")?; // Mark scripts as executable std::fs::set_permissions( cwd.child("partial_shebang.sh").path(), std::fs::Permissions::from_mode(0o755), )?; std::fs::set_permissions( cwd.child("shebang_with_space.sh").path(), std::fs::Permissions::from_mode(0o755), )?; std::fs::set_permissions( cwd.child("whitespace.sh").path(), std::fs::Permissions::from_mode(0o755), )?; std::fs::set_permissions( cwd.child("invalid_shebang.sh").path(), std::fs::Permissions::from_mode(0o755), )?; // non_executable.txt is not marked executable context.git_add("."); // Run: should fail for partial_shebang.sh, whitespace.sh, invalid_shebang.sh cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check that executables have shebangs.....................................Failed - hook id: check-executables-have-shebangs - exit code: 1 partial_shebang.sh marked executable but has no (or invalid) shebang! If it isn't supposed to be executable, try: 'chmod -x partial_shebang.sh' If on Windows, you may also need to: 'git add --chmod=-x partial_shebang.sh' If it is supposed to be executable, double-check its shebang. invalid_shebang.sh marked executable but has no (or invalid) shebang! If it isn't supposed to be executable, try: 'chmod -x invalid_shebang.sh' If on Windows, you may also need to: 'git add --chmod=-x invalid_shebang.sh' If it is supposed to be executable, double-check its shebang. whitespace.sh marked executable but has no (or invalid) shebang! If it isn't supposed to be executable, try: 'chmod -x whitespace.sh' If on Windows, you may also need to: 'git add --chmod=-x whitespace.sh' If it is supposed to be executable, double-check its shebang. ----- stderr ----- "); // Fix the files: add valid shebangs or remove executable bit cwd.child("partial_shebang.sh") .write_str("#!/bin/sh\necho fixed\n")?; cwd.child("whitespace.sh").write_str("#!/bin/sh\n")?; cwd.child("invalid_shebang.sh") .write_str("#!/bin/bash\necho fixed\n")?; context.git_add("."); // Second run: should now pass cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check that executables have shebangs.....................................Passed ----- stderr ----- "); Ok(()) } #[cfg(windows)] #[test] fn check_executables_have_shebangs_various_cases_win() -> Result<()> { use crate::common::git_cmd; let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-executables-have-shebangs "}); let cwd = context.work_dir(); cwd.child("partial_shebang.sh") .write_str("#\necho partial\n")?; cwd.child("shebang_with_space.sh") .write_str("#! /bin/bash\necho ok\n")?; cwd.child("non_executable.txt") .write_str("not executable\n")?; cwd.child("whitespace.sh").write_str(" \n")?; cwd.child("invalid_shebang.sh") .write_str("##!/bin/bash\necho bad\n")?; context.git_add("."); let executable_files = [ "partial_shebang.sh", "shebang_with_space.sh", "whitespace.sh", "invalid_shebang.sh", ]; for file in &executable_files { git_cmd(cwd.path()) .args(["update-index", "--chmod=+x", file]) .status()?; } // Run: should fail for partial_shebang.sh, whitespace.sh, invalid_shebang.sh cmd_snapshot!(context.filters(), context.run(), @r#" success: false exit_code: 1 ----- stdout ----- check that executables have shebangs.....................................Failed - hook id: check-executables-have-shebangs - exit code: 1 invalid_shebang.sh marked executable but has no (or invalid) shebang! If it isn't supposed to be executable, try: 'chmod -x invalid_shebang.sh' If on Windows, you may also need to: 'git add --chmod=-x invalid_shebang.sh' If it is supposed to be executable, double-check its shebang. partial_shebang.sh marked executable but has no (or invalid) shebang! If it isn't supposed to be executable, try: 'chmod -x partial_shebang.sh' If on Windows, you may also need to: 'git add --chmod=-x partial_shebang.sh' If it is supposed to be executable, double-check its shebang. whitespace.sh marked executable but has no (or invalid) shebang! If it isn't supposed to be executable, try: 'chmod -x whitespace.sh' If on Windows, you may also need to: 'git add --chmod=-x whitespace.sh' If it is supposed to be executable, double-check its shebang. ----- stderr ----- "#); Ok(()) } fn is_case_sensitive_filesystem(context: &TestContext) -> Result { let test_lower = context.work_dir().child("case_test_file.txt"); test_lower.write_str("test")?; let test_upper = context.work_dir().child("CASE_TEST_FILE.txt"); let is_sensitive = !test_upper.exists(); fs_err::remove_file(test_lower.path())?; Ok(is_sensitive) } #[test] fn check_case_conflict_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); if !is_case_sensitive_filesystem(&context)? { // Skipping test on case-insensitive filesystem return Ok(()); } // Create initial files and commit let cwd = context.work_dir(); cwd.child("README.md").write_str("Initial commit")?; cwd.child("src/foo.txt").write_str("existing file")?; context.git_add("."); context.git_commit("Initial commit"); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-case-conflict "}); // Try to add a file with conflicting case cwd.child("src/FOO.txt").write_str("conflicting case")?; context.git_add("."); // First run: should fail due to case conflict cmd_snapshot!(context.filters(), context.run(), @r#" success: false exit_code: 1 ----- stdout ----- check for case conflicts.................................................Failed - hook id: check-case-conflict - exit code: 1 Case-insensitivity conflict found: src/FOO.txt Case-insensitivity conflict found: src/foo.txt ----- stderr ----- "#); // Remove the conflicting file context.git_rm("src/FOO.txt"); // Add a non-conflicting file cwd.child("src/bar.txt").write_str("no conflict")?; context.git_add("."); // Second run: should pass cmd_snapshot!(context.filters(), context.run(), @r#" success: true exit_code: 0 ----- stdout ----- check for case conflicts.................................................Passed ----- stderr ----- "#); Ok(()) } #[test] fn check_case_conflict_directory() -> Result<()> { let context = TestContext::new(); context.init_project(); if !is_case_sensitive_filesystem(&context)? { // Skipping test on case-insensitive filesystem return Ok(()); } // Create directory with file let cwd = context.work_dir(); cwd.child("src/utils/helper.py").write_str("helper")?; context.git_add("."); context.git_commit("Initial commit"); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-case-conflict "}); // Try to add a file that conflicts with directory name cwd.child("src/UTILS/other.py").write_str("conflict")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: false exit_code: 1 ----- stdout ----- check for case conflicts.................................................Failed - hook id: check-case-conflict - exit code: 1 Case-insensitivity conflict found: src/UTILS Case-insensitivity conflict found: src/utils ----- stderr ----- "#); Ok(()) } #[test] fn check_case_conflict_among_new_files() -> Result<()> { let context = TestContext::new(); context.init_project(); if !is_case_sensitive_filesystem(&context)? { // Skipping test on case-insensitive filesystem return Ok(()); } let cwd = context.work_dir(); cwd.child("README.md").write_str("Initial")?; context.git_add("."); context.git_commit("Initial commit"); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-case-conflict "}); // Add multiple new files with conflicting cases cwd.child("NewFile.txt").write_str("file 1")?; cwd.child("newfile.txt").write_str("file 2")?; cwd.child("NEWFILE.TXT").write_str("file 3")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: false exit_code: 1 ----- stdout ----- check for case conflicts.................................................Failed - hook id: check-case-conflict - exit code: 1 Case-insensitivity conflict found: NEWFILE.TXT Case-insensitivity conflict found: NewFile.txt Case-insensitivity conflict found: newfile.txt ----- stderr ----- "#); Ok(()) } #[test] fn check_json5() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: check-json5 "}); let cwd = context.work_dir(); // Create test files cwd.child("valid.json5").write_str(indoc::indoc! {r" // This is a comment { unquotedKey: 'value', // Trailing comma anotherKey: 12345, } "})?; cwd.child("invalid_missing_comma.json5") .write_str(indoc::indoc! {r" { key1: 'value1' key2: 'value2', // Missing comma between key-value pairs } "})?; context.git_add("."); // First run: hooks should fail cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check json5..............................................................Failed - hook id: check-json5 - exit code: 1 invalid_missing_comma.json5: Failed to json5 decode (expected comma at line 3 column 5) ----- stderr ----- "); // Fix the files cwd.child("invalid_missing_comma.json5") .write_str(indoc::indoc! {r" { key1: 'value1', key2: 'value2', } "})?; context.git_add("."); // Second run: hooks should now pass cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check json5..............................................................Passed ----- stderr ----- "); Ok(()) } /// Test that builtin hooks work correctly even when a system-wide binary with the /// same name exists on PATH (regression test for ). /// /// When pre-commit-hooks is installed system-wide via pip, binaries like /// `trailing-whitespace-fixer` are placed in PATH. These binaries have shebangs /// (e.g., `#!/usr/bin/python3`). Before the fix, `resolve(None)` would find these /// binaries, parse their shebangs, and corrupt argument parsing. #[test] #[cfg(unix)] fn builtin_hooks_ignore_system_path_binaries() -> Result<()> { let context = TestContext::new(); context.init_project(); // Create a fake "trailing-whitespace-fixer" binary with a shebang in a temp dir. // This simulates `pip install pre-commit-hooks` which places such binaries in PATH. let fake_bin_dir = context.home_dir().child("fake_bin"); fake_bin_dir.create_dir_all()?; let fake_binary = fake_bin_dir.child("trailing-whitespace-fixer"); fake_binary.write_str("#!/usr/bin/python3\n# fake binary\n")?; std::fs::set_permissions(fake_binary.path(), std::fs::Permissions::from_mode(0o755))?; context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: builtin hooks: - id: trailing-whitespace "}); let cwd = context.work_dir(); cwd.child("test.txt").write_str("hello world \n")?; context.git_add("."); // Prepend the fake bin directory to PATH so the fake binary is found first. let original_path = EnvVars::var_os(EnvVars::PATH).unwrap_or_default(); let mut new_path = std::ffi::OsString::from(fake_bin_dir.path()); new_path.push(":"); new_path.push(&original_path); // Run prek with the modified PATH. // Before the fix: this would fail with a clap argument parsing error like: // "unexpected argument '/path/to/trailing-whitespace-fixer' found" // After the fix: this should pass because builtin hooks use split() not resolve(None). cmd_snapshot!(context.filters(), context.run().env("PATH", new_path), @r" success: false exit_code: 1 ----- stdout ----- trim trailing whitespace.................................................Failed - hook id: trailing-whitespace - exit code: 1 - files were modified by this hook Fixing test.txt ----- stderr ----- "); // Verify the file was fixed (trailing whitespace removed). assert_eq!(context.read("test.txt"), "hello world\n"); Ok(()) } ================================================ FILE: crates/prek/tests/cache.rs ================================================ use assert_fs::assert::PathAssert; use assert_fs::fixture::{ChildPath, PathChild, PathCreateDir}; use assert_fs::prelude::FileWriteStr; use prek_consts::PRE_COMMIT_CONFIG_YAML; use serde_json::json; use crate::common::{TestContext, cmd_snapshot}; mod common; #[test] fn cache_dir() { let context = TestContext::new(); let home = context.work_dir().child("home"); cmd_snapshot!(context.filters(), context.command().arg("cache").arg("dir").env("PREK_HOME", &*home), @r" success: true exit_code: 0 ----- stdout ----- [TEMP_DIR]/home ----- stderr ----- "); } #[test] fn cache_gc_verbose_shows_removed_entries() { let context = TestContext::new(); context.write_pre_commit_config("repos: []\n"); let home = context.home_dir(); // Seed store entries that will be removed. home.child("repos/deadbeef") .create_dir_all() .expect("create repo dir"); home.child("repos/deadbeef/.prek-repo.json") .write_str( &serde_json::to_string_pretty(&json!({ "repo": "https://github.com/pre-commit/pre-commit-hooks", "rev": "v1.0.0", })) .expect("serialize repo marker"), ) .expect("write repo marker"); home.child("hooks/hook-env-dead") .create_dir_all() .expect("create hook env dir"); home.child("hooks/hook-env-dead/.prek-hook.json") .write_str( &serde_json::to_string_pretty(&json!({ "language": "python", "language_version": "3.12.0", "dependencies": [ "https://example.com/repo@v1.0.0", "dep1", "dep2", "dep3", "dep4", "dep5", "dep6", "dep7", ], "env_path": home.child("hooks/hook-env-dead").path(), "toolchain": "/usr/bin/python3", "extra": {}, })) .expect("serialize hook marker"), ) .expect("write hook marker"); home.child("cache/go") .create_dir_all() .expect("create cache dir"); // Have a tracked config that exists but references nothing (so everything above is unreferenced). let config_path = context.work_dir().child(PRE_COMMIT_CONFIG_YAML); write_config_tracking_file(home, &[config_path.path()]).expect("write tracking file"); cmd_snapshot!(context.filters(), context.command().args(["cache", "gc", "-v"]),@r" success: true exit_code: 0 ----- stdout ----- Removed 1 repo, 1 hook env, 1 cache entry ([SIZE]) Removed 1 repo: - https://github.com/pre-commit/pre-commit-hooks@v1.0.0 path: [HOME]/repos/deadbeef Removed 1 hook env: - python env path: [HOME]/hooks/hook-env-dead language: python (3.12.0) repo: https://example.com/repo@v1.0.0 deps: dep1, dep2, dep3, dep4, dep5, dep6, … (+1 more) Removed 1 cache entry: - go path: [HOME]/cache/go ----- stderr ----- "); } #[test] fn cache_clean() -> anyhow::Result<()> { let context = TestContext::new().with_filtered_cache_clean_summary(); let home = context.work_dir().child("home"); home.create_dir_all()?; home.child("cache/nested").create_dir_all()?; home.child("cache/data.bin").write_str("hello")?; home.child("cache/nested/data.bin").write_str("world!")?; cmd_snapshot!(context.filters(), context.command().arg("cache").arg("clean").env("PREK_HOME", &*home), @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Removed [N] file(s) ([SIZE]) "); home.assert(predicates::path::missing()); // Test `prek clean` works for backward compatibility home.create_dir_all()?; home.child("cache").create_dir_all()?; home.child("cache/one.txt").write_str("abc")?; cmd_snapshot!(context.filters(), context.command().arg("clean").env("PREK_HOME", &*home), @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Removed [N] file(s) ([SIZE]) "); home.assert(predicates::path::missing()); Ok(()) } #[test] fn cache_size() -> anyhow::Result<()> { let context = TestContext::new().with_filtered_cache_size(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: end-of-file-fixer "}); cwd.child("file.txt").write_str("Hello, world!\n")?; context.git_add("."); context.run(); cmd_snapshot!(context.filters(), context.command().arg("cache").arg("size"), @r" success: true exit_code: 0 ----- stdout ----- [SIZE] ----- stderr ----- "); cmd_snapshot!(context.filters(), context.command().arg("cache").arg("size").arg("-H"), @r" success: true exit_code: 0 ----- stdout ----- [SIZE] ----- stderr ----- "); Ok(()) } #[test] fn cache_gc_removes_unreferenced_entries() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-yaml - repo: local hooks: - id: python-hook name: Python Hook entry: python -c "print('Hello from Python')" language: python "#}); cwd.child("valid.yaml").write_str("a: 1\n")?; context.git_add("."); let home = context.home_dir(); // Populate store + config tracking. cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check yaml...............................................................Passed Python Hook..............................................................Passed ----- stderr ----- "); // Add a few obviously-unused entries. home.child("repos/unused-repo").create_dir_all()?; home.child("hooks/unused-hook-env").create_dir_all()?; home.child("tools/node").create_dir_all()?; home.child("cache/go").create_dir_all()?; // Reduce hooks context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-yaml "}); cmd_snapshot!(context.filters(), context.command().arg("cache").arg("gc"), @r" success: true exit_code: 0 ----- stdout ----- Removed 1 repo, 2 hook envs, 1 tool, 1 cache entry ([SIZE]) ----- stderr ----- "); home.child("repos/unused-repo") .assert(predicates::path::missing()); home.child("hooks/unused-hook-env") .assert(predicates::path::missing()); home.child("tools/node").assert(predicates::path::missing()); home.child("cache/go").assert(predicates::path::missing()); Ok(()) } #[test] fn cache_gc_prunes_unused_tool_versions() -> anyhow::Result<()> { let context = TestContext::new(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: local-python name: Local Python Hook entry: "python -c \"print(1)\"" language: python - id: local-pygrep name: Local Pygrep Hook entry: "python -c \"print(1)\"" language: pygrep - id: local-node name: Local Node Hook entry: "node -e \"console.log(1)\"" language: node - id: local-go name: Local Go Hook entry: "go version" language: golang - id: local-ruby name: Local Ruby Hook entry: "ruby -e 'puts 1'" language: ruby - id: local-rust name: Local Rust Hook entry: "rustc --version" language: rust "#}); let home = context.home_dir(); // Track the config so GC has something to mark from. let config_path = context.work_dir().child(PRE_COMMIT_CONFIG_YAML); write_config_tracking_file(home, &[config_path.path()])?; // Seed "used" hook env markers so GC can read `.prek-hook.json` and retain the // corresponding tool versions per language. let env_py = home.child("hooks/python-keep"); let env_node = home.child("hooks/node-keep"); let env_go = home.child("hooks/go-keep"); let env_ruby = home.child("hooks/ruby-remove"); let env_rust = home.child("hooks/rust-remove"); env_py.create_dir_all()?; env_node.create_dir_all()?; env_go.create_dir_all()?; env_ruby.create_dir_all()?; env_rust.create_dir_all()?; let py_keep = home.child("tools/python/3.12.0"); let py_remove = home.child("tools/python/3.11.0"); py_keep.create_dir_all()?; py_remove.create_dir_all()?; let node_keep = home.child("tools/node/22.0.0"); let node_remove = home.child("tools/node/21.0.0"); node_keep.create_dir_all()?; node_remove.create_dir_all()?; let go_keep = home.child("tools/go/1.24.0"); let go_remove = home.child("tools/go/1.23.0"); go_keep.create_dir_all()?; go_remove.create_dir_all()?; // Match logic for local hooks: empty deps + language request is `Any` by default. let marker_py = json!({ "language": "python", "language_version": "3.12.0", "dependencies": [], "env_path": env_py.path(), "toolchain": py_keep.child("bin/python").path(), "extra": {}, }); env_py .child(".prek-hook.json") .write_str(&serde_json::to_string_pretty(&marker_py)?)?; let marker_node = json!({ "language": "node", "language_version": "22.0.0", "dependencies": [], "env_path": env_node.path(), "toolchain": node_keep.child("bin/node").path(), "extra": {}, }); env_node .child(".prek-hook.json") .write_str(&serde_json::to_string_pretty(&marker_node)?)?; let marker_go = json!({ "language": "golang", "language_version": "1.24.0", "dependencies": [], "env_path": env_go.path(), "toolchain": go_keep.child("bin/go").path(), "extra": {}, }); env_go .child(".prek-hook.json") .write_str(&serde_json::to_string_pretty(&marker_go)?)?; cmd_snapshot!(context.filters(), context.command().args(["cache", "gc", "--dry-run", "-v"]), @r" success: true exit_code: 0 ----- stdout ----- Would remove 2 hook envs, 3 tools ([SIZE]) Would remove 2 hook envs: - ruby-remove path: [HOME]/hooks/ruby-remove - rust-remove path: [HOME]/hooks/rust-remove Would remove 3 tools: - go/1.23.0 path: [HOME]/tools/go/1.23.0 - node/21.0.0 path: [HOME]/tools/node/21.0.0 - python/3.11.0 path: [HOME]/tools/python/3.11.0 ----- stderr ----- "); cmd_snapshot!(context.filters(), context.command().args(["cache", "gc", "-v"]), @r" success: true exit_code: 0 ----- stdout ----- Removed 2 hook envs, 3 tools ([SIZE]) Removed 2 hook envs: - ruby-remove path: [HOME]/hooks/ruby-remove - rust-remove path: [HOME]/hooks/rust-remove Removed 3 tools: - go/1.23.0 path: [HOME]/tools/go/1.23.0 - node/21.0.0 path: [HOME]/tools/node/21.0.0 - python/3.11.0 path: [HOME]/tools/python/3.11.0 ----- stderr ----- "); Ok(()) } #[test] fn cache_gc_prunes_tool_versions_without_positive_identification() -> anyhow::Result<()> { let context = TestContext::new(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: local-python name: Local Python Hook entry: "python -c \"print(1)\"" language: python "#}); let home = context.home_dir(); // Track the config so GC has something to mark from. let config_path = context.work_dir().child(PRE_COMMIT_CONFIG_YAML); write_config_tracking_file(home, &[config_path.path()])?; // Seed a matching installed hook env marker, but use a toolchain path that is *not* inside // PREK_HOME/tools. This means we cannot positively identify a used tool version, so all // tool versions under the bucket are unused and should be pruned. let env_py = home.child("hooks/python-keep"); env_py.create_dir_all()?; let marker_py = json!({ "language": "python", "language_version": "3.12.0", "dependencies": [], "env_path": env_py.path(), "toolchain": "/usr/bin/python3", "extra": {}, }); env_py .child(".prek-hook.json") .write_str(&serde_json::to_string_pretty(&marker_py)?)?; // Seed tool versions that should be removed. let py_312 = home.child("tools/python/3.12.0"); let py_311 = home.child("tools/python/3.11.0"); py_312.create_dir_all()?; py_311.create_dir_all()?; // Add a temp dir to ensure it is not removed. home.child("repos/.temp").create_dir_all()?; home.child("tools/.temp").create_dir_all()?; cmd_snapshot!( context.filters(), context.command().args(["cache", "gc", "--dry-run", "-v"]), @r" success: true exit_code: 0 ----- stdout ----- Would remove 2 tools ([SIZE]) Would remove 2 tools: - python/3.11.0 path: [HOME]/tools/python/3.11.0 - python/3.12.0 path: [HOME]/tools/python/3.12.0 ----- stderr ----- " ); cmd_snapshot!(context.filters(), context.command().args(["cache", "gc"]), @r" success: true exit_code: 0 ----- stdout ----- Removed 2 tools ([SIZE]) ----- stderr ----- "); py_312.assert(predicates::path::missing()); py_311.assert(predicates::path::missing()); home.child("tools/python") .assert(predicates::path::is_dir()); Ok(()) } #[test] fn cache_gc_keeps_local_hook_env() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: local-python name: Local Python Hook entry: python -c "print('hello')" language: python "#}); cwd.child("file.txt").write_str("Hello\n")?; context.git_add("."); // Install + run the local hook so it creates a hook env under PREK_HOME/hooks. cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- Local Python Hook........................................................Passed ----- stderr ----- "); let home = context.home_dir(); let hooks_dir = home.child("hooks"); let mut local_envs = Vec::new(); for entry in fs_err::read_dir(hooks_dir.path())? { let entry = entry?; if !entry.file_type()?.is_dir() { continue; } let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with("python-") { local_envs.push(name); } } assert!( !local_envs.is_empty(), "expected at least one local hook env" ); // Add an obviously-unused entry to ensure GC does work. home.child("hooks/unused-hook-env").create_dir_all()?; cmd_snapshot!(context.filters(), context.command().args(["cache", "gc"]), @r" success: true exit_code: 0 ----- stdout ----- Removed 1 hook env ([SIZE]) ----- stderr ----- "); // The local hook env(s) should remain. for env in local_envs { home.child(format!("hooks/{env}")) .assert(predicates::path::is_dir()); } // Unused should be swept. home.child("hooks/unused-hook-env") .assert(predicates::path::missing()); Ok(()) } fn write_config_tracking_file( home: &ChildPath, configs: &[&std::path::Path], ) -> anyhow::Result<()> { let configs: Vec = configs .iter() .map(|p| p.to_string_lossy().to_string()) .collect(); let content = serde_json::to_string_pretty(&configs)?; home.child("config-tracking.json").write_str(&content)?; Ok(()) } fn write_workspace_cache_file( home: &ChildPath, workspace_root: &std::path::Path, ) -> anyhow::Result<()> { use std::hash::{Hash as _, Hasher as _}; use std::time::SystemTime; let config_path = workspace_root.join(PRE_COMMIT_CONFIG_YAML); let metadata = fs_err::metadata(&config_path)?; let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH); let size = metadata.len(); let mut hasher = std::collections::hash_map::DefaultHasher::new(); workspace_root.hash(&mut hasher); let digest = hex::encode(hasher.finish().to_le_bytes()); let cache_path = home.child("cache/prek/workspace").child(digest); let parent = cache_path.parent().expect("cache path has parent"); fs_err::create_dir_all(parent)?; let content = json!({ "version": 1u32, "workspace_root": workspace_root, "created_at": serde_json::to_value(SystemTime::now())?, "config_files": [ { "path": config_path, "modified": serde_json::to_value(modified)?, "size": size, } ], }); cache_path.write_str(&serde_json::to_string_pretty(&content)?)?; Ok(()) } #[test] fn cache_gc_bootstraps_tracking_from_workspace_cache() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config("repos: []\n"); context.git_add("."); let home = context.home_dir(); write_workspace_cache_file(home, context.work_dir().path())?; // Seed store entries that should be swept, even if `config-tracking.json` is missing. home.child("repos/deadbeef").create_dir_all()?; home.child("hooks/hook-env-dead").create_dir_all()?; cmd_snapshot!(context.filters(), context.command().arg("cache").arg("gc"), @r" success: true exit_code: 0 ----- stdout ----- Removed 1 repo, 1 hook env ([SIZE]) ----- stderr ----- "); home.child("repos/deadbeef") .assert(predicates::path::missing()); home.child("hooks/hook-env-dead") .assert(predicates::path::missing()); Ok(()) } #[test] fn cache_gc_drops_missing_tracked_config() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config("repos: []\n"); context.git_add("."); let home = context.home_dir(); let config_path = cwd.child(PRE_COMMIT_CONFIG_YAML); write_config_tracking_file(home, &[config_path.path()])?; // Simulate config being deleted between runs. fs_err::remove_file(config_path.path())?; // Add a few obviously-unused entries to ensure GC sweeps. home.child("repos/unused-repo").create_dir_all()?; home.child("hooks/unused-hook-env").create_dir_all()?; home.child("tools/node").create_dir_all()?; home.child("cache/go").create_dir_all()?; home.child("scratch/some-temp").create_dir_all()?; home.child("patches/some-patch").create_dir_all()?; cmd_snapshot!(context.filters(), context.command().arg("cache").arg("gc"), @r" success: true exit_code: 0 ----- stdout ----- Removed 1 repo, 1 hook env, 1 tool, 1 cache entry ([SIZE]) ----- stderr ----- "); // Tracking file should be updated to drop the missing config. let content = fs_err::read_to_string(home.child("config-tracking.json").path())?; let tracked: Vec = serde_json::from_str(&content)?; assert!(tracked.is_empty()); // Scratch and patches are always cleared when GC runs. home.child("scratch").assert(predicates::path::missing()); home.child("patches").assert(predicates::path::is_dir()); Ok(()) } #[test] fn cache_gc_keeps_tracked_config_on_parse_error() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); // Intentionally invalid YAML. cwd.child(PRE_COMMIT_CONFIG_YAML).write_str("repos: [\n")?; context.git_add("."); let home = context.home_dir(); let config_path = cwd.child(PRE_COMMIT_CONFIG_YAML); write_config_tracking_file(home, &[config_path.path()])?; // Add a few obviously-unused entries to ensure GC sweeps even when config is unparsable. home.child("repos/unused-repo").create_dir_all()?; home.child("hooks/unused-hook-env").create_dir_all()?; home.child("tools/node").create_dir_all()?; home.child("cache/go").create_dir_all()?; cmd_snapshot!(context.filters(), context.command().arg("cache").arg("gc"), @r" success: true exit_code: 0 ----- stdout ----- Removed 1 repo, 1 hook env, 1 tool, 1 cache entry ([SIZE]) ----- stderr ----- "); // Parse errors should not drop the config from tracking. let content = fs_err::read_to_string(home.child("config-tracking.json").path())?; let tracked: Vec = serde_json::from_str(&content)?; assert_eq!(tracked.len(), 1); Ok(()) } #[test] fn cache_gc_dry_run_does_not_remove_entries() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config("repos: []\n"); context.git_add("."); let home = context.home_dir(); // Seed tracking with a missing config to force sweeping everything. let missing_config_path = cwd.child("missing-config.yaml"); write_config_tracking_file(home, &[missing_config_path.path()])?; home.child("repos/unused-repo").create_dir_all()?; home.child("hooks/unused-hook-env").create_dir_all()?; home.child("tools/node").create_dir_all()?; home.child("cache/go").create_dir_all()?; home.child("scratch/some-temp").create_dir_all()?; cmd_snapshot!(context.filters(), context.command().arg("cache").arg("gc").arg("--dry-run"), @r" success: true exit_code: 0 ----- stdout ----- Would remove 1 repo, 1 hook env, 1 tool, 1 cache entry ([SIZE]) ----- stderr ----- "); // Nothing should be removed in dry-run mode. home.child("repos/unused-repo") .assert(predicates::path::is_dir()); home.child("hooks/unused-hook-env") .assert(predicates::path::is_dir()); home.child("tools/node").assert(predicates::path::is_dir()); home.child("cache/go").assert(predicates::path::is_dir()); home.child("scratch/some-temp") .assert(predicates::path::is_dir()); Ok(()) } ================================================ FILE: crates/prek/tests/common/mod.rs ================================================ #![allow(dead_code, unreachable_pub)] use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::Command; use assert_cmd::assert::OutputAssertExt; use assert_fs::fixture::{ChildPath, FileWriteStr, PathChild, PathCreateDir}; use etcetera::BaseStrategy; use rustc_hash::FxHashSet; use prek_consts::PRE_COMMIT_CONFIG_YAML; use prek_consts::env_vars::EnvVars; pub fn git_cmd(dir: impl AsRef) -> Command { let mut cmd = Command::new("git"); cmd.current_dir(dir) .args(["-c", "commit.gpgsign=false"]) .args(["-c", "tag.gpgsign=false"]) .args(["-c", "core.autocrlf=false"]) .args(["-c", "user.name=Prek Test"]) .args(["-c", "user.email=test@prek.dev"]); cmd } pub struct TestContext { temp_dir: ChildPath, home_dir: ChildPath, /// Standard filters for this test context. filters: Vec<(String, String)>, // To keep the directory alive. #[allow(dead_code)] _root: tempfile::TempDir, } impl TestContext { pub fn new() -> Self { let bucket = Self::test_bucket_dir(); fs_err::create_dir_all(&bucket).expect("Failed to create test bucket"); let root = tempfile::TempDir::new_in(bucket).expect("Failed to create test root directory"); let temp_dir = ChildPath::new(root.path()).child("temp"); fs_err::create_dir_all(&temp_dir).expect("Failed to create test working directory"); Self::from_root(root, temp_dir) } pub fn new_at(path: PathBuf) -> Self { let bucket = Self::test_bucket_dir(); fs_err::create_dir_all(&bucket).expect("Failed to create test bucket"); let root = tempfile::TempDir::new_in(bucket).expect("Failed to create test root directory"); let temp_dir = ChildPath::new(path); fs_err::create_dir_all(&temp_dir).expect("Failed to create test working directory"); Self::from_root(root, temp_dir) } fn from_root(root: tempfile::TempDir, temp_dir: ChildPath) -> Self { let home_dir = ChildPath::new(root.path()).child("home"); fs_err::create_dir_all(&home_dir).expect("Failed to create test home directory"); let mut filters = Vec::new(); filters.extend( Self::path_patterns(&temp_dir) .into_iter() .map(|pattern| (pattern, "[TEMP_DIR]/".to_string())), ); filters.extend( Self::path_patterns(&home_dir) .into_iter() .map(|pattern| (pattern, "[HOME]/".to_string())), ); if let Some(current_exe) = EnvVars::var_os("NEXTEST_BIN_EXE_prek") { filters.extend( Self::path_patterns(current_exe) .into_iter() .map(|pattern| (pattern, "[CURRENT_EXE]".to_string())), ); } let current_exe = assert_cmd::cargo::cargo_bin!("prek"); filters.extend( Self::path_patterns(current_exe) .into_iter() .map(|pattern| (pattern, "[CURRENT_EXE]".to_string())), ); Self { temp_dir, home_dir, filters, _root: root, } } pub fn test_bucket_dir() -> PathBuf { EnvVars::var(EnvVars::PREK_INTERNAL__TEST_DIR) .map(PathBuf::from) .unwrap_or_else(|_| { etcetera::base_strategy::choose_base_strategy() .expect("Failed to find base strategy") .data_dir() .join("prek") .join("tests") }) } /// Generate an escaped regex pattern for the given path. fn path_pattern(path: impl AsRef) -> String { format!( // Trim the trailing separator for cross-platform directories filters r"{}\\?/?", regex::escape(&path.as_ref().display().to_string()) // Make separators platform-agnostic because on Windows we will display // paths with Unix-style separators sometimes .replace('/', r"(\\|\/)") .replace(r"\\", r"(\\|\/)") ) } /// Generate various escaped regex patterns for the given path. pub fn path_patterns(path: impl AsRef) -> Vec { let mut patterns = Vec::new(); // We can only canonicalize paths that exist already if path.as_ref().exists() { patterns.push(Self::path_pattern( path.as_ref() .canonicalize() .expect("Failed to create canonical path"), )); } // Include a non-canonicalized version patterns.push(Self::path_pattern(path)); patterns } /// Read a file in the temporary directory pub fn read(&self, file: impl AsRef) -> String { fs_err::read_to_string(self.temp_dir.join(&file)) .unwrap_or_else(|_| panic!("Missing file: `{}`", file.as_ref().display())) } pub fn command(&self) -> Command { let mut cmd = if EnvVars::is_set(EnvVars::PREK_INTERNAL__RUN_ORIGINAL_PRE_COMMIT) { // Run the original pre-commit to check compatibility. let mut cmd = Command::new("pre-commit"); cmd.current_dir(self.work_dir()); cmd.env(EnvVars::PRE_COMMIT_HOME, &**self.home_dir()); cmd } else { // The absolute path to a binary target's executable. This is only set when running an integration test or benchmark. // When reusing builds from an archive, this is set to the remapped path within the target directory. let bin = EnvVars::var_os("NEXTEST_BIN_EXE_prek") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(assert_cmd::cargo::cargo_bin!("prek"))); let mut cmd = Command::new(bin); cmd.current_dir(self.work_dir()); cmd.env(EnvVars::PREK_HOME, &**self.home_dir()); cmd.env(EnvVars::PREK_INTERNAL__SORT_FILENAMES, "1"); cmd }; // Disable git autocrlf to avoid line ending issues in tests. cmd.env("GIT_CONFIG_COUNT", "1") .env("GIT_CONFIG_KEY_0", "core.autocrlf") .env("GIT_CONFIG_VALUE_0", "false"); cmd } pub fn run(&self) -> Command { let mut command = self.command(); command.arg("run"); command } pub fn validate_config(&self) -> Command { let mut command = self.command(); command.arg("validate-config"); command } pub fn validate_manifest(&self) -> Command { let mut command = self.command(); command.arg("validate-manifest"); command } pub fn install(&self) -> Command { let mut command = self.command(); command.arg("install"); command } pub fn prepare_hooks(&self) -> Command { let mut command = self.command(); command.arg("prepare-hooks"); command } pub fn uninstall(&self) -> Command { let mut command = self.command(); command.arg("uninstall"); command } pub fn sample_config(&self) -> Command { let mut command = self.command(); command.arg("sample-config"); command } pub fn list(&self) -> Command { let mut command = self.command(); command.arg("list"); command } pub fn auto_update(&self) -> Command { let mut cmd = self.command(); cmd.arg("auto-update"); cmd } pub fn try_repo(&self) -> Command { let mut cmd = self.command(); cmd.arg("try-repo"); cmd } /// Standard snapshot filters _plus_ those for this test context. pub fn filters(&self) -> Vec<(&str, &str)> { // Put test context snapshots before the default filters // This ensures we don't replace other patterns inside paths from the test context first self.filters .iter() .map(|(p, r)| (p.as_str(), r.as_str())) .chain(INSTA_FILTERS.iter().copied()) .collect() } /// Get the working directory for the test context. pub fn work_dir(&self) -> &ChildPath { &self.temp_dir } /// Get the home directory for the test context. pub fn home_dir(&self) -> &ChildPath { &self.home_dir } /// Initialize a sample project for prek. pub fn init_project(&self) { git_cmd(&self.temp_dir) .arg("-c") .arg("init.defaultBranch=master") .arg("init") .assert() .success(); } /// Run `git add`. pub fn git_add(&self, path: impl AsRef) { git_cmd(&self.temp_dir) .arg("add") .arg(path) .assert() .success(); } /// Run `git commit`. pub fn git_commit(&self, message: &str) { git_cmd(&self.temp_dir) .arg("commit") .arg("-m") .arg(message) .env(EnvVars::PREK_HOME, &**self.home_dir()) .assert() .success(); } /// Run `git tag`. pub fn git_tag(&self, tag: &str) { git_cmd(&self.temp_dir) .arg("tag") .arg(tag) .arg("-m") .arg(format!("Tag {tag}")) .assert() .success(); } /// Run `git reset`. pub fn git_reset(&self, target: &str) { git_cmd(&self.temp_dir) .arg("reset") .arg(target) .assert() .success(); } /// Run `git rm`. pub fn git_rm(&self, path: &str) { git_cmd(&self.temp_dir) .arg("rm") .arg("--cached") .arg(path) .assert() .success(); let file_path = self.temp_dir.child(path); if file_path.exists() { fs_err::remove_file(file_path).unwrap(); } } /// Run `git clean`. pub fn git_clean(&self) { git_cmd(&self.temp_dir) .arg("clean") .arg("-fdx") .assert() .success(); } /// Create a new git branch. pub fn git_branch(&self, branch_name: &str) { git_cmd(&self.temp_dir) .arg("branch") .arg(branch_name) .assert() .success(); } /// Switch to a git branch. pub fn git_checkout(&self, branch_name: &str) { git_cmd(&self.temp_dir) .arg("checkout") .arg(branch_name) .assert() .success(); } /// Write a `.pre-commit-config.yaml` file in the temporary directory. pub fn write_pre_commit_config(&self, content: &str) { self.temp_dir .child(PRE_COMMIT_CONFIG_YAML) .write_str(content) .expect("Failed to write pre-commit config"); } /// Setup a workspace with multiple projects, each with the same config. /// This creates a tree-like directory structure for testing workspace functionality. pub fn setup_workspace(&self, project_paths: &[&str], config: &str) -> anyhow::Result<()> { // Always create root config self.temp_dir .child(PRE_COMMIT_CONFIG_YAML) .write_str(config)?; // Create each project directory and config for path in project_paths { let project_dir = self.temp_dir.child(path); project_dir.create_dir_all()?; project_dir .child(PRE_COMMIT_CONFIG_YAML) .write_str(config)?; } Ok(()) } /// Add extra filtering for cache size output #[must_use] pub fn with_filtered_cache_size(mut self) -> Self { // Filter raw byte counts (numbers on their own line) self.filters .push((r"(?m)^\d+\n".to_string(), "[SIZE]\n".to_string())); // Filter human-readable sizes (e.g., "384.2 KiB") self.filters.push(( r"(?m)^\d+(\.\d+)? ([KMGTPE]i)?B\n".to_string(), "[SIZE]\n".to_string(), )); self } /// Add extra filtering for `cache clean` summary output. #[must_use] pub fn with_filtered_cache_clean_summary(mut self) -> Self { self.filters.push(( r"(?m)^Removed \d+ files? \([^)]+\)\n".to_string(), "Removed [N] file(s) ([SIZE])\n".to_string(), )); self } } #[doc(hidden)] // Macro and test context only, don't use directly. pub const INSTA_FILTERS: &[(&str, &str)] = &[ // File sizes (r"(\s|\()(\d+\.)?\d+\s?([KMGTPE]i)?B", "$1[SIZE]"), // Rewrite Windows output to Unix output (r"\\([\w\d]|\.\.|\.)", "/$1"), // The exact message is host language dependent ( r"Caused by: .* \(os error 2\)", "Caused by: No such file or directory (os error 2)", ), // Time seconds (r"\b(\d+\.)?\d+(ms|s)\b", "[TIME]"), // Strip non-deterministic lock contention warnings from parallel test execution (r"(?m)^warning: Waiting to acquire lock.*\n", ""), ]; #[allow(unused_macros)] macro_rules! cmd_snapshot { ($spawnable:expr, @$snapshot:literal) => {{ cmd_snapshot!($crate::common::INSTA_FILTERS.iter().copied().collect::>(), $spawnable, @$snapshot) }}; ($filters:expr, $spawnable:expr, @$snapshot:literal) => {{ let mut settings = insta::Settings::clone_current(); for (matcher, replacement) in $filters { settings.add_filter(matcher, replacement); } let _guard = settings.bind_to_scope(); insta_cmd::assert_cmd_snapshot!($spawnable, @$snapshot); }}; } #[allow(unused_imports)] pub(crate) use cmd_snapshot; pub(crate) fn remove_bin_from_path(bin: &str, path: Option) -> anyhow::Result { let path = path.unwrap_or(EnvVars::var_os(EnvVars::PATH).expect("Path must be set")); let Ok(dirs) = which::which_all(bin) else { return Ok(path); }; let dirs: FxHashSet<_> = dirs .filter_map(|path| path.parent().map(Path::to_path_buf)) .collect(); let new_path_entries: Vec<_> = std::env::split_paths(&path) .filter(|path| !dirs.contains(path.as_path())) .collect(); Ok(std::env::join_paths(new_path_entries)?) } ================================================ FILE: crates/prek/tests/fixtures/go.yaml ================================================ repos: - repo: local hooks: - id: golang name: golang language: golang entry: gofumpt -h additional_dependencies: ["mvdan.cc/gofumpt@v0.8.0"] always_run: true verbose: true pass_filenames: false - id: golang name: golang language: golang entry: go version language_version: '1.23.5' always_run: true pass_filenames: false ================================================ FILE: crates/prek/tests/fixtures/issue227.yaml ================================================ repos: - repo: https://github.com/tox-dev/tox-ini-fmt rev: 1.5.0 hooks: - id: tox-ini-fmt ================================================ FILE: crates/prek/tests/fixtures/issue253/biome.json ================================================ { "root": false, "formatter": { "indentStyle": "space", "indentWidth": 4 } } ================================================ FILE: crates/prek/tests/fixtures/issue253/input.json ================================================ { "hello": { "name": "world"} } ================================================ FILE: crates/prek/tests/fixtures/issue253/issue253.yaml ================================================ repos: - repo: https://github.com/biomejs/pre-commit rev: v0.6.1 hooks: - id: biome-check additional_dependencies: [ "@biomejs/biome@2.0.6" ] ================================================ FILE: crates/prek/tests/fixtures/issue265.yaml ================================================ repos: - repo: https://github.com/Lucas-C/pre-commit-hooks rev: v1.4.2 hooks: - id: remove-crlf stages: [ manual ] ================================================ FILE: crates/prek/tests/fixtures/node-dependencies.yaml ================================================ repos: - repo: local hooks: - id: node name: node language: node entry: cowsay Hello World! additional_dependencies: ["cowsay"] always_run: true verbose: true pass_filenames: false ================================================ FILE: crates/prek/tests/fixtures/node-version.yaml ================================================ repos: - repo: local hooks: - id: node name: node language: node entry: node -p 'process.version' language_version: '19' always_run: true pass_filenames: false - id: node name: node language: node entry: node -p 'process.version' language_version: 'lts/hydrogen' always_run: true pass_filenames: false ================================================ FILE: crates/prek/tests/fixtures/python-version.yaml ================================================ repos: - repo: local hooks: - id: python-3.11 name: Python 3.11 entry: python -c "import sys; print(sys.version_info[:3])" language: python language_version: '3.11' always_run: true pass_filenames: false ================================================ FILE: crates/prek/tests/fixtures/repeated-repos.yaml ================================================ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace ================================================ FILE: crates/prek/tests/fixtures/uv-pre-commit-config.yaml ================================================ fail_fast: true exclude: | (?x)^( .*/(snapshots)/.*| )$ repos: - repo: https://github.com/abravalheri/validate-pyproject rev: v0.20.2 hooks: - id: validate-pyproject - repo: https://github.com/crate-ci/typos rev: v1.26.0 hooks: - id: typos priority: 10 - repo: local hooks: - id: cargo-fmt name: cargo fmt entry: cargo fmt -- language: system types: [rust] pass_filenames: false # This makes it a lot faster - repo: local hooks: - id: cargo-dev-generate-all name: cargo dev generate-all entry: cargo dev generate-all language: system types: [rust] pass_filenames: false files: ^crates/(uv-cli|uv-settings)/ - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.1.0 hooks: - id: prettier types_or: [yaml, json5] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.6.9 hooks: - id: ruff-format - id: ruff args: [--fix, --exit-non-zero-on-fix] ================================================ FILE: crates/prek/tests/fixtures/uv-pre-commit-hooks.yaml ================================================ - id: pip-compile name: pip-compile description: "Automatically run 'uv pip compile' on your requirements" entry: uv pip compile language: python files: ^requirements\.(in|txt)$ args: [] pass_filenames: false additional_dependencies: [] minimum_pre_commit_version: "2.9.2" - id: uv-lock name: uv-lock description: "Automatically run 'uv lock' on your project dependencies" entry: uv lock language: python files: ^(uv\.lock|pyproject\.toml|uv\.toml)$ args: [] pass_filenames: false additional_dependencies: [] minimum_pre_commit_version: "2.9.2" - id: uv-export name: uv-export description: "Automatically run 'uv export' on your project dependencies" entry: uv export language: python files: ^uv\.lock$ args: ["--frozen", "--output-file=requirements.txt"] pass_filenames: false additional_dependencies: [] minimum_pre_commit_version: "2.9.2" ================================================ FILE: crates/prek/tests/hook_impl.rs ================================================ #[cfg(unix)] use std::os::unix::fs::PermissionsExt; #[cfg(unix)] use std::path::Path; use assert_cmd::assert::OutputAssertExt; use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; use indoc::indoc; use prek_consts::PRE_COMMIT_CONFIG_YAML; use prek_consts::env_vars::EnvVars; use crate::common::{TestContext, cmd_snapshot, git_cmd}; mod common; #[test] fn hook_impl() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc! { r" repos: - repo: local hooks: - id: fail name: fail language: fail entry: always fail always_run: true "}); context.git_add("."); let mut commit = git_cmd(context.work_dir()); commit .arg("commit") .env(EnvVars::PREK_HOME, &**context.home_dir()) .arg("-m") .arg("Initial commit"); cmd_snapshot!(context.filters(), context.install(), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); cmd_snapshot!(context.filters(), commit, @r" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- fail.....................................................................Failed - hook id: fail - exit code: 1 always fail .pre-commit-config.yaml "); } #[test] fn hook_impl_pre_push() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc! { r#" repos: - repo: local hooks: - id: success name: success language: system entry: echo "hook ran successfully" always_run: true "#}); context.git_add("."); let mut commit = git_cmd(context.work_dir()); commit .arg("commit") .env(EnvVars::PREK_HOME, &**context.home_dir()) .arg("-m") .arg("Initial commit"); cmd_snapshot!(context.filters(), context.install().arg("--hook-type").arg("pre-push"), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-push` ----- stderr ----- "#); let mut filters = context.filters(); filters.push((r"\b[0-9a-f]{7}\b", "[SHA1]")); cmd_snapshot!(filters, commit, @r" success: true exit_code: 0 ----- stdout ----- [master (root-commit) [SHA1]] Initial commit 1 file changed, 8 insertions(+) create mode 100644 .pre-commit-config.yaml ----- stderr ----- "); // Set up a bare remote repository let remote_repo_path = context.home_dir().join("remote.git"); std::fs::create_dir_all(&remote_repo_path)?; let mut init_remote = git_cmd(&remote_repo_path); init_remote .arg("-c") .arg("init.defaultBranch=master") .arg("init") .arg("--bare"); cmd_snapshot!(context.filters(), init_remote, @r#" success: true exit_code: 0 ----- stdout ----- Initialized empty Git repository in [HOME]/remote.git/ ----- stderr ----- "#); // Add remote to local repo let mut add_remote = git_cmd(context.work_dir()); add_remote .arg("remote") .arg("add") .arg("origin") .arg(&remote_repo_path); cmd_snapshot!(context.filters(), add_remote, @r#" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- "#); // First push - should trigger the hook let mut push_cmd = git_cmd(context.work_dir()); push_cmd .arg("push") .arg("origin") .arg("master") .env(EnvVars::PREK_HOME, &**context.home_dir()); cmd_snapshot!(context.filters(), push_cmd, @r" success: true exit_code: 0 ----- stdout ----- success..................................................................Passed ----- stderr ----- To [HOME]/remote.git * [new branch] master -> master "); // Second push - should not trigger the hook (nothing new to push) let mut push_cmd2 = git_cmd(context.work_dir()); push_cmd2 .arg("push") .arg("origin") .arg("master") .env(EnvVars::PREK_HOME, &**context.home_dir()); cmd_snapshot!(context.filters(), push_cmd2, @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Everything up-to-date "); Ok(()) } #[test] fn hook_impl_runs_legacy_hook() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context .work_dir() .child(PRE_COMMIT_CONFIG_YAML) .write_str(indoc! {r" repos: - repo: local hooks: - id: manual-only name: manual-only language: system entry: echo manual-only stages: [ manual ] "})?; context.work_dir().child("file.txt").write_str("x")?; context.git_add("."); cmd_snapshot!(context.filters(), context.install(), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "); let legacy_hook = context.work_dir().child(".git/hooks/pre-commit.legacy"); legacy_hook.write_str(indoc::indoc! {r#" #!/bin/sh python3 -c 'print("legacy pre-commit ran")' exit 1 "#})?; #[cfg(unix)] set_executable(legacy_hook.path())?; let mut commit = git_cmd(context.work_dir()); commit .env(EnvVars::PREK_HOME, &**context.home_dir()) .arg("commit") .arg("-m") .arg("Test commit"); cmd_snapshot!(context.filters(), commit, @" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- legacy pre-commit ran "); Ok(()) } #[test] fn hook_impl_pre_push_runs_legacy_and_prek() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc! { r#" repos: - repo: local hooks: - id: success name: success language: system entry: echo "hook ran successfully" always_run: true "#}); context.git_add("."); context.git_commit("Initial commit"); cmd_snapshot!(context.filters(), context.install().arg("--hook-type").arg("pre-push"), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-push` ----- stderr ----- "#); let legacy_hook = context.work_dir().child(".git/hooks/pre-push.legacy"); legacy_hook.write_str(indoc::indoc! {r#" #!/bin/sh python3 -c 'print("legacy pre-push ran")' exit 1 "#})?; #[cfg(unix)] set_executable(legacy_hook.path())?; let remote_repo_path = context.home_dir().join("remote.git"); std::fs::create_dir_all(&remote_repo_path)?; let mut init_remote = git_cmd(&remote_repo_path); init_remote .arg("-c") .arg("init.defaultBranch=master") .arg("init") .arg("--bare"); init_remote.output()?.assert().success(); let mut add_remote = git_cmd(context.work_dir()); add_remote .arg("remote") .arg("add") .arg("origin") .arg(&remote_repo_path); add_remote.output()?.assert().success(); context.work_dir().child("file.txt").write_str("x")?; context.git_add("."); context.git_commit("Second commit"); let mut push_cmd = git_cmd(context.work_dir()); push_cmd .arg("push") .arg("origin") .arg("master") .env(EnvVars::PREK_HOME, &**context.home_dir()); cmd_snapshot!(context.filters(), push_cmd, @" success: false exit_code: 1 ----- stdout ----- legacy pre-push ran success..................................................................Passed ----- stderr ----- error: failed to push some refs to '[HOME]/remote.git' "); Ok(()) } #[cfg(unix)] fn set_executable(path: &Path) -> anyhow::Result<()> { let mut perms = fs_err::metadata(path)?.permissions(); perms.set_mode(0o755); fs_err::set_permissions(path, perms)?; Ok(()) } /// Test prek hook runs in the correct worktree. #[test] fn run_worktree() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc! { r" repos: - repo: local hooks: - id: fail name: fail language: fail entry: always fail always_run: true "}); context.git_add("."); context.git_commit("Initial commit"); cmd_snapshot!(context.filters(), context.install(), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); // Create a new worktree. git_cmd(context.work_dir()) .arg("worktree") .arg("add") .arg("worktree") .arg("HEAD") .output()? .assert() .success(); // Modify the config in the main worktree context .work_dir() .child(PRE_COMMIT_CONFIG_YAML) .write_str("")?; let mut commit = git_cmd(context.work_dir().child("worktree")); commit .arg("commit") .env(EnvVars::PREK_HOME, &**context.home_dir()) .arg("-m") .arg("Initial commit") .arg("--allow-empty"); cmd_snapshot!(context.filters(), commit, @r" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- fail.....................................................................Failed - hook id: fail - exit code: 1 always fail "); Ok(()) } /// Test prek hooks runs with `GIT_DIR` respected. #[test] fn git_dir_respected() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc! { r#" repos: - repo: local hooks: - id: print-git-dir name: Print Git Dir language: python entry: python -c 'import os, sys; print("GIT_DIR:", os.environ.get("GIT_DIR")); print("GIT_WORK_TREE:", os.environ.get("GIT_WORK_TREE")); sys.exit(1)' pass_filenames: false "#}); context.git_add("."); let cwd = context.work_dir(); cmd_snapshot!(context.filters(), context.install(), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); let mut commit = git_cmd(context.home_dir()); commit .arg("--git-dir") .arg(cwd.join(".git")) .arg("--work-tree") .arg(&**cwd) .env(EnvVars::PREK_HOME, &**context.home_dir()) .arg("commit") .arg("-m") .arg("Test commit with GIT_DIR set"); cmd_snapshot!(context.filters(), commit, @r" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- Print Git Dir............................................................Failed - hook id: print-git-dir - exit code: 1 GIT_DIR: [TEMP_DIR]/.git GIT_WORK_TREE: . "); } #[test] fn workspace_hook_impl_root() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc! {r#" repos: - repo: local hooks: - id: test-hook name: Test Hook language: python entry: python -c 'import os; print("cwd:", os.getcwd())' verbose: true "#}; context.setup_workspace(&["project2", "project3"], config)?; context.git_add("."); // Install from root cmd_snapshot!(context.filters(), context.install(), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); let mut commit = git_cmd(context.work_dir()); commit .env(EnvVars::PREK_HOME, &**context.home_dir()) .arg("commit") .arg("-m") .arg("Test commit from subdirectory"); let filters = context .filters() .into_iter() .chain([("[a-f0-9]{7}", "abc1234")]) .collect::>(); cmd_snapshot!(filters.clone(), commit, @r" success: true exit_code: 0 ----- stdout ----- [master (root-commit) abc1234] Test commit from subdirectory 3 files changed, 24 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 project2/.pre-commit-config.yaml create mode 100644 project3/.pre-commit-config.yaml ----- stderr ----- Running hooks for `project2`: Test Hook................................................................Passed - hook id: test-hook - duration: [TIME] cwd: [TEMP_DIR]/project2 Running hooks for `project3`: Test Hook................................................................Passed - hook id: test-hook - duration: [TIME] cwd: [TEMP_DIR]/project3 Running hooks for `.`: Test Hook................................................................Passed - hook id: test-hook - duration: [TIME] cwd: [TEMP_DIR]/ "); Ok(()) } #[test] fn workspace_hook_impl_subdirectory() -> anyhow::Result<()> { let context = TestContext::new(); let cwd = context.work_dir(); context.init_project(); let config = indoc! {r#" repos: - repo: local hooks: - id: test-hook name: Test Hook language: python entry: python -c 'import os; print("cwd:", os.getcwd())' verbose: true "#}; context.setup_workspace(&["project2", "project3"], config)?; context.git_add("."); // Install from a subdirectory cmd_snapshot!(context.filters(), context.install().current_dir(cwd.join("project2")), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `../.git/hooks/pre-commit` for workspace `[TEMP_DIR]/project2` hint: this hook installed for `[TEMP_DIR]/project2` only; run `prek install` from `[TEMP_DIR]/` to install for the entire repo. ----- stderr ----- "); let mut commit = git_cmd(cwd); commit .env(EnvVars::PREK_HOME, &**context.home_dir()) .arg("commit") .arg("-m") .arg("Test commit from subdirectory"); let filters = context .filters() .into_iter() .chain([("[a-f0-9]{7}", "abc1234")]) .collect::>(); cmd_snapshot!(filters.clone(), commit, @r" success: true exit_code: 0 ----- stdout ----- [master (root-commit) abc1234] Test commit from subdirectory 3 files changed, 24 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 project2/.pre-commit-config.yaml create mode 100644 project3/.pre-commit-config.yaml ----- stderr ----- Running in workspace: `[TEMP_DIR]/project2` Test Hook................................................................Passed - hook id: test-hook - duration: [TIME] cwd: [TEMP_DIR]/project2 "); Ok(()) } /// Install from a subdirectory, and run commit in another worktree. #[test] fn workspace_hook_impl_worktree_subdirectory() -> anyhow::Result<()> { let context = TestContext::new(); let cwd = context.work_dir(); context.init_project(); let config = indoc! {r#" repos: - repo: local hooks: - id: test-hook name: Test Hook language: python entry: python -c 'import os; print("cwd:", os.getcwd())' verbose: true "#}; context.setup_workspace(&["project2", "project3"], config)?; context.git_add("."); context.git_commit("Initial commit"); // Install from a subdirectory cmd_snapshot!(context.filters(), context.install().current_dir(cwd.join("project2")), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `../.git/hooks/pre-commit` for workspace `[TEMP_DIR]/project2` hint: this hook installed for `[TEMP_DIR]/project2` only; run `prek install` from `[TEMP_DIR]/` to install for the entire repo. ----- stderr ----- "); // Create a new worktree. git_cmd(cwd) .arg("worktree") .arg("add") .arg("worktree") .arg("HEAD") .output()? .assert() .success(); // Modify the config in the main worktree context .work_dir() .child("project2") .child(PRE_COMMIT_CONFIG_YAML) .write_str("")?; let mut commit = git_cmd(cwd.child("worktree")); commit .env(EnvVars::PREK_HOME, &**context.home_dir()) .arg("commit") .arg("-m") .arg("Test commit from subdirectory") .arg("--allow-empty"); let filters = context .filters() .into_iter() .chain([("[a-f0-9]{7}", "abc1234")]) .collect::>(); cmd_snapshot!(filters.clone(), commit, @r" success: true exit_code: 0 ----- stdout ----- [detached HEAD abc1234] Test commit from subdirectory ----- stderr ----- Running in workspace: `[TEMP_DIR]/worktree/project2` Test Hook............................................(no files to check)Skipped "); Ok(()) } #[test] fn workspace_hook_impl_no_project_found() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Create a directory without .pre-commit-config.yaml let empty_dir = context.work_dir().child("empty"); empty_dir.create_dir_all()?; empty_dir.child("file.txt").write_str("Some content")?; context.git_add("."); // Install hook that allows missing config cmd_snapshot!(context.filters(), context.install(), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "); // Try to run hook-impl from directory without config let mut commit = git_cmd(&empty_dir); commit .env(EnvVars::PREK_HOME, &**context.home_dir()) .arg("commit") .arg("-m") .arg("Test commit"); cmd_snapshot!(context.filters(), commit, @r" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- error: No `prek.toml` or `.pre-commit-config.yaml` found in the current directory or parent directories. hint: If you just added one, rerun your command with the `--refresh` flag to rescan the workspace. - To temporarily silence this, run `PREK_ALLOW_NO_CONFIG=1 git ...` - To permanently silence this, install hooks with the `--allow-missing-config` flag - To uninstall hooks, run `prek uninstall` "); // Commit with `PREK_ALLOW_NO_CONFIG=1` let mut commit = git_cmd(&empty_dir); commit .env(EnvVars::PREK_HOME, &**context.home_dir()) .env(EnvVars::PREK_ALLOW_NO_CONFIG, "1") .arg("commit") .arg("-m") .arg("Test commit"); let filters = context .filters() .into_iter() .chain([("[a-f0-9]{7}", "1d5e501")]) .collect::>(); // The hook should simply succeed because there is no config cmd_snapshot!(filters.clone(), commit, @r" success: true exit_code: 0 ----- stdout ----- [master (root-commit) 1d5e501] Test commit 1 file changed, 1 insertion(+) create mode 100644 empty/file.txt ----- stderr ----- "); // Create the root `.pre-commit-config.yaml` context .work_dir() .child(PRE_COMMIT_CONFIG_YAML) .write_str(indoc::indoc! {r" repos: - repo: local hooks: - id: fail name: fail entry: fail language: fail "})?; context.git_add("."); // Commit with `PREK_ALLOW_NO_CONFIG=1` again, the hooks should run (and fail) let mut commit = git_cmd(&empty_dir); commit .env(EnvVars::PREK_HOME, &**context.home_dir()) .env(EnvVars::PREK_ALLOW_NO_CONFIG, "1") .arg("commit") .arg("-m") .arg("Test commit"); cmd_snapshot!(filters.clone(), commit, @r" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- fail.....................................................................Failed - hook id: fail - exit code: 1 fail .pre-commit-config.yaml "); Ok(()) } #[test] fn hook_impl_does_not_fail_when_no_hooks_match_stage() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Only a manual-stage hook; a pre-commit hook run should find nothing for the stage. context .work_dir() .child(PRE_COMMIT_CONFIG_YAML) .write_str(indoc::indoc! {r" repos: - repo: local hooks: - id: manual-only name: manual-only language: system entry: echo manual-only stages: [ manual ] "})?; context.work_dir().child("file.txt").write_str("x")?; context.git_add("."); // Install the git hook (which invokes `prek hook-impl`). cmd_snapshot!(context.filters(), context.install(), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "); // Commit should succeed; the hook should not error just because no hooks match pre-commit. let mut commit = git_cmd(context.work_dir()); commit .env(EnvVars::PREK_HOME, &**context.home_dir()) .arg("commit") .arg("-m") .arg("Test commit"); let filters = context .filters() .into_iter() .chain([("[a-f0-9]{7}", "abc1234")]) .collect::>(); cmd_snapshot!(filters, commit, @r" success: true exit_code: 0 ----- stdout ----- [master (root-commit) abc1234] Test commit 2 files changed, 9 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 file.txt ----- stderr ----- "); Ok(()) } #[test] fn workspace_hook_impl_with_selectors() -> anyhow::Result<()> { let context = TestContext::new(); let cwd = context.work_dir(); context.init_project(); let config = indoc! {r#" repos: - repo: local hooks: - id: test-hook name: Test Hook language: python entry: python -c 'import os; print("cwd:", os.getcwd())' verbose: true "#}; context.setup_workspace(&["project2", "project3"], config)?; context.git_add("."); cmd_snapshot!(context.filters(), context.install().arg("project2/"), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "); let mut commit = git_cmd(cwd); commit .env(EnvVars::PREK_HOME, &**context.home_dir()) .arg("commit") .arg("-m") .arg("Test commit from subdirectory"); let filters = context .filters() .into_iter() .chain([("[a-f0-9]{7}", "abc1234")]) .collect::>(); cmd_snapshot!(filters.clone(), commit, @r" success: true exit_code: 0 ----- stdout ----- [master (root-commit) abc1234] Test commit from subdirectory 3 files changed, 24 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 project2/.pre-commit-config.yaml create mode 100644 project3/.pre-commit-config.yaml ----- stderr ----- Running hooks for `project2`: Test Hook................................................................Passed - hook id: test-hook - duration: [TIME] cwd: [TEMP_DIR]/project2 "); Ok(()) } ================================================ FILE: crates/prek/tests/identify.rs ================================================ #[cfg(unix)] use crate::common::{TestContext, cmd_snapshot}; #[cfg(unix)] use assert_fs::fixture::{FileWriteStr, PathChild}; mod common; #[cfg(unix)] // "executable" tag is different on Windows #[test] fn identify_text_with_missing_paths() -> anyhow::Result<()> { let context = TestContext::new(); context .work_dir() .child("hello.py") .write_str("print('hi')\n")?; cmd_snapshot!( context.filters(), context .command() .arg("util") .arg("identify") .arg(".") .arg("hello.py") .arg("missing.py"), @" success: false exit_code: 1 ----- stdout ----- .: directory hello.py: file, non-executable, python, text ----- stderr ----- error: missing.py: No such file or directory (os error 2) " ); Ok(()) } #[cfg(unix)] // "executable" tag is different on Windows #[test] fn identify_json_with_missing_paths() -> anyhow::Result<()> { let context = TestContext::new(); context .work_dir() .child("hello.py") .write_str("print('hi')\n")?; cmd_snapshot!( context.filters(), context .command() .arg("util") .arg("identify") .arg("--output-format") .arg("json") .arg(".") .arg("hello.py") .arg("missing.py"), @r#" success: false exit_code: 1 ----- stdout ----- [ { "path": ".", "tags": [ "directory" ] }, { "path": "hello.py", "tags": [ "file", "non-executable", "python", "text" ] } ] ----- stderr ----- error: missing.py: No such file or directory (os error 2) "#); Ok(()) } ================================================ FILE: crates/prek/tests/install.rs ================================================ use crate::common::{TestContext, cmd_snapshot, git_cmd}; use assert_cmd::assert::OutputAssertExt; use assert_fs::assert::PathAssert; use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; use indoc::indoc; use insta::assert_snapshot; use prek_consts::PRE_COMMIT_CONFIG_YAML; use prek_consts::env_vars::EnvVars; mod common; #[test] fn install() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Install `prek` hook. cmd_snapshot!(context.filters(), context.install(), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@" "#); } ); // Install `pre-commit` and `post-commit` hook. context .work_dir() .child(".git/hooks/pre-commit") .write_str("#!/bin/sh\necho 'pre-commit'\n")?; cmd_snapshot!(context.filters(), context.install().arg("--hook-type").arg("pre-commit").arg("--hook-type").arg("post-commit"), @r" success: true exit_code: 0 ----- stdout ----- Hook already exists at `.git/hooks/pre-commit`, moved it to `.git/hooks/pre-commit.legacy` Migration mode: prek will also run legacy hook `.git/hooks/pre-commit.legacy`. Use `--overwrite` to remove legacy hooks. prek installed at `.git/hooks/pre-commit` prek installed at `.git/hooks/post-commit` ----- stderr ----- "); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@" "#); } ); assert_snapshot!(context.read(".git/hooks/pre-commit.legacy"), @r##" #!/bin/sh echo 'pre-commit' "##); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/post-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=post-commit -- "$@" "#); } ); // Overwrite existing hooks. cmd_snapshot!(context.filters(), context.install().arg("-t").arg("pre-commit").arg("--hook-type").arg("post-commit").arg("--overwrite"), @r#" success: true exit_code: 0 ----- stdout ----- Overwriting existing hook at `.git/hooks/pre-commit` prek installed at `.git/hooks/pre-commit` Overwriting existing hook at `.git/hooks/post-commit` prek installed at `.git/hooks/post-commit` ----- stderr ----- "#); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@" "#); } ); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/post-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=post-commit -- "$@" "#); } ); Ok(()) } #[test] fn install_with_quiet_flag() { let context = TestContext::new(); context.init_project(); cmd_snapshot!(context.filters(), context.install().arg("-q"), @r#" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- "#); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" -q hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@" "#); } ); } #[test] fn install_with_silent_flag() { let context = TestContext::new(); context.init_project(); cmd_snapshot!(context.filters(), context.install().arg("-qq"), @r#" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- "#); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" -qq hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@" "#); } ); } #[test] fn install_with_verbose_flag() { let context = TestContext::new(); context.init_project(); cmd_snapshot!(context.filters(), context.install().arg("-v"), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" -v hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@" "#); } ); } #[test] fn install_with_no_progress_flag() { let context = TestContext::new(); context.init_project(); cmd_snapshot!(context.filters(), context.install().arg("--no-progress"), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" --no-progress hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@" "#); } ); } #[test] fn install_with_git_dir() { let context = TestContext::new(); context.init_project(); cmd_snapshot!(context.filters(), context.install().arg("--git-dir").arg("custom-git-dir"), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `custom-git-dir/hooks/pre-commit` ----- stderr ----- "#); context .work_dir() .child(".git/hooks/pre-commit") .assert(predicates::path::missing()); context .work_dir() .child("custom-git-dir/hooks/pre-commit") .assert(predicates::path::exists()); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read("custom-git-dir/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@" "#); } ); } #[test] fn install_with_git_dir_allows_hooks_path_set() { let context = TestContext::new(); context.init_project(); git_cmd(context.work_dir()) .args(["config", "core.hooksPath", "custom-hooks"]) .assert() .success(); cmd_snapshot!(context.filters(), context.install(), @r#" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Cowardly refusing to install hooks with `core.hooksPath` set. hint: Run these commands to remove core.hooksPath: hint: git config --unset-all --local core.hooksPath hint: git config --unset-all --global core.hooksPath "#); cmd_snapshot!(context.filters(), context.install().arg("--git-dir").arg(".git"), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); } /// Hook permissions should be standard when `core.sharedRepository` is not set. #[test] #[cfg(unix)] fn install_uses_standard_permissions_by_default() { use std::os::unix::fs::PermissionsExt; let context = TestContext::new(); context.init_project(); context.install().assert().success(); // Verify hook permissions are 0o755 (standard) let hook_path = context.work_dir().join(".git/hooks/pre-commit"); let metadata = std::fs::metadata(&hook_path).unwrap(); let mode = metadata.permissions().mode() & 0o777; assert_eq!( mode, 0o755, "Hook should have standard permissions (0o755), got {mode:o}" ); } /// Hook permissions should be group-writable when `core.sharedRepository` is set. #[test] #[cfg(unix)] fn install_uses_group_permissions_for_shared_repository() { use std::os::unix::fs::PermissionsExt; let context = TestContext::new(); context.init_project(); // Set core.sharedRepository = group git_cmd(context.work_dir()) .args(["config", "core.sharedRepository", "group"]) .assert() .success(); context.install().assert().success(); // Verify hook permissions are 0o775 (group-writable) let hook_path = context.work_dir().join(".git/hooks/pre-commit"); let metadata = std::fs::metadata(&hook_path).unwrap(); let mode = metadata.permissions().mode() & 0o777; assert_eq!( mode, 0o775, "Hook should have group-writable permissions (0o775), got {mode:o}" ); } /// Hook permissions should respect explicit octal `core.sharedRepository` values. #[test] #[cfg(unix)] fn install_uses_explicit_shared_repository_mode() { use std::os::unix::fs::PermissionsExt; let context = TestContext::new(); context.init_project(); git_cmd(context.work_dir()) .args(["config", "core.sharedRepository", "0640"]) .assert() .success(); context.install().assert().success(); let hook_path = context.work_dir().join(".git/hooks/pre-commit"); let metadata = std::fs::metadata(&hook_path).unwrap(); let mode = metadata.permissions().mode() & 0o777; assert_eq!( mode, 0o750, "Hook should respect explicit shared mode (0o750), got {mode:o}" ); } /// Run `prek install --prepare-hooks` to install the git hook and prepare prek hook environments. #[test] fn install_with_hooks() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace "}); context .home_dir() .child("repos") .assert(predicates::path::missing()); context .home_dir() .child("hooks") .assert(predicates::path::missing()); cmd_snapshot!(context.filters(), context.install().arg("--prepare-hooks"), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); // Check that repos and hooks are created. assert_eq!(context.home_dir().child("repos").read_dir()?.count(), 1); assert_eq!(context.home_dir().child("hooks").read_dir()?.count(), 1); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@" "#); } ); Ok(()) } #[test] fn install_with_legacy_install_hooks_flag_alias() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: test-hook name: Test Hook language: python entry: python -c 'print("test")' "#}); context .home_dir() .child("hooks") .assert(predicates::path::missing()); cmd_snapshot!(context.filters(), context.install().arg("--install-hooks"), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); assert_eq!(context.home_dir().child("hooks").read_dir()?.count(), 1); Ok(()) } #[test] fn install_with_existing_legacy_hook() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Install our hook script first. context.install().assert().success(); // Simulate an existing migrated legacy hook. context .work_dir() .child(".git/hooks/pre-commit.legacy") .write_str("#!/bin/sh\necho 'legacy'\n")?; // Without --overwrite, we should stay in migration mode. cmd_snapshot!(context.filters(), context.install(), @r" success: true exit_code: 0 ----- stdout ----- Migration mode: prek will also run legacy hook `.git/hooks/pre-commit.legacy`. Use `--overwrite` to remove legacy hooks. prek installed at `.git/hooks/pre-commit` ----- stderr ----- "); context .work_dir() .child(".git/hooks/pre-commit.legacy") .assert(predicates::path::exists()); // With --overwrite, the legacy script should be removed. cmd_snapshot!(context.filters(), context.install().arg("--overwrite"), @r#" success: true exit_code: 0 ----- stdout ----- Overwriting existing hook at `.git/hooks/pre-commit` prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); context .work_dir() .child(".git/hooks/pre-commit.legacy") .assert(predicates::path::missing()); Ok(()) } /// Run `prek prepare-hooks` to prepare prek hook environments without installing the git hook. #[test] fn install_hooks_only() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace "}); context .home_dir() .child("repos") .assert(predicates::path::missing()); context .home_dir() .child("hooks") .assert(predicates::path::missing()); cmd_snapshot!(context.filters(), context.prepare_hooks(), @r#" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- "#); // Check that repos and hooks are created. assert_eq!(context.home_dir().child("repos").read_dir()?.count(), 1); assert_eq!(context.home_dir().child("hooks").read_dir()?.count(), 1); // Ensure the git hook is not installed. context .work_dir() .child(".git/hooks/pre-commit") .assert(predicates::path::missing()); Ok(()) } #[test] fn install_with_legacy_install_hooks_subcommand_alias() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: test-hook name: Test Hook language: python entry: python -c 'print("test")' "#}); context .home_dir() .child("hooks") .assert(predicates::path::missing()); cmd_snapshot!( context.filters(), context.command().arg("install-hooks"), @r#" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- "#); assert_eq!(context.home_dir().child("hooks").read_dir()?.count(), 1); Ok(()) } #[test] fn uninstall() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Hook does not exist. cmd_snapshot!(context.filters(), context.uninstall(), @r#" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- `.git/hooks/pre-commit` does not exist, skipping. "#); // Uninstall `pre-commit` hook. context.install().assert().success(); cmd_snapshot!(context.filters(), context.uninstall(), @r#" success: true exit_code: 0 ----- stdout ----- Uninstalled `pre-commit` ----- stderr ----- "#); context .work_dir() .child(".git/hooks/pre-commit") .assert(predicates::path::missing()); // Hook is not managed by `pre-commit`. context .work_dir() .child(".git/hooks/pre-commit") .write_str("#!/bin/sh\necho 'pre-commit'\n")?; cmd_snapshot!(context.filters(), context.uninstall(), @r#" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- `.git/hooks/pre-commit` is not managed by prek, skipping. "#); // Restore previous hook. context.install().assert().success(); cmd_snapshot!(context.filters(), context.uninstall(), @" success: true exit_code: 0 ----- stdout ----- Uninstalled `pre-commit` Restored `.git/hooks/pre-commit.legacy` to `.git/hooks/pre-commit` ----- stderr ----- "); // Uninstall multiple hooks. context .install() .arg("-t") .arg("pre-commit") .arg("-t") .arg("post-commit") .assert() .success(); cmd_snapshot!(context.filters(), context.uninstall().arg("-t").arg("pre-commit").arg("-t").arg("post-commit"), @" success: true exit_code: 0 ----- stdout ----- Uninstalled `pre-commit` Restored `.git/hooks/pre-commit.legacy` to `.git/hooks/pre-commit` Uninstalled `post-commit` ----- stderr ----- "); Ok(()) } /// `prek uninstall --all` should remove all prek-managed hooks. #[test] fn uninstall_all_managed_hooks() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Install both pre-commit and pre-push hooks. context .install() .arg("-t") .arg("pre-commit") .arg("-t") .arg("pre-push") .assert() .success(); assert!(context.work_dir().join(".git/hooks/pre-commit").exists()); assert!(context.work_dir().join(".git/hooks/pre-push").exists()); let custom_hook = "#!/bin/sh\necho 'custom pre-commit'\n"; context .work_dir() .child(".git/hooks/pre-commit") .write_str(custom_hook)?; // Uninstall with `--all` should only remove managed hooks. cmd_snapshot!(context.filters(), context.uninstall().arg("--all"), @r" success: true exit_code: 0 ----- stdout ----- Uninstalled `pre-push` ----- stderr ----- "); assert_eq!(context.read(".git/hooks/pre-commit"), custom_hook); assert!(!context.work_dir().join(".git/hooks/pre-push").exists()); Ok(()) } #[test] fn uninstall_remove_legacy_hook() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Create the `pre-commit` hook. context.install().assert().success(); // Create the `pre-commit.legacy` file. fs_err::copy( context.work_dir().join(".git/hooks/pre-commit"), context.work_dir().join(".git/hooks/pre-commit.legacy"), )?; // Uninstall should remove the `pre-commit.legacy` file too. cmd_snapshot!(context.filters(), context.uninstall(), @" success: true exit_code: 0 ----- stdout ----- Uninstalled `pre-commit` ----- stderr ----- Found legacy hook at `.git/hooks/pre-commit.legacy`, removing it. "); context .work_dir() .child(".git/hooks/pre-commit") .assert(predicates::path::missing()); context .work_dir() .child(".git/hooks/pre-commit.legacy") .assert(predicates::path::missing()); // Create a legacy script that is not ours and ensure it is not removed. context.install().assert().success(); context .work_dir() .child(".git/hooks/pre-commit.legacy") .write_str("#!/bin/sh\necho 'legacy'\n")?; cmd_snapshot!(context.filters(), context.uninstall(), @" success: true exit_code: 0 ----- stdout ----- Uninstalled `pre-commit` Restored `.git/hooks/pre-commit.legacy` to `.git/hooks/pre-commit` ----- stderr ----- "); context .work_dir() .child(".git/hooks/pre-commit") .assert(predicates::path::exists()); context .work_dir() .child(".git/hooks/pre-commit.legacy") .assert(predicates::path::missing()); Ok(()) } #[test] fn init_template_dir() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); cmd_snapshot!(context.filters(), context.command().arg("init-templatedir").arg(".git"), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir '.git'` "#); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit --skip-on-missing-config -- "$@" "#); } ); // Run from a subdirectory. let child = context.work_dir().child("subdir"); child.create_dir_all()?; cmd_snapshot!(context.filters(), context.command().arg("init-templatedir").arg("temp-dir").current_dir(child), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `temp-dir/hooks/pre-commit` ----- stderr ----- warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir 'temp-dir'` "#); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read("subdir/temp-dir/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit --skip-on-missing-config -- "$@" "#); } ); // `--config` points to non-existing file. cmd_snapshot!(context.filters(), context.command().arg("init-templatedir").arg("-c").arg("non-exist-config").arg("subdir2"), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `subdir2/hooks/pre-commit` with specified config `non-exist-config` ----- stderr ----- warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir 'subdir2'` "); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read("subdir2/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit --config="non-exist-config" --skip-on-missing-config -- "$@" "#); } ); Ok(()) } /// Tests `prek util init-template-dir` works. #[test] fn util_init_template_dir() { let context = TestContext::new(); context.init_project(); cmd_snapshot!(context.filters(), context.command().arg("util").arg("init-templatedir").arg(".git"), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir '.git'` "#); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit --skip-on-missing-config -- "$@" "#); } ); } /// Tests `prek init-template-dir` in a non-git repository. #[test] fn init_template_dir_non_git_repo() { let context = TestContext::new(); cmd_snapshot!(context.filters(), context.command().arg("init-template-dir").arg(".git"), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir '.git'` "#); context.write_pre_commit_config(indoc::indoc! {" default_install_hook_types: - pre-commit - commit-msg - pre-push repos: "}); cmd_snapshot!(context.filters(), context.command().arg("init-template-dir").arg("-c").arg(context.work_dir().join(PRE_COMMIT_CONFIG_YAML)).arg(".git"), @r" success: true exit_code: 0 ----- stdout ----- Overwriting existing hook at `.git/hooks/pre-commit` prek installed at `.git/hooks/pre-commit` with specified config `[TEMP_DIR]/.pre-commit-config.yaml` prek installed at `.git/hooks/commit-msg` with specified config `[TEMP_DIR]/.pre-commit-config.yaml` prek installed at `.git/hooks/pre-push` with specified config `[TEMP_DIR]/.pre-commit-config.yaml` ----- stderr ----- warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir '.git'` "); } #[test] fn workspace_install() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc! {r#" repos: - repo: local hooks: - id: test-hook name: Test Hook language: python entry: python -c 'print("test")' "#}; context.setup_workspace( &[ "project2", "project3", "nested/project4", "project3/project5", ], config, )?; context.git_add("."); // Install from root directory. cmd_snapshot!(context.filters(), context.install(), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@" "#); } ); // Install from a subdirectory. cmd_snapshot!(context.filters(), context.install().current_dir(context.work_dir().join("project3")), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `../.git/hooks/pre-commit` for workspace `[TEMP_DIR]/project3` hint: this hook installed for `[TEMP_DIR]/project3` only; run `prek install` from `[TEMP_DIR]/` to install for the entire repo. ----- stderr ----- "); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit --cd="project3" -- "$@" "#); } ); // Install with selectors cmd_snapshot!(context.filters(), context.install().arg("project3/").arg("--skip").arg("project2/"), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 project3/ --skip=project2/ --hook-type=pre-commit -- "$@" "#); } ); // Invalid selectors cmd_snapshot!(context.filters(), context.install().arg(":"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Invalid selector: `:` caused by: hook ID part is empty "); // SKIP env var is ignored cmd_snapshot!(context.filters(), context.install().arg("project3/").env(EnvVars::SKIP, "project5/"), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- warning: Skip selectors from environment variables `SKIP` are ignored during installing hooks. "); insta::with_settings!( { filters => context.filters() }, { assert_snapshot!(context.read(".git/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 project3/ --hook-type=pre-commit -- "$@" "#); } ); Ok(()) } #[test] fn workspace_install_hooks() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc! {r#" repos: - repo: local hooks: - id: test-hook name: Test Hook language: python entry: python -c 'print("test")' "#}; context.setup_workspace( &[ "project2", "project3", "nested/project4", "project3/project5", ], config, )?; context.git_add("."); // Install by selectors cmd_snapshot!(context.filters(), context.prepare_hooks().arg("project3").arg("--skip").arg("project3/project5/"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- "); // Install all hooks cmd_snapshot!(context.filters(), context.prepare_hooks(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- "); // Check that hooks are created. assert_eq!(context.home_dir().child("hooks").read_dir()?.count(), 1); Ok(()) } /// Only install root config's hook types in a workspace. #[test] fn workspace_install_only_root_hook_types() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let root_config = indoc! {r#" default_install_hook_types: [pre-commit, post-commit] repos: - repo: local hooks: - id: root-hook name: Root Hook language: python entry: python -c 'print("root")' "#}; let nested_config = indoc! {r#" default_install_hook_types: [pre-push, post-merge] repos: - repo: local hooks: - id: nested-hook name: Nested Hook language: python entry: python -c 'print("nested")' "#}; context .work_dir() .child(PRE_COMMIT_CONFIG_YAML) .write_str(root_config)?; context.work_dir().child("project2").create_dir_all()?; context .work_dir() .child("project2") .child(PRE_COMMIT_CONFIG_YAML) .write_str(nested_config)?; context.git_add("."); cmd_snapshot!(context.filters(), context.install(), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` prek installed at `.git/hooks/post-commit` ----- stderr ----- "); // Should only install pre-commit and post-commit hooks from root config assert!(context.work_dir().join(".git/hooks/pre-commit").exists()); assert!(context.work_dir().join(".git/hooks/post-commit").exists()); assert!(!context.work_dir().join(".git/hooks/pre-push").exists()); assert!(!context.work_dir().join(".git/hooks/post-merge").exists()); Ok(()) } #[test] fn workspace_uninstall() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc! {r#" repos: - repo: local hooks: - id: test-hook name: Test Hook language: python entry: python -c 'print("test")' "#}; context.setup_workspace( &[ "project2", "project3", "nested/project4", "project3/project5", ], config, )?; context.git_add("."); // Install first context.install().assert().success(); // Then uninstall cmd_snapshot!(context.filters(), context.uninstall(), @r" success: true exit_code: 0 ----- stdout ----- Uninstalled `pre-commit` ----- stderr ----- "); // Verify hooks are removed assert!(!context.work_dir().join(".git/hooks/pre-commit").exists()); Ok(()) } #[test] fn workspace_init_template_dir() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc! {r#" repos: - repo: local hooks: - id: test-hook name: Test Hook language: python entry: python -c "print('test')" "#}; context.setup_workspace( &[ "project2", "project3", "nested/project4", "project3/project5", ], config, )?; context.git_add("."); // Create a template directory let template_dir = context.work_dir().child("template"); template_dir.create_dir_all()?; cmd_snapshot!(context.filters(), context.command().arg("init-template-dir").arg(&*template_dir), @r" success: true exit_code: 0 ----- stdout ----- prek installed at `template/hooks/pre-commit` ----- stderr ----- warning: git config `init.templateDir` not set to the target directory, try `git config --global init.templateDir '[TEMP_DIR]/template'` "); // Check that hooks are created in the template directory assert!(template_dir.join("hooks/pre-commit").exists()); let filters = context.filters(); insta::with_settings!( { filters => filters.clone() }, { insta::assert_snapshot!(context.read("template/hooks/pre-commit"), @r#" #!/bin/sh # File generated by prek: https://github.com/j178/prek # ID: 182c10f181da4464a3eec51b83331688 HERE="$(cd "$(dirname "$0")" && pwd)" PREK="[CURRENT_EXE]" # Check if the full path to prek is executable, otherwise fallback to PATH if [ ! -x "$PREK" ]; then PREK="prek" fi exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit --skip-on-missing-config -- "$@" "#); } ); Ok(()) } /// Test that a warning is shown when the config file exists but is invalid. #[test] fn install_invalid_config_warning() { let context = TestContext::new(); context.init_project(); // Write an invalid config (missing required `rev` field). context.write_pre_commit_config(indoc! {r" repos: - repo: https://github.com/pre-commit/pre-commit-hooks hooks: - id: trailing-whitespace "}); // Install should succeed but show a warning about the invalid config. cmd_snapshot!(context.filters(), context.install(), @" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- warning: Failed to parse `.pre-commit-config.yaml`: error: line 3 column 5: missing field `rev` --> :3:5 | 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | hooks: | ^ missing field `rev` 4 | - id: trailing-whitespace | "); } ================================================ FILE: crates/prek/tests/languages/bun.rs ================================================ use anyhow::Result; use assert_fs::assert::PathAssert; use assert_fs::fixture::PathChild; use prek_consts::env_vars::EnvVars; use crate::common::{TestContext, cmd_snapshot}; /// Test basic Bun hook execution. #[test] fn basic_bun() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: bun-check name: bun check language: bun entry: bun -e 'console.log("Hello from Bun!")' always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- bun check................................................................Passed - hook id: bun-check - duration: [TIME] Hello from Bun! ----- stderr ----- "); } /// Test that `additional_dependencies` are installed correctly. #[test] fn additional_dependencies() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: bun-cowsay name: bun cowsay language: bun entry: cowsay Hello World! additional_dependencies: ["cowsay"] always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- bun cowsay...............................................................Passed - hook id: bun-cowsay - duration: [TIME] ______________ < Hello World! > -------------- \ ^__^ \ (oo)/_______ (__)\ )\/\ ||----w | || || ----- stderr ----- "); // Run again to check `health_check` works correctly (cache reuse). cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- bun cowsay...............................................................Passed - hook id: bun-cowsay - duration: [TIME] ______________ < Hello World! > -------------- \ ^__^ \ (oo)/_______ (__)\ )\/\ ||----w | || || ----- stderr ----- "); } /// Test `language_version` specification and bun installation. /// In CI, we ensure bun 1.3 is installed. #[test] fn language_version() -> Result<()> { if !EnvVars::is_set(EnvVars::CI) { // Skip when not running in CI, as we may have other go versions installed locally. return Ok(()); } let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: bun-version name: bun version check language: bun language_version: ">1.2" entry: bun -e 'console.log(`Bun ${Bun.version}`)' always_run: true verbose: true pass_filenames: false - id: bun-version name: bun version check language: bun language_version: "1.3" entry: bun -e 'console.log(`Bun ${Bun.version}`)' always_run: true verbose: true pass_filenames: false - id: bun-version name: bun version check language: bun language_version: "1.2" # will auto download entry: bun -e 'console.log(`Bun ${Bun.version}`)' always_run: true verbose: true pass_filenames: false - id: bun-version name: bun version check language: bun language_version: "bun@1.2" entry: bun -e 'console.log(`Bun ${Bun.version}`)' always_run: true verbose: true pass_filenames: false additional_dependencies: ["cowsay"] # different dep to force create separate env "#}); context.git_add("."); let bun_dir = context.home_dir().child("tools").child("bun"); bun_dir.assert(predicates::path::missing()); let filters = context .filters() .into_iter() .chain([(r"Bun (\d+\.\d+)\.\d+", "Bun $1.X")]) .collect::>(); cmd_snapshot!(filters, context.run(), @r" success: true exit_code: 0 ----- stdout ----- bun version check........................................................Passed - hook id: bun-version - duration: [TIME] Bun 1.3.X bun version check........................................................Passed - hook id: bun-version - duration: [TIME] Bun 1.3.X bun version check........................................................Passed - hook id: bun-version - duration: [TIME] Bun 1.2.X bun version check........................................................Passed - hook id: bun-version - duration: [TIME] Bun 1.2.X ----- stderr ----- "); // Check that only bun 1.2 is installed. let installed_versions = bun_dir .read_dir()? .flatten() .filter_map(|d| { let filename = d.file_name().to_string_lossy().to_string(); if filename.starts_with('.') { None } else { Some(filename) } }) .collect::>(); assert_eq!( installed_versions.len(), 1, "Expected only one Bun version to be installed, but found: {installed_versions:?}" ); assert!( installed_versions.iter().any(|v| v.contains("1.2")), "Expected Bun 1.2 to be installed, but found: {installed_versions:?}" ); Ok(()) } ================================================ FILE: crates/prek/tests/languages/deno.rs ================================================ use assert_fs::assert::PathAssert; use assert_fs::fixture::{FileWriteStr, PathChild}; use prek_consts::env_vars::EnvVars; use crate::common::{TestContext, cmd_snapshot, remove_bin_from_path}; /// Test basic Deno hook execution with an inline script. #[test] fn basic_deno() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: deno-check name: deno check language: deno entry: deno eval 'console.log("Hello from Deno!")' always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- deno check...............................................................Passed - hook id: deno-check - duration: [TIME] Hello from Deno! ----- stderr ----- "); } /// Test running a TypeScript script file with an explicit `deno run` entry. #[test] fn script_file() { let context = TestContext::new(); context.init_project(); // Create a TypeScript script context .work_dir() .child("check.ts") .write_str(indoc::indoc! {r#" console.log("Script executed successfully!"); "#}) .expect("Failed to write check.ts"); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: ts-script name: ts script language: deno entry: deno run ./check.ts always_run: true verbose: true pass_filenames: false "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- ts script................................................................Passed - hook id: ts-script - duration: [TIME] Script executed successfully! ----- stderr ----- "); } /// Test running Deno built-in subcommands with an explicit `deno` prefix. #[test] fn builtin_commands() { let context = TestContext::new(); context.init_project(); // Create a TypeScript file for formatting check context .work_dir() .child("example.ts") .write_str(indoc::indoc! {r" const x = 1; console.log(x); "}) .expect("Failed to write example.ts"); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: deno-fmt-check name: deno fmt check language: deno entry: deno fmt --check types: [ts] verbose: true "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- deno fmt check...........................................................Passed - hook id: deno-fmt-check - duration: [TIME] Checked 1 file ----- stderr ----- "); } /// Test a remote Deno hook whose manifest installs its own executable. #[test] fn remote_hook() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/prek-test-repos/deno-hooks rev: v3.1.0 hooks: - id: deno-eval always_run: true verbose: true "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- deno-eval................................................................Passed - hook id: deno-eval - duration: [TIME] This is a remote deno hook ----- stderr ----- "); } /// Test a remote Deno hook whose configured additional dependency installs the executable it runs. #[test] fn remote_hook_with_additional_dependencies() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: https://github.com/prek-test-repos/deno-hooks rev: v3.1.0 hooks: - id: deno-semver additional_dependencies: ["npm:semver@7:semver-tool"] always_run: true verbose: true "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- deno-semver..............................................................Passed - hook id: deno-semver - duration: [TIME] 1.2.3 ----- stderr ----- "); } /// Test a remote Deno hook whose manifest installs a local file as an executable dependency. #[test] fn remote_hook_with_local_file_additional_dependency() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/prek-test-repos/deno-hooks rev: v3.1.0 hooks: - id: deno-local-dep always_run: true verbose: true "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- deno-local-dep...........................................................Passed - hook id: deno-local-dep - duration: [TIME] Hello from remote local additional dependency! ----- stderr ----- "); } /// Test that `additional_dependencies` are installed as CLI executables. #[test] fn additional_dependencies() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: semver-version name: semver version language: deno entry: semver-tool 1.2.3 additional_dependencies: ["npm:semver@7:semver-tool"] always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); let filters = context.filters().into_iter().collect::>(); cmd_snapshot!(filters.clone(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- semver version...........................................................Passed - hook id: semver-version - duration: [TIME] 1.2.3 ----- stderr ----- "); // Run again to ensure the existing environment is reused cleanly. cmd_snapshot!(filters, context.run(), @r" success: true exit_code: 0 ----- stdout ----- semver version...........................................................Passed - hook id: semver-version - duration: [TIME] 1.2.3 ----- stderr ----- "); } /// Test that a local file can be installed as an executable additional dependency. #[test] fn additional_dependencies_local_file() { let context = TestContext::new(); context.init_project(); context .work_dir() .child("tool.ts") .write_str(indoc::indoc! {r#" console.log("Hello from local additional dependency!"); "#}) .expect("Failed to write tool.ts"); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: local-tool name: local tool language: deno entry: echo-tool additional_dependencies: ["./tool.ts:echo-tool"] always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- local tool...............................................................Passed - hook id: local-tool - duration: [TIME] Hello from local additional dependency! ----- stderr ----- "); } /// Test `language_version` specification and deno installation. /// In CI, we ensure deno 2.x is installed via setup-deno action. #[test] fn language_version() { if !EnvVars::is_set(EnvVars::CI) { // Skip when not running in CI, as we may have other deno versions installed locally. return; } let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: deno-version name: deno version check (system) language: deno language_version: '2' entry: deno eval 'console.log(`Deno ${Deno.version.deno}`)' always_run: true verbose: true pass_filenames: false - id: deno-version name: deno version check (deno@2) language: deno language_version: deno@2 entry: deno eval 'console.log(`Deno ${Deno.version.deno}`)' always_run: true verbose: true pass_filenames: false - id: deno-version name: deno version check (1.46 - will auto download) language: deno language_version: '1.46' entry: deno eval 'console.log(`Deno ${Deno.version.deno}`)' always_run: true verbose: true pass_filenames: false - id: deno-version name: deno version check (deno@1.46) language: deno language_version: deno@1.46 entry: deno eval 'console.log(`Deno ${Deno.version.deno}`)' always_run: true verbose: true pass_filenames: false "}); context.git_add("."); let deno_dir = context.home_dir().child("tools").child("deno"); deno_dir.assert(predicates::path::missing()); // Use two filters: first masks minor+patch for Deno 2.x (major-only request), // then masks only patch for specific minor versions like 1.46.x let filters = context .filters() .into_iter() .chain([ (r"Deno 2\.\d+\.\d+", "Deno 2.X.X"), (r"Deno (\d+\.\d+)\.\d+", "Deno $1.X"), ]) .collect::>(); cmd_snapshot!(filters, context.run(), @r" success: true exit_code: 0 ----- stdout ----- deno version check (system)..............................................Passed - hook id: deno-version - duration: [TIME] Deno 2.X.X deno version check (deno@2)..............................................Passed - hook id: deno-version - duration: [TIME] Deno 2.X.X deno version check (1.46 - will auto download)...........................Passed - hook id: deno-version - duration: [TIME] Deno 1.46.X deno version check (deno@1.46)...........................................Passed - hook id: deno-version - duration: [TIME] Deno 1.46.X ----- stderr ----- "); // Check that only deno 1.46 is installed (2.x uses system). let installed_versions = deno_dir .read_dir() .expect("Failed to read deno tools directory") .flatten() .filter_map(|d| { let filename = d.file_name().to_string_lossy().to_string(); if filename.starts_with('.') { None } else { Some(filename) } }) .collect::>(); assert_eq!( installed_versions.len(), 1, "Expected only one Deno version to be installed, but found: {installed_versions:?}" ); assert!( installed_versions.iter().any(|v| v.contains("1.46")), "Expected Deno 1.46 to be installed, but found: {installed_versions:?}" ); } /// Test that deno hooks work without system deno in PATH. /// Regression test ensuring run-time resolution still finds the managed toolchain. #[test] fn without_system_deno() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: deno-check name: deno check language: deno entry: deno eval 'console.log("Hello")' always_run: true pass_filenames: false "#}); context.git_add("."); let new_path = remove_bin_from_path("deno", None).expect("Failed to remove deno from PATH"); cmd_snapshot!(context.filters(), context.run().env("PATH", new_path), @r" success: true exit_code: 0 ----- stdout ----- deno check...............................................................Passed ----- stderr ----- "); } /// Test semver range version specification. #[test] fn version_range() { if !EnvVars::is_set(EnvVars::CI) { // Skip when not running in CI. return; } let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: deno-version name: deno version range language: deno language_version: ">=2.0" entry: deno eval 'console.log(`Deno ${Deno.version.deno}`)' always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); let filters = context .filters() .into_iter() .chain([(r"Deno \d+\.\d+\.\d+", "Deno [VERSION]")]) .collect::>(); cmd_snapshot!(filters, context.run(), @r" success: true exit_code: 0 ----- stdout ----- deno version range.......................................................Passed - hook id: deno-version - duration: [TIME] Deno [VERSION] ----- stderr ----- "); } /// Test that hook failure is properly reported. #[test] fn hook_failure() { let context = TestContext::new(); context.init_project(); // Create a TypeScript file with a lint error context .work_dir() .child("bad.ts") .write_str(indoc::indoc! {r" // This has a lint error: no-explicit-any let x: any = 1; console.log(x); "}) .expect("Failed to write bad.ts"); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: deno-lint name: deno lint language: deno entry: deno lint types: [ts] verbose: true "}); context.git_add("."); // The lint should fail due to no-explicit-any let output = context.run().output().expect("Failed to run hook"); assert!(!output.status.success(), "Expected lint to fail"); } /// Test script with Deno permissions. /// Note: Permissions must come before the script in the entry, so use explicit `deno run`. #[test] fn script_with_permissions() { let context = TestContext::new(); context.init_project(); // Create a script that reads an environment variable context .work_dir() .child("read_env.ts") .write_str(indoc::indoc! {r#" console.log(Deno.env.get("TEST_VAR") ?? "not set"); "#}) .expect("Failed to write read_env.ts"); // Permissions must be specified before the script path when using deno run context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: deno-env name: deno env language: deno entry: deno run --allow-env ./read_env.ts always_run: true verbose: true pass_filenames: false "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run().env("TEST_VAR", "hello"), @r" success: true exit_code: 0 ----- stdout ----- deno env.................................................................Passed - hook id: deno-env - duration: [TIME] hello ----- stderr ----- "); } ================================================ FILE: crates/prek/tests/languages/docker.rs ================================================ use assert_fs::fixture::{FileWriteStr, PathChild}; use crate::common::{TestContext, cmd_snapshot}; /// GitHub Action only has docker for linux hosted runners. #[test] fn docker() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: https://github.com/prek-test-repos/docker-hooks rev: v1.0 hooks: - id: hello-world entry: "sh -c 'echo $MESSAGE! $*' --" env: MESSAGE: "Hello, world" verbose: true always_run: true "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: true exit_code: 0 ----- stdout ----- Hello World..............................................................Passed - hook id: hello-world - duration: [TIME] Hello, world! .pre-commit-config.yaml ----- stderr ----- "#); } #[test] fn workspace_docker() -> anyhow::Result<()> { let context = TestContext::new(); let cwd = context.work_dir(); context.init_project(); let config = indoc::indoc! {r" repos: - repo: https://github.com/prek-test-repos/docker-hooks rev: v1.0 hooks: - id: hello-world entry: echo verbose: true "}; context.setup_workspace(&["project1", "project2"], config)?; cwd.child("project1").child("project1.txt").write_str("")?; cwd.child("project2").child("project2.txt").write_str("")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project1`: Hello World..............................................................Passed - hook id: hello-world - duration: [TIME] project1.txt .pre-commit-config.yaml Running hooks for `project2`: Hello World..............................................................Passed - hook id: hello-world - duration: [TIME] project2.txt .pre-commit-config.yaml Running hooks for `.`: Hello World..............................................................Passed - hook id: hello-world - duration: [TIME] project1/.pre-commit-config.yaml .pre-commit-config.yaml project2/project2.txt project1/project1.txt project2/.pre-commit-config.yaml ----- stderr ----- "); Ok(()) } ================================================ FILE: crates/prek/tests/languages/docker_image.rs ================================================ use std::os::unix::fs::PermissionsExt; use anyhow::Result; use assert_cmd::Command; use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use crate::common::{TestContext, cmd_snapshot}; #[test] fn docker_image() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); // Test suite from https://github.com/super-linter/super-linter/tree/main/test/linters/gitleaks/bad cwd.child("gitleaks_bad_01.txt") .write_str(indoc::indoc! {r" aws_access_key_id = AROA47DSWDEZA3RQASWB aws_secret_access_key = wQwdsZDiWg4UA5ngO0OSI2TkM4kkYxF6d2S1aYWM "})?; // Use fully qualified image name for Podman/Docker compatibility Command::new("docker") .args(["pull", "docker.io/zricethezav/gitleaks:v8.21.2"]) .assert() .success(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: gitleaks-docker name: Detect hardcoded secrets language: docker_image entry: docker.io/zricethezav/gitleaks:v8.21.2 git --pre-commit --redact --staged --verbose pass_filenames: false "}); context.git_add("."); let filters = context .filters() .into_iter() .chain([(r"\d\d?:\d\d(AM|PM)", "[TIME]")]) .collect::>(); cmd_snapshot!(filters, context.run(), @r#" success: false exit_code: 1 ----- stdout ----- Detect hardcoded secrets.................................................Failed - hook id: gitleaks-docker - exit code: 1 Finding: aws_access_key_id = REDACTED Secret: REDACTED RuleID: generic-api-key Entropy: 3.521928 File: gitleaks_bad_01.txt Line: 1 Fingerprint: gitleaks_bad_01.txt:generic-api-key:1 Finding: aws_secret_access_key = REDACTED Secret: REDACTED RuleID: generic-api-key Entropy: 4.703056 File: gitleaks_bad_01.txt Line: 2 Fingerprint: gitleaks_bad_01.txt:generic-api-key:2 ○ │╲ │ ○ ○ ░ ░ gitleaks [TIME] INF 1 commits scanned. [TIME] INF scan completed in [TIME] [TIME] WRN leaks found: 2 ----- stderr ----- "#); Ok(()) } /// Test that `docker_image` does not try to resolve entry in the host system PATH. #[test] fn docker_image_does_not_resolve_entry() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); let bin_dir = cwd.child("bin"); bin_dir.create_dir_all()?; let alpine_stub = bin_dir.child("alpine"); alpine_stub.write_str("#!/bin/sh\necho host\n")?; let mut perms = std::fs::metadata(alpine_stub.path())?.permissions(); perms.set_mode(0o755); std::fs::set_permissions(alpine_stub.path(), perms)?; Command::new("docker") .args(["pull", "docker.io/library/alpine:latest"]) .assert() .success(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: alpine-echo name: Alpine echo language: docker_image entry: alpine /bin/sh -c 'echo ok' pass_filenames: false always_run: true verbose: true "}); context.git_add("."); let mut cmd = context.run(); cmd.env(EnvVars::PATH, prepend_paths(&[bin_dir.path()])?); cmd_snapshot!(context.filters(), cmd, @r" success: true exit_code: 0 ----- stdout ----- Alpine echo..............................................................Passed - hook id: alpine-echo - duration: [TIME] ok ----- stderr ----- "); Ok(()) } ================================================ FILE: crates/prek/tests/languages/fail.rs ================================================ use anyhow::Result; use assert_fs::prelude::*; use crate::common::{TestContext, cmd_snapshot}; /// GitHub Action only has docker for linux hosted runners. #[test] fn fail() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("changelog").create_dir_all()?; cwd.child("changelog/changelog.md").touch()?; context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: changelogs-rst name: changelogs must be rst entry: changelog filenames must end in .rst language: fail files: 'changelog/.*(? anyhow::Result<()> { if !EnvVars::is_set(EnvVars::CI) { // Skip when not running in CI, as we may have other go versions installed locally. return Ok(()); } let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: golang name: golang language: golang entry: go version language_version: '1.24' pass_filenames: false always_run: true - id: golang name: golang language: golang entry: go version language_version: go1.24 always_run: true pass_filenames: false - id: golang name: golang language: golang entry: go version language_version: '1.23' # will auto download always_run: true pass_filenames: false - id: golang name: golang language: golang entry: go version language_version: go1.23 always_run: true pass_filenames: false - id: golang name: golang language: golang entry: go version language_version: go1.23 always_run: true pass_filenames: false - id: golang name: golang language: golang entry: go version language_version: '<1.25' always_run: true pass_filenames: false "}); context.git_add("."); let go_dir = context.home_dir().child("tools").child("go"); go_dir.assert(predicates::path::missing()); let filters = [( r"go version (go1\.\d{1,2})\.\d{1,2} ([\w]+/[\w]+)", "go version $1.X [OS]/[ARCH]", )] .into_iter() .chain(context.filters()) .collect::>(); cmd_snapshot!(filters, context.run().arg("-v"), @r#" success: true exit_code: 0 ----- stdout ----- golang...................................................................Passed - hook id: golang - duration: [TIME] go version go1.24.X [OS]/[ARCH] golang...................................................................Passed - hook id: golang - duration: [TIME] go version go1.24.X [OS]/[ARCH] golang...................................................................Passed - hook id: golang - duration: [TIME] go version go1.23.X [OS]/[ARCH] golang...................................................................Passed - hook id: golang - duration: [TIME] go version go1.23.X [OS]/[ARCH] golang...................................................................Passed - hook id: golang - duration: [TIME] go version go1.23.X [OS]/[ARCH] golang...................................................................Passed - hook id: golang - duration: [TIME] go version go1.24.X [OS]/[ARCH] ----- stderr ----- "#); // Check that only go 1.23 is installed. let installed_versions = go_dir .read_dir()? .flatten() .filter_map(|d| { let filename = d.file_name().to_string_lossy().to_string(); if filename.starts_with('.') { None } else { Some(filename) } }) .collect::>(); assert_eq!( installed_versions.len(), 1, "Expected only one Go version to be installed, but found: {installed_versions:?}" ); assert!( installed_versions.iter().any(|v| v.contains("1.23")), "Expected Go 1.23 to be installed, but found: {installed_versions:?}" ); Ok(()) } /// Test a remote go hook. #[test] fn remote_hook() { let context = TestContext::new(); context.init_project(); // Run hooks with system found go. context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/prek-test-repos/golang-hooks rev: v1.0 hooks: - id: echo verbose: true "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- echo.....................................................................Passed - hook id: echo - duration: [TIME] .pre-commit-config.yaml ----- stderr ----- "); // Test that `additional_dependencies` are installed correctly. context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: golang name: golang language: golang entry: gofumpt -h additional_dependencies: ["mvdan.cc/gofumpt@v0.8.0"] always_run: true verbose: true language_version: '1.23.11' # will auto download pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: true exit_code: 0 ----- stdout ----- golang...................................................................Passed - hook id: golang - duration: [TIME] usage: gofumpt [flags] [path ...] -version show version and exit -d display diffs instead of rewriting files -e report all errors (not just the first 10 on different lines) -l list files whose formatting differs from gofumpt's -w write result to (source) file instead of stdout -extra enable extra rules which should be vetted by a human -lang str target Go version in the form "go1.X" (default from go.mod) -modpath str Go module path containing the source file (default from go.mod) ----- stderr ----- "#); // Run hooks with newly downloaded go. context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/prek-test-repos/golang-hooks rev: v1.0 hooks: - id: echo verbose: true language_version: '1.23.11' # will auto download "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- echo.....................................................................Passed - hook id: echo - duration: [TIME] .pre-commit-config.yaml ----- stderr ----- "); } /// Fix #[test] fn local_additional_deps() -> anyhow::Result<()> { let go_hook = TestContext::new(); go_hook.init_project(); // Create a local go hook with additional_dependencies. go_hook .work_dir() .child("go.mod") .write_str(indoc::indoc! {r" module example.com/go-hook "})?; go_hook .work_dir() .child("main.go") .write_str(indoc::indoc! {r#" package main func main() { println("Hello, World!") } "#})?; go_hook.work_dir().child("cmd").create_dir_all()?; go_hook .work_dir() .child("cmd/main.go") .write_str(indoc::indoc! {r#" package main func main() { println("Hello, Utility!") } "#})?; go_hook .work_dir() .child(PRE_COMMIT_HOOKS_YAML) .write_str(indoc::indoc! {r" - id: go-hook name: go-hook entry: cmd language: golang additional_dependencies: [ ./cmd ] "})?; go_hook.git_add("."); go_hook.git_commit("Initial commit"); git_cmd(go_hook.work_dir()) .args(["tag", "v1.0", "-m", "v1.0"]) .output()?; let context = TestContext::new(); context.init_project(); let work_dir = context.work_dir(); let hook_url = go_hook.work_dir().to_str().unwrap(); work_dir .child(PRE_COMMIT_CONFIG_YAML) .write_str(&indoc::formatdoc! {r" repos: - repo: {hook_url} rev: v1.0 hooks: - id: go-hook verbose: true ", hook_url = hook_url})?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- go-hook..................................................................Passed - hook id: go-hook - duration: [TIME] Hello, Utility! ----- stderr ----- "); Ok(()) } /// Ensure `go.mod` metadata (go/toolchain directives) is used to constrain /// the Go version for remote hooks. #[test] fn remote_go_mod_metadata_sets_language_version() -> anyhow::Result<()> { // Create a remote repo containing a golang hook. let go_hook = TestContext::new(); go_hook.init_project(); go_hook .work_dir() .child("go.mod") .write_str(indoc::indoc! {r" module example.com/go-hook go 2.100 // unrealistic version to ensure the downloading fails "})?; go_hook .work_dir() .child(PRE_COMMIT_HOOKS_YAML) .write_str(indoc::indoc! {r" - id: echo name: echo entry: echo language: golang verbose: true "})?; go_hook.git_add("."); go_hook.git_commit("Initial commit"); git_cmd(go_hook.work_dir()) .args(["tag", "v1.0", "-m", "v1.0"]) .output()?; // Use it as a remote repo in a separate project. let context = TestContext::new(); context.init_project(); let hook_url = go_hook.work_dir().to_str().unwrap(); context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {hook_url} rev: v1.0 hooks: - id: echo verbose: true ", hook_url = hook_url}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to install hook `echo` caused by: Failed to install go caused by: Failed to resolve go version `>= 2.100.0` caused by: Version `>= 2.100.0` not found on remote "); Ok(()) } ================================================ FILE: crates/prek/tests/languages/haskell.rs ================================================ use assert_fs::fixture::{FileWriteStr, PathChild}; use prek_consts::env_vars::EnvVars; use crate::common::{TestContext, cmd_snapshot}; #[test] fn local_hook() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: hello name: hello language: haskell entry: hello always_run: true verbose: true pass_filenames: false "}); context .work_dir() .child("hello.cabal") .write_str(indoc::indoc! {r" cabal-version: 3.0 name: hello version: 0.1.0.0 build-type: Simple executable hello main-is: Main.hs default-language: GHC2021 build-depends: base >= 4.19 && < 5 "})?; context .work_dir() .child("Main.hs") .write_str(indoc::indoc! {r#" module Main where main :: IO () main = putStrLn "Hello Haskell!" "#})?; context.git_add("."); cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_INTERNAL__SKIP_CABAL_UPDATE, "1"), @" success: true exit_code: 0 ----- stdout ----- hello....................................................................Passed - hook id: hello - duration: [TIME] Hello Haskell! ----- stderr ----- "); // Run again to check `health_check` works correctly. cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_INTERNAL__SKIP_CABAL_UPDATE, "1"), @" success: true exit_code: 0 ----- stdout ----- hello....................................................................Passed - hook id: hello - duration: [TIME] Hello Haskell! ----- stderr ----- "); Ok(()) } #[test] fn additional_dependencies() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: hello name: hello language: haskell entry: hello additional_dependencies: ["hello"] always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters, context.run().env(EnvVars::PREK_INTERNAL__SKIP_CABAL_UPDATE, "1"), @" success: true exit_code: 0 ----- stdout ----- hello....................................................................Passed - hook id: hello - duration: [TIME] Hello, World! ----- stderr ----- "); } #[test] fn remote_hook() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/prek-test-repos/haskell-hooks rev: v1.0.0 hooks: - id: hello always_run: true verbose: true "}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters, context.run().env(EnvVars::PREK_INTERNAL__SKIP_CABAL_UPDATE, "1"), @" success: true exit_code: 0 ----- stdout ----- hello....................................................................Passed - hook id: hello - duration: [TIME] This is a remote haskell hook ----- stderr ----- "); } ================================================ FILE: crates/prek/tests/languages/julia.rs ================================================ use crate::common::{TestContext, cmd_snapshot}; #[test] fn local_hook() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: julia-test name: julia-test language: julia entry: -e 'println("Hello from Julia!")' always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- julia-test...............................................................Passed - hook id: julia-test - duration: [TIME] Hello from Julia! ----- stderr ----- "); // Run again to check `health_check` works correctly. cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- julia-test...............................................................Passed - hook id: julia-test - duration: [TIME] Hello from Julia! ----- stderr ----- "); } #[test] fn additional_dependencies() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: julia-deps name: julia-deps language: julia entry: -e 'using JSON; println("JSON module loaded")' additional_dependencies: ["JSON"] always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- julia-deps...............................................................Passed - hook id: julia-deps - duration: [TIME] JSON module loaded ----- stderr ----- "); } #[test] fn project_toml() -> anyhow::Result<()> { use assert_fs::fixture::{FileWriteStr, PathChild}; let context = TestContext::new(); context.init_project(); context .work_dir() .child("Project.toml") .write_str(indoc::indoc! {r#" [deps] Example = "7876af07-990d-54b4-ab0e-23690620f79a" "#})?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: julia-project name: julia-project language: julia entry: -e 'using Example; println("Example module loaded")' always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- julia-project............................................................Passed - hook id: julia-project - duration: [TIME] Example module loaded ----- stderr ----- "); Ok(()) } #[test] fn script_file() -> anyhow::Result<()> { use assert_fs::fixture::{FileWriteStr, PathChild}; let context = TestContext::new(); context.init_project(); context .work_dir() .child("my_script.jl") .write_str(r#"println("Hello from script file!")"#)?; context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: julia-script name: julia-script language: julia entry: my_script.jl always_run: true verbose: true pass_filenames: false "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- julia-script.............................................................Passed - hook id: julia-script - duration: [TIME] Hello from script file! ----- stderr ----- "); Ok(()) } #[test] fn remote_hook() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/prek-test-repos/julia-hooks rev: v1.0.0 hooks: - id: hello always_run: true verbose: true "}); context.git_add("."); let filters = context.filters(); cmd_snapshot!(filters, context.run(), @" success: true exit_code: 0 ----- stdout ----- hello....................................................................Passed - hook id: hello - duration: [TIME] This is a remote julia hook Args: hello ----- stderr ----- "); } ================================================ FILE: crates/prek/tests/languages/lua.rs ================================================ use assert_fs::fixture::{FileWriteStr, PathChild}; use crate::common::{TestContext, cmd_snapshot}; #[test] fn health_check() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: lua name: lua language: lua entry: lua -e 'print("Hello from Lua!")' always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- lua......................................................................Passed - hook id: lua - duration: [TIME] Hello from Lua! ----- stderr ----- "); // Run again to check `health_check` works correctly. cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- lua......................................................................Passed - hook id: lua - duration: [TIME] Hello from Lua! ----- stderr ----- "); } /// Test specifying `language_version` for Lua hooks which is not supported for now. #[test] fn language_version() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: local name: local language: lua entry: lua -v language_version: '5.4' always_run: true verbose: true pass_filenames: false "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to init hooks caused by: Invalid hook `local` caused by: Hook specified `language_version: 5.4` but the language `lua` does not support toolchain installation for now "); } /// Test that stderr from hooks is captured and shown to the user. #[test] fn hook_stderr() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: local name: local language: lua entry: lua ./hook.lua "}); context .work_dir() .child("hook.lua") .write_str("io.stderr:write('How are you\\n'); os.exit(1)")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- local....................................................................Failed - hook id: local - exit code: 1 How are you ----- stderr ----- "); Ok(()) } /// Test Lua script execution with file arguments. #[test] fn script_with_files() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: lua name: lua language: lua entry: lua ./script.lua verbose: true "}); context .work_dir() .child("script.lua") .write_str(indoc::indoc! {r#" for i, arg in ipairs(arg) do print("Processing file:", arg) end "#})?; context .work_dir() .child("test1.lua") .write_str("print('test1')")?; context .work_dir() .child("test2.lua") .write_str("print('test2')")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- lua......................................................................Passed - hook id: lua - duration: [TIME] Processing file: script.lua Processing file: .pre-commit-config.yaml Processing file: test2.lua Processing file: test1.lua ----- stderr ----- "); Ok(()) } /// Test Lua environment variables (`LUA_PATH` and `LUA_CPATH`) #[test] fn lua_environment() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: lua name: lua language: lua entry: lua -e 'print("LUA_PATH:", os.getenv("LUA_PATH")); print("LUA_CPATH:", os.getenv("LUA_CPATH"))' always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); let filters = context .filters() .into_iter() .chain([(r"lua-[A-Za-z0-9]+", "lua-[HASH]")]) .collect::>(); #[cfg(not(target_os = "windows"))] cmd_snapshot!(filters, context.run(), @r" success: true exit_code: 0 ----- stdout ----- lua......................................................................Passed - hook id: lua - duration: [TIME] LUA_PATH: [HOME]/hooks/lua-[HASH]/share/lua/5.4/?.lua;[HOME]/hooks/lua-[HASH]/share/lua/5.4/?/init.lua;; LUA_CPATH: [HOME]/hooks/lua-[HASH]/lib/lua/5.4/?.so;; ----- stderr ----- "); #[cfg(target_os = "windows")] cmd_snapshot!(filters, context.run(), @r#" success: true exit_code: 0 ----- stdout ----- lua......................................................................Passed - hook id: lua - duration: [TIME] LUA_PATH: [HOME]/hooks/lua-[HASH]/share/lua/5.4\?.lua;[HOME]/hooks/lua-[HASH]/share/lua/5.4\?/init.lua;; LUA_CPATH: [HOME]/hooks/lua-[HASH]/lib/lua/5.4\?.dll;; ----- stderr ----- "#); } /// Test Lua hook with additional dependencies. #[test] fn additional_dependencies() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: lua name: lua language: lua entry: lua -e 'require("lfs"); print("LuaFileSystem module loaded successfully")' additional_dependencies: ["luafilesystem"] always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- lua......................................................................Passed - hook id: lua - duration: [TIME] LuaFileSystem module loaded successfully ----- stderr ----- "); } /// Test remote Lua hook from GitHub repository. #[test] fn remote_hook() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/prek-test-repos/lua-hooks rev: v1.0.0 hooks: - id: lua-hooks always_run: true verbose: true "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- lua-hooks................................................................Passed - hook id: lua-hooks - duration: [TIME] this is a lua remote hook ----- stderr ----- "); } ================================================ FILE: crates/prek/tests/languages/main.rs ================================================ #[path = "../common/mod.rs"] mod common; mod bun; mod deno; #[cfg(all(feature = "docker", target_os = "linux"))] mod docker; #[cfg(all(feature = "docker", target_os = "linux"))] mod docker_image; mod fail; mod golang; mod haskell; mod julia; mod lua; mod node; mod pygrep; mod python; mod ruby; mod rust; mod script; mod swift; mod unimplemented; mod unsupported; ================================================ FILE: crates/prek/tests/languages/node.rs ================================================ use assert_fs::assert::PathAssert; use assert_fs::fixture::PathChild; use prek_consts::env_vars::EnvVars; use crate::common::{TestContext, cmd_snapshot, remove_bin_from_path}; /// Test `language_version` parsing and auto downloading works correctly. /// We use `setup-node` action to install node 20 in CI, so node 19 should be downloaded by prek. #[test] fn language_version() -> anyhow::Result<()> { if !EnvVars::is_set(EnvVars::CI) { // Skip when not running in CI, as we may have other node versions installed locally. return Ok(()); } let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: node name: node language: node entry: node -p 'process.version' language_version: '20' always_run: true - id: node name: node language: node entry: node -p 'process.version' language_version: node20 always_run: true - id: node name: node language: node entry: node -p 'process.version' language_version: '19' # will auto download always_run: true - id: node name: node language: node entry: node -p 'process.version' language_version: node19 always_run: true - id: node name: node language: node entry: node -p 'process.version' language_version: '<20' always_run: true - id: node name: node language: node entry: node -p 'process.version' language_version: 'lts/iron' # node 20 always_run: true "}); context.git_add("."); let node_dir = context.home_dir().child("tools").child("node"); node_dir.assert(predicates::path::missing()); let filters = context .filters() .into_iter() .chain([(r"v(\d+)\.\d+.\d+", "v$1.X.X")]) .collect::>(); cmd_snapshot!(filters, context.run().arg("-v"), @r#" success: true exit_code: 0 ----- stdout ----- node.....................................................................Passed - hook id: node - duration: [TIME] v20.X.X node.....................................................................Passed - hook id: node - duration: [TIME] v20.X.X node.....................................................................Passed - hook id: node - duration: [TIME] v19.X.X node.....................................................................Passed - hook id: node - duration: [TIME] v19.X.X node.....................................................................Passed - hook id: node - duration: [TIME] v19.X.X node.....................................................................Passed - hook id: node - duration: [TIME] v20.X.X ----- stderr ----- "#); // Check that only node 19 is installed. let installed_versions = node_dir .read_dir()? .flatten() .filter_map(|d| { let filename = d.file_name().to_string_lossy().to_string(); if filename.starts_with('.') { None } else { Some(filename) } }) .collect::>(); assert_eq!( installed_versions.len(), 1, "Expected only one node version to be installed, but found: {installed_versions:?}" ); assert!( installed_versions.iter().any(|v| v.starts_with("19")), "Expected node v19 to be installed, but found: {installed_versions:?}" ); Ok(()) } /// Test that `additional_dependencies` are installed correctly. #[test] fn additional_dependencies() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: node name: node language: node entry: cowsay Hello World! additional_dependencies: ["cowsay"] always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- node.....................................................................Passed - hook id: node - duration: [TIME] ______________ < Hello World! > -------------- \ ^__^ \ (oo)/_______ (__)\ )\/\ ||----w | || || ----- stderr ----- "); // Run again to check `health_check` works correctly. cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- node.....................................................................Passed - hook id: node - duration: [TIME] ______________ < Hello World! > -------------- \ ^__^ \ (oo)/_______ (__)\ )\/\ ||----w | || || ----- stderr ----- "); } /// Test that npm install works without system node in PATH. /// Regression test for #1492: `install()` must use the provisioned toolchain. #[test] fn additional_dependencies_without_system_node() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: node name: node language: node entry: cowsay Hello additional_dependencies: ["cowsay"] always_run: true pass_filenames: false "#}); context.git_add("."); let new_path = remove_bin_from_path("node", None)?; cmd_snapshot!(context.filters(), context.run().env("PATH", new_path), @r" success: true exit_code: 0 ----- stdout ----- node.....................................................................Passed ----- stderr ----- "); Ok(()) } /// Test that `npm.cmd` can be found on Windows. #[test] fn npm_version() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: npm-version name: npm-version language: system entry: npm --version always_run: true pass_filenames: false verbose: true "}); context.git_add("."); let filters = context .filters() .into_iter() .chain([(r"\d+\.\d+\.\d+", "[NPM_VERSION]")]) .collect::>(); cmd_snapshot!(filters, context.run(), @r" success: true exit_code: 0 ----- stdout ----- npm-version..............................................................Passed - hook id: npm-version - duration: [TIME] [NPM_VERSION] ----- stderr ----- "); } ================================================ FILE: crates/prek/tests/languages/pygrep.rs ================================================ use anyhow::Result; use assert_fs::prelude::*; use crate::common::{TestContext, cmd_snapshot}; /// Test basic pygrep functionality - case-sensitive matching #[test] fn basic_case_sensitive() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("test.py") .write_str("TODO: implement this\nprint('Hello World')\n# todo: fix later")?; cwd.child("other.py") .write_str("print('No issues here')\n")?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: check-todo name: check-todo language: pygrep entry: "TODO" files: "\\.py$" "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check-todo...............................................................Failed - hook id: check-todo - exit code: 1 test.py:1:TODO: implement this ----- stderr ----- "); // Run again to ensure `health_check` works correctly. cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check-todo...............................................................Failed - hook id: check-todo - exit code: 1 test.py:1:TODO: implement this ----- stderr ----- "); Ok(()) } /// Test case-insensitive matching #[test] fn case_insensitive() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("test.py") .write_str("TODO: implement this\nprint('Hello World')\n# todo: fix later")?; cwd.child("other.py") .write_str("print('No issues here')\n")?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: check-todo-insensitive name: check-todo-insensitive language: pygrep entry: "TODO" args: ["--ignore-case"] files: "\\.py$" "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check-todo-insensitive...................................................Failed - hook id: check-todo-insensitive - exit code: 1 test.py:1:TODO: implement this test.py:3:# todo: fix later ----- stderr ----- "); Ok(()) } /// Test multiline mode #[test] fn multiline_mode() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("test.py").write_str( "def function():\n \"\"\"A function\n with multiline docstring\n \"\"\"\n pass", )?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: check-multiline-docstring name: check-multiline-docstring language: pygrep entry: '""".*\n.*docstring.*\n.*"""' args: ["--multiline"] files: "\\.py$" "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: false exit_code: 1 ----- stdout ----- check-multiline-docstring................................................Failed - hook id: check-multiline-docstring - exit code: 1 test.py:2: """A function with multiline docstring """ ----- stderr ----- "#); Ok(()) } /// Test negate mode - passes when pattern is NOT found #[test] fn negate_mode() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("good.py").write_str("print('Hello World')\n")?; cwd.child("bad.py") .write_str("TODO: implement this\nprint('Hello World')\n")?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: no-todo name: no-todo language: pygrep entry: "TODO" args: ["--negate"] files: "\\.py$" "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- no-todo..................................................................Failed - hook id: no-todo - exit code: 1 good.py ----- stderr ----- "); Ok(()) } /// Test negate mode with multiline - should output filename if pattern not found #[test] fn negate_multiline_mode() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("no_pattern.py") .write_str("print('Hello World')\n")?; cwd.child("has_pattern.py").write_str( "def function():\n \"\"\"A function\n with multiline docstring\n \"\"\"\n pass", )?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: check-no-multiline-docstring name: check-no-multiline-docstring language: pygrep entry: '""".*\n.*docstring.*\n.*"""' args: ["--multiline", "--negate"] files: "\\.py$" "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check-no-multiline-docstring.............................................Failed - hook id: check-no-multiline-docstring - exit code: 1 no_pattern.py ----- stderr ----- "); Ok(()) } /// Test invalid regex pattern #[test] fn invalid_regex() { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("test.py") .write_str("print('Hello World')\n") .unwrap(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: invalid-regex name: invalid-regex language: pygrep entry: "[unclosed" files: "\\.py$" "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to run hook `invalid-regex` caused by: Failed to parse regex: unterminated character set at position 0 "); } #[test] fn python_regex_quirks() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("test.py") .write_str("def function(arg1, arg2):\n pass\ndef bad_function():\n pass")?; // Test lookbehind assertion - function with arguments context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: function-with-args name: function-with-args language: pygrep entry: "def\\s+\\w+\\([^)]*\\w[^)]*\\):" files: "\\.py$" "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- function-with-args.......................................................Failed - hook id: function-with-args - exit code: 1 test.py:1:def function(arg1, arg2): ----- stderr ----- "); Ok(()) } /// Test complex regex with word boundaries and character classes #[test] fn complex_regex_patterns() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("test.py") .write_str("import sys\nfrom os import path\nimport json\nfrom typing import Dict")?; // Match import statements but not 'from' imports context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: direct-imports name: direct-imports language: pygrep entry: "^import\\s+[a-zA-Z_][a-zA-Z0-9_]*$" files: "\\.py$" "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- direct-imports...........................................................Failed - hook id: direct-imports - exit code: 1 test.py:1:import sys test.py:3:import json ----- stderr ----- "); Ok(()) } /// Test combination of case insensitive and multiline #[test] fn case_insensitive_multiline() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("test.py") .write_str("# TODO: fix this\ndef function():\n # todo: implement\n pass")?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: check-todos name: check-todos language: pygrep entry: "todo.*\n.*implement" args: ["--ignore-case", "--multiline"] files: "\\.py$" "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- check-todos..............................................................Failed - hook id: check-todos - exit code: 1 test.py:1:# TODO: fix this def function(): # todo: implement ----- stderr ----- "); Ok(()) } /// Test successful case where pattern is not found #[test] fn pattern_not_found() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("test.py") .write_str("print('Hello World')\n# All good here")?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: check-todo name: check-todo language: pygrep entry: "TODO" files: "\\.py$" "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: true exit_code: 0 ----- stdout ----- check-todo...............................................................Passed ----- stderr ----- "#); Ok(()) } #[test] fn invalid_args() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("test.py") .write_str("print('Hello World')\n# All good here")?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: check-todo name: check-todo language: pygrep entry: "TODO" args: ["--hello"] files: "\\.py$" "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to run hook `check-todo` caused by: Failed to parse `args` caused by: Unknown argument: --hello "); Ok(()) } ================================================ FILE: crates/prek/tests/languages/python.rs ================================================ use assert_fs::assert::PathAssert; use assert_fs::fixture::{FileWriteStr, PathChild}; use prek_consts::PRE_COMMIT_HOOKS_YAML; use prek_consts::env_vars::EnvVars; use crate::common::{TestContext, cmd_snapshot}; /// Test `language_version` parsing and downloading. /// We use `setup-python` action to install Python 3.12 in CI, when running tests uv can find them. /// Other versions may need to be downloaded while running the tests. #[test] fn language_version() -> anyhow::Result<()> { if !EnvVars::is_set(EnvVars::CI) { // Skip when not running in CI, as we may have other Python versions installed locally. return Ok(()); } let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: python3 name: python3 language: python entry: python -c 'print("Hello, World!")' language_version: python3 always_run: true - id: python3.12 name: python3.12 language: python entry: python -c 'import sys; print(sys.version_info[:2])' language_version: python3.12 always_run: true - id: python3.12 name: python3.12 language: python entry: python -c 'import sys; print(sys.version_info[:2])' language_version: '3.12' always_run: true - id: python3.12 name: python3.12 language: python entry: python -c 'import sys; print(sys.version_info[:2])' language_version: 'python312' - id: python3.12 name: python3.12 language: python entry: python -c 'import sys; print(sys.version_info[:2])' language_version: '312' always_run: true - id: python3.12 name: python3.12 language: python entry: python -c 'import sys; print(sys.version_info[:2])' language_version: python3.12 always_run: true - id: python3.12 name: python3.12 language: python entry: python -c 'import sys; print(sys.version_info[:2])' language_version: '3.11.1' # will auto download always_run: true "#}); context.git_add("."); let python_dir = context.home_dir().child("tools").child("python"); python_dir.assert(predicates::path::missing()); cmd_snapshot!(context.filters(), context.run().arg("-v"), @r#" success: true exit_code: 0 ----- stdout ----- python3..................................................................Passed - hook id: python3 - duration: [TIME] Hello, World! python3.12...............................................................Passed - hook id: python3.12 - duration: [TIME] (3, 12) python3.12...............................................................Passed - hook id: python3.12 - duration: [TIME] (3, 12) python3.12...............................................................Passed - hook id: python3.12 - duration: [TIME] (3, 12) python3.12...............................................................Passed - hook id: python3.12 - duration: [TIME] (3, 12) python3.12...............................................................Passed - hook id: python3.12 - duration: [TIME] (3, 12) python3.12...............................................................Passed - hook id: python3.12 - duration: [TIME] (3, 11) ----- stderr ----- "#); // Check that only Python 3.11 is installed. let installed_versions = python_dir .read_dir()? .flatten() .filter_map(|d| { if d.file_type().ok()?.is_symlink() { // Skip symlinks, which may point to other versions. return None; } let filename = d.file_name().to_string_lossy().to_string(); if filename.starts_with('.') { None } else { Some(filename) } }) .collect::>(); assert_eq!( installed_versions.len(), 1, "Expected only one Python version to be installed, but found: {installed_versions:?}" ); assert!( installed_versions.iter().any(|v| v.contains("3.11")), "Expected Python 3.11 to be installed, but found: {installed_versions:?}" ); Ok(()) } #[test] fn invalid_version() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: local name: local language: python entry: python -c 'print("Hello, world!")' language_version: 'invalid-version' # invalid version always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to init hooks caused by: Invalid hook `local` caused by: Invalid `language_version` value: `invalid-version` "); } /// Request a version that neither can be found nor downloaded. #[test] fn can_not_download() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: less-than-3.6 name: less-than-3.6 language: python entry: python -c 'import sys; print(sys.version_info[:3])' language_version: '<=3.6' # not supported version always_run: true "}); context.git_add("."); let mut filters = context .filters() .into_iter() .chain([( "managed installations, search path, or registry", "managed installations or search path", )]) .collect::>(); if cfg!(windows) { // Unix uses "exit status", Windows uses "exit code" filters.push((r"exit code: ", "exit status: ")); } cmd_snapshot!(filters, context.run().arg("-v"), @r#" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to install hook `less-than-3.6` caused by: Failed to create Python virtual environment caused by: Command `create venv` exited with an error: [status] exit status: 2 [stderr] error: No interpreter found for Python <=3.6 in managed installations or search path "#); } /// Test that `additional_dependencies` are installed correctly. #[test] fn additional_dependencies() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: local name: local language: python language_version: '3.11' # will auto download entry: pyecho Hello, world! additional_dependencies: ["pyecho-cli"] always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- local....................................................................Passed - hook id: local - duration: [TIME] Hello, world! ----- stderr ----- "); } #[test] fn additional_dependencies_in_remote_repo() -> anyhow::Result<()> { // Create a remote repo with a python hook that has additional dependencies. let repo = TestContext::new(); repo.init_project(); let repo_path = repo.work_dir(); repo_path .child(PRE_COMMIT_HOOKS_YAML) .write_str(indoc::indoc! {r#" - id: hello name: hello language: python entry: pyecho Greetings from hook additional_dependencies: [".[cli]"] "#})?; repo_path.child("module.py").write_str(indoc::indoc! {r#" def greet(): print("Greetings from module") "#})?; repo_path.child("setup.py").write_str(indoc::indoc! {r#" from setuptools import setup, find_packages setup( name="remote-hooks", version="0.1.0", py_modules=["module"], extras_require={ "cli": ["pyecho-cli"] } ) "#})?; repo.git_add("."); repo.git_commit("Add manifest"); repo.git_tag("v0.1.0"); let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: v0.1.0 hooks: - id: hello name: hello verbose: true ", repo_path.display()}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- hello....................................................................Passed - hook id: hello - duration: [TIME] Greetings from hook .pre-commit-config.yaml ----- stderr ----- "); Ok(()) } /// Ensure that stderr from hooks is captured and shown to the user. #[test] fn hook_stderr() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: local name: local language: python entry: python ./hook.py "}); context .work_dir() .child("hook.py") .write_str("import sys; print('How are you', file=sys.stderr); sys.exit(1)")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- local....................................................................Failed - hook id: local - exit code: 1 How are you ----- stderr ----- "); Ok(()) } /// Test that pep723 script for local hook is installed correctly. /// Only if no additional dependencies are specified. #[test] fn pep723_script() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: other-hook name: other-hook language: python entry: python -c 'print("hello from other-hook")' verbose: true pass_filenames: false - id: local name: local language: python entry: ./script.py hello world verbose: true pass_filenames: false "#}); // On Windows, uv venv does not create `python3.exe`, `python3.12.exe` symlink, // be sure to use `python` as the interpreter name. context .work_dir() .child("script.py") .write_str(indoc::indoc! {r#" #!/usr/bin/env python # /// script # requires-python = ">=3.10" # dependencies = [ "pyecho-cli" ] # /// from pyecho import main main() "#})?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- other-hook...............................................................Passed - hook id: other-hook - duration: [TIME] hello from other-hook local....................................................................Passed - hook id: local - duration: [TIME] hello world ----- stderr ----- "); Ok(()) } /// Test that GIT environment variables do not leak into uv pip install subprocess. /// When prek runs in a git worktree, git sets `GIT_DIR` which should not propagate to /// pip install where it breaks packages using `setuptools_scm` for file discovery. /// /// Regression test for #[test] fn git_env_vars_not_leaked_to_pip_install() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // setup.py that fails if GIT_DIR leaks into pip install context .work_dir() .child("setup.py") .write_str(indoc::indoc! {r#" import os, sys from setuptools import setup if os.environ.get("GIT_DIR"): sys.exit("ERROR: GIT_DIR should not leak into pip install") setup(name="test", version="0.1.0", extras_require={"test": []}) "#})?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: check-no-git-dir name: check-no-git-dir language: python entry: python -c "print('ok')" additional_dependencies: [".[test]"] always_run: true "#}); context.git_add("."); // Simulate worktree environment by setting GIT_DIR (like git does in worktrees) cmd_snapshot!(context.filters(), context.run() .env("GIT_DIR", context.work_dir().join(".git")), @r" success: true exit_code: 0 ----- stdout ----- check-no-git-dir.........................................................Passed ----- stderr ----- "); Ok(()) } /// Test that health check passes when Python toolchain path involves symlinks. /// The stored toolchain path and the queried path should be canonicalized before comparison. /// /// Regression test for symlink-related "Python executable mismatch" errors. #[test] #[cfg(unix)] fn health_check_with_symlinked_toolchain() -> anyhow::Result<()> { use prek_consts::prepend_paths; use std::os::unix::fs::symlink; let context = TestContext::new(); context.init_project(); // Find a Python executable, create a symlinked directory to its parent, // and prepend that to PATH so that prek picks up the symlinked path. let python_executable = which::which("python3")?; let symlinked_bin = context.work_dir().child("symlinked-bin"); symlink(python_executable.parent().unwrap(), &symlinked_bin)?; let new_path = prepend_paths(&[&*symlinked_bin])?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: local name: local language: python entry: python -c 'print("hello")' always_run: true pass_filenames: false "#}); context.git_add("."); // First run installs the hook cmd_snapshot!(context.filters(), context.run().env(EnvVars::PATH, new_path), @" success: true exit_code: 0 ----- stdout ----- local....................................................................Passed ----- stderr ----- "); let hooks_dir = context.home_dir().child("hooks"); let hook_envs = hooks_dir .read_dir()? .flatten() .filter(|d| d.file_name().to_string_lossy().starts_with("python-")) .collect::>(); assert_eq!( hook_envs.len(), 1, "Expected one installed hook env, found: {hook_envs:?}", ); // Second run triggers health check with a symlinked toolchain path cmd_snapshot!(context.filters(), context.run(), @" success: true exit_code: 0 ----- stdout ----- local....................................................................Passed ----- stderr ----- "); let hook_envs = hooks_dir .read_dir()? .flatten() .filter(|d| d.file_name().to_string_lossy().starts_with("python-")) .collect::>(); assert_eq!( hook_envs.len(), 1, "Expected one installed hook env, found: {hook_envs:?}", ); Ok(()) } ================================================ FILE: crates/prek/tests/languages/ruby.rs ================================================ use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; use crate::common::{TestContext, cmd_snapshot, git_cmd}; /// Test basic Ruby hook with system Ruby #[test] fn system_ruby() { let context = TestContext::new(); context.init_project(); // Discover the actual system Ruby path let ruby_path = which::which("ruby") .expect("Ruby not found in PATH") .to_string_lossy() .to_string(); context.write_pre_commit_config(&format!( indoc::indoc! {r" repos: - repo: local hooks: - id: ruby-version name: ruby-version language: ruby entry: ruby --version language_version: system pass_filenames: false always_run: true - id: ruby-version-unspecified name: ruby-version-unspecified language: ruby entry: ruby --version pass_filenames: false always_run: true - id: ruby-version-path name: ruby-version-path language: ruby language_version: {} entry: ruby --version pass_filenames: false always_run: true "}, ruby_path )); context.git_add("."); let filters = [( r"ruby (\d+\.\d+)\.\d+(?:p\d+)? \(\d{4}-\d{2}-\d{2} revision [0-9a-f]{0,10}\).*?\[.+\]", "ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]", )] .into_iter() .chain(context.filters()) .collect::>(); cmd_snapshot!(filters, context.run().arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- ruby-version.............................................................Passed - hook id: ruby-version - duration: [TIME] ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM] ruby-version-unspecified.................................................Passed - hook id: ruby-version-unspecified - duration: [TIME] ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM] ruby-version-path........................................................Passed - hook id: ruby-version-path - duration: [TIME] ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM] ----- stderr ----- "); } /// Test that `language_version: default` works #[test] fn language_version_default() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: ruby-default name: ruby-default language: ruby entry: ruby --version language_version: default pass_filenames: false always_run: true "}); context.git_add("."); let filters = [( r"ruby (\d+\.\d+)\.\d+(?:p\d+)? \(\d{4}-\d{2}-\d{2} revision [0-9a-f]{0,10}\).*?\[.+\]", "ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]", )] .into_iter() .chain(context.filters()) .collect::>(); cmd_snapshot!(filters, context.run().arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- ruby-default.............................................................Passed - hook id: ruby-default - duration: [TIME] ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM] ----- stderr ----- "); } /// Test basic Ruby hook with a specified (and available) version of Ruby #[test] fn specific_ruby_available() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: ruby-version-prefixed name: ruby-version-prefixed language: ruby entry: ruby --version language_version: ruby3.4 pass_filenames: false always_run: true - id: ruby-version name: ruby-version language: ruby entry: ruby --version language_version: '3.4' pass_filenames: false always_run: true - id: ruby-version-range-min name: ruby-version-range-min language: ruby entry: ruby --version language_version: '>=3.2' pass_filenames: false always_run: true - id: ruby-version-range-max name: ruby-version-range-max language: ruby entry: ruby --version language_version: '<4.0' pass_filenames: false always_run: true - id: ruby-version-constrained-range name: ruby-version-constrained-range language: ruby entry: ruby --version language_version: '>=3.2, <4' pass_filenames: false always_run: true "}); context.git_add("."); let filters = [( r"ruby (\d+\.\d+)\.\d+(?:p\d+)? \(\d{4}-\d{2}-\d{2} revision [0-9a-f]{0,10}\).*?\[.+\]", "ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]", )] .into_iter() .chain(context.filters()) .collect::>(); cmd_snapshot!(filters, context.run().arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- ruby-version-prefixed....................................................Passed - hook id: ruby-version-prefixed - duration: [TIME] ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM] ruby-version.............................................................Passed - hook id: ruby-version - duration: [TIME] ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM] ruby-version-range-min...................................................Passed - hook id: ruby-version-range-min - duration: [TIME] ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM] ruby-version-range-max...................................................Passed - hook id: ruby-version-range-max - duration: [TIME] ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM] ruby-version-constrained-range...........................................Passed - hook id: ruby-version-constrained-range - duration: [TIME] ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM] ----- stderr ----- "); } /// Test basic Ruby hook with a specified (and unavailable) version of Ruby #[test] fn specific_ruby_unavailable() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: ruby-version name: ruby-version language: ruby entry: ruby --version language_version: 3.1.3 pass_filenames: false always_run: true "}); context.git_add("."); let filters = [( r"ruby (\d+\.\d+)\.\d+(?:p\d+)? \(\d{4}-\d{2}-\d{2} revision [0-9a-f]{0,10}\).*?\[.+\]", "ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]", )] .into_iter() .chain(context.filters()) .collect::>(); #[cfg(target_os = "windows")] cmd_snapshot!(filters, context.run().arg("-v"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to install hook `ruby-version` caused by: Failed to install Ruby caused by: No suitable Ruby found for request: 3.1.3 Automatic installation is not supported on this platform. Please install Ruby manually. "); #[cfg(not(target_os = "windows"))] cmd_snapshot!(filters, context.run().arg("-v"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to install hook `ruby-version` caused by: Failed to install Ruby caused by: No suitable Ruby found for request: 3.1.3 No rv-ruby release found matching: 3.1.3 Please install Ruby manually. "); } /// Test Ruby hook with `additional_dependencies` and `require` statement #[test] fn additional_gem_dependencies() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Create a Ruby script that uses a gem from additional_dependencies // Use 'rspec' - a gem that's NOT bundled with Ruby context .work_dir() .child("test_script.rb") .write_str(indoc::indoc! {r" require 'rspec' puts RSpec::Version::STRING "})?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: test-gem-require name: test-gem-require language: ruby entry: ruby test_script.rb language_version: system additional_dependencies: ["rspec"] pass_filenames: false always_run: true - id: test-gem-require-versioned name: test-gem-require-versioned language: ruby entry: ruby test_script.rb language_version: system additional_dependencies: ["rspec:3.12.0"] pass_filenames: false always_run: true - id: test-gem-require-missing name: test-gem-require-missing language: ruby entry: ruby test_script.rb language_version: system pass_filenames: false always_run: true "#}); context.git_add("."); let filters = [ // Normalize unpinned rspec version (only for test-gem-require, not test-gem-require-versioned) ( r"(- hook id: test-gem-require\n- duration: .*?\n\n) \d+\.\d+\.\d+", "$1 X.Y.Z", ), // Normalize Ruby internal paths (r"]+>:\d+:in", ":[X]:in"), ] .into_iter() .chain(context.filters()) .collect::>(); cmd_snapshot!(filters, context.run().arg("-v"), @r" success: false exit_code: 1 ----- stdout ----- test-gem-require.........................................................Passed - hook id: test-gem-require - duration: [TIME] X.Y.Z test-gem-require-versioned...............................................Passed - hook id: test-gem-require-versioned - duration: [TIME] 3.12.0 test-gem-require-missing.................................................Failed - hook id: test-gem-require-missing - duration: [TIME] - exit code: 1 :[X]:in 'Kernel#require': cannot load such file -- rspec (LoadError) from :[X]:in 'Kernel#require' from test_script.rb:1:in '
' ----- stderr ----- "); Ok(()) } /// Test Ruby hook with gemspec #[test] fn gemspec_workflow() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Create a simple gemspec context .work_dir() .child("test_gem.gemspec") .write_str(indoc::indoc! {r#" Gem::Specification.new do |spec| spec.name = "test_gem" spec.version = "0.1.0" spec.authors = ["Test"] spec.email = ["test@example.com"] spec.summary = "Test gem" spec.files = ["lib/test_gem.rb"] spec.require_paths = ["lib"] end "#})?; // Create lib directory and file context.work_dir().child("lib").create_dir_all()?; context .work_dir() .child("lib/test_gem.rb") .write_str(indoc::indoc! {r#" module TestGem def self.hello "Hello from TestGem" end end "#})?; // Create test script context .work_dir() .child("test_script.rb") .write_str(indoc::indoc! {r" require 'test_gem' puts TestGem.hello "})?; context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: test-gemspec name: test-gemspec language: ruby entry: ruby -I lib test_script.rb language_version: system pass_filenames: false always_run: true "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- test-gemspec.............................................................Passed - hook id: test-gemspec - duration: [TIME] Hello from TestGem ----- stderr ----- "); Ok(()) } /// Test environment isolation between Ruby hooks #[test] fn environment_isolation() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: hook1 name: hook1 language: ruby entry: ruby -e "puts 'hook1=' + ENV['GEM_HOME']" language_version: system pass_filenames: false always_run: true verbose: true - id: hook2 name: hook2 language: ruby entry: ruby -e "puts 'hook2=' + ENV['GEM_HOME']" language_version: system pass_filenames: false always_run: true verbose: true - id: hook3 name: hook3 language: ruby entry: ruby -e "puts 'hook3=' + ENV['GEM_HOME']" language_version: system additional_dependencies: ["rspec"] pass_filenames: false always_run: true verbose: true - id: hook4 name: hook4 language: ruby entry: ruby -e "puts 'hook4=' + ENV['GEM_HOME']" language_version: system additional_dependencies: ["webrick"] pass_filenames: false always_run: true verbose: true - id: hook5 name: hook5 language: ruby entry: ruby -e "puts 'hook5=' + ENV['GEM_HOME']" language_version: system additional_dependencies: ["rspec"] pass_filenames: false always_run: true verbose: true "#}); context.git_add("."); let output = context.run().output()?; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), "Command failed\nEXIT CODE: {:?}\nSTDOUT:\n{}\nSTDERR:\n{}", output.status.code(), stdout, stderr ); // Extract GEM_HOME paths from each hook's output let extract_gem_home = |hook_id: &str| -> String { let prefix = format!("{hook_id}="); stdout .lines() .find_map(|line| line.trim().strip_prefix(&prefix)) .unwrap_or_else(|| panic!("Failed to extract GEM_HOME for {hook_id}")) .to_string() }; let hook1_gem_home = extract_gem_home("hook1"); let hook2_gem_home = extract_gem_home("hook2"); let hook3_gem_home = extract_gem_home("hook3"); let hook4_gem_home = extract_gem_home("hook4"); let hook5_gem_home = extract_gem_home("hook5"); // Verify isolation: hook1 == hook2 (same dependencies (none)) assert_eq!( hook1_gem_home, hook2_gem_home, "hook1 and hook2 should share the same environment (both have no additional_dependencies)" ); // Verify isolation: hook3 == hook5 (same dependencies (rspec)) assert_eq!( hook3_gem_home, hook5_gem_home, "hook3 and hook5 should share the same environment (both have the same additional_dependencies)" ); // Verify isolation: hook1 != hook3 (different dependencies) assert_ne!( hook1_gem_home, hook3_gem_home, "hook1 and hook3 should have different environments (hook3 has rspec)" ); // Verify isolation: hook1 != hook4 (different dependencies) assert_ne!( hook1_gem_home, hook4_gem_home, "hook1 and hook4 should have different environments (hook4 has webrick)" ); // Verify isolation: hook3 != hook4 (different dependencies) assert_ne!( hook3_gem_home, hook4_gem_home, "hook3 and hook4 should have different environments (different gems)" ); // Run the command again to check that the environments are reused let output = context.run().output()?; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), "Command failed\nEXIT CODE: {:?}\nSTDOUT:\n{}\nSTDERR:\n{}", output.status.code(), stdout, stderr ); let hook1_gem_home_v2 = extract_gem_home("hook1"); let hook2_gem_home_v2 = extract_gem_home("hook2"); let hook3_gem_home_v2 = extract_gem_home("hook3"); let hook4_gem_home_v2 = extract_gem_home("hook4"); let hook5_gem_home_v2 = extract_gem_home("hook5"); assert_eq!( hook1_gem_home, hook1_gem_home_v2, "hook1 should reuse the same environment on a second run" ); assert_eq!( hook2_gem_home, hook2_gem_home_v2, "hook2 should reuse the same environment on a second run" ); assert_eq!( hook3_gem_home, hook3_gem_home_v2, "hook3 should reuse the same environment on a second run" ); assert_eq!( hook4_gem_home, hook4_gem_home_v2, "hook4 should reuse the same environment on a second run" ); assert_eq!( hook5_gem_home, hook5_gem_home_v2, "hook5 should reuse the same environment on a second run" ); Ok(()) } /// Test local Ruby hook repository with gemspec build and install #[test] fn local_hook_with_gemspec() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Create a local hook repository with a gemspec let hook_repo = context.work_dir().child("my-hook-repo"); hook_repo.create_dir_all()?; // Create the gemspec hook_repo .child("my_hook.gemspec") .write_str(indoc::indoc! {r#" Gem::Specification.new do |spec| spec.name = "my_hook" spec.version = "0.1.0" spec.authors = ["Test"] spec.email = ["test@example.com"] spec.summary = "Test hook gem" spec.files = ["bin/my-hook"] spec.executables = ["my-hook"] spec.bindir = "bin" end "#})?; // Create executable hook_repo.child("bin").create_dir_all()?; hook_repo.child("bin/my-hook").write_str(indoc::indoc! {r#" #!/usr/bin/env ruby puts "Hook executed from gem!" "#})?; // Create .pre-commit-hooks.yaml manifest hook_repo .child(".pre-commit-hooks.yaml") .write_str(indoc::indoc! {r" - id: my-hook name: My Hook entry: my-hook language: ruby pass_filenames: false "})?; // Initialize git repo in the hook directory (separate from main project) let output = git_cmd(&hook_repo).args(["init"]).output()?; assert!(output.status.success(), "git init failed: {output:?}"); // Configure git user for this repo git_cmd(&hook_repo) .args(["config", "user.name", "Test User"]) .output()?; git_cmd(&hook_repo) .args(["config", "user.email", "test@example.com"]) .output()?; let output = git_cmd(&hook_repo).args(["add", "."]).output()?; assert!(output.status.success(), "git add failed: {output:?}"); let output = git_cmd(&hook_repo) .args(["commit", "-m", "Initial commit"]) .output()?; assert!(output.status.success(), "git commit failed: {output:?}"); // Get the commit SHA let rev_output = git_cmd(&hook_repo).args(["rev-parse", "HEAD"]).output()?; assert!(rev_output.status.success(), "git rev-parse failed"); let rev = String::from_utf8_lossy(&rev_output.stdout) .trim() .to_string(); // Configure prek to use this local repo context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: {} hooks: - id: my-hook name: my-hook entry: my-hook language: ruby pass_filenames: false always_run: true ", hook_repo.to_path_buf().display(), rev }); context.git_add(".pre-commit-config.yaml"); cmd_snapshot!(context.filters(), context.run().arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- my-hook..................................................................Passed - hook id: my-hook - duration: [TIME] Hook executed from gem! ----- stderr ----- "); Ok(()) } /// Test Ruby hook with native gem (C extension) #[test] fn native_gem_dependency() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Create a Ruby script that uses msgpack (small native gem that compiles quickly) context .work_dir() .child("check_msgpack.rb") .write_str(indoc::indoc! {r#" #!/usr/bin/env ruby require 'msgpack' # Test that the native extension works data = { "hello" => "world", "number" => 42 } packed = MessagePack.pack(data) unpacked = MessagePack.unpack(packed) puts "MessagePack native extension working!" puts "Packed size: #{packed.bytesize} bytes" "#})?; context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: test-native-gem name: test-native-gem language: ruby entry: ruby check_msgpack.rb additional_dependencies: ['msgpack'] pass_filenames: false always_run: true "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- test-native-gem..........................................................Passed - hook id: test-native-gem - duration: [TIME] MessagePack native extension working! Packed size: 21 bytes ----- stderr ----- "); Ok(()) } /// Test that multiple gemspecs in a hook repo are built and installed together, /// with all dependencies resolved in a single pass. #[test] fn multiple_gemspecs() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let hook_repo = context.work_dir().child("multi-gem-repo"); hook_repo.create_dir_all()?; // First gemspec: depends on `rainbow` hook_repo .child("gem_one.gemspec") .write_str(indoc::indoc! {r#" Gem::Specification.new do |spec| spec.name = "gem_one" spec.version = "0.1.0" spec.authors = ["Test"] spec.summary = "First gem" spec.files = ["bin/gem-one"] spec.executables = ["gem-one"] spec.bindir = "bin" spec.add_dependency "rainbow", "~> 3.0" end "#})?; // Second gemspec: also depends on `rainbow` (overlapping) plus `unicode-display_width`. // This tests that --explain de-duplicates shared dependencies across gemspecs. hook_repo.child("lib").create_dir_all()?; hook_repo .child("lib/gem_two.rb") .write_str("module GemTwo; end\n")?; hook_repo .child("gem_two.gemspec") .write_str(indoc::indoc! {r#" Gem::Specification.new do |spec| spec.name = "gem_two" spec.version = "0.1.0" spec.authors = ["Test"] spec.summary = "Second gem" spec.files = ["lib/gem_two.rb"] spec.add_dependency "rainbow", "~> 3.0" spec.add_dependency "unicode-display_width", "~> 3.0" end "#})?; // Executable that requires both gems' dependencies hook_repo.child("bin").create_dir_all()?; hook_repo.child("bin/gem-one").write_str(indoc::indoc! {r#" #!/usr/bin/env ruby require 'rainbow' require 'unicode/display_width' puts "rainbow=#{Gem.loaded_specs['rainbow'].version}" puts "udw=#{Gem.loaded_specs['unicode-display_width'].version}" "#})?; hook_repo .child(".pre-commit-hooks.yaml") .write_str(indoc::indoc! {r" - id: multi-gem name: Multi Gem entry: gem-one language: ruby pass_filenames: false "})?; let output = git_cmd(&hook_repo).args(["init"]).output()?; assert!(output.status.success(), "git init failed: {output:?}"); git_cmd(&hook_repo) .args(["config", "user.name", "Test User"]) .output()?; git_cmd(&hook_repo) .args(["config", "user.email", "test@example.com"]) .output()?; let output = git_cmd(&hook_repo).args(["add", "."]).output()?; assert!(output.status.success(), "git add failed: {output:?}"); let output = git_cmd(&hook_repo) .args(["commit", "-m", "Initial commit"]) .output()?; assert!(output.status.success(), "git commit failed: {output:?}"); let rev_output = git_cmd(&hook_repo).args(["rev-parse", "HEAD"]).output()?; let rev = String::from_utf8_lossy(&rev_output.stdout) .trim() .to_string(); context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {} rev: {} hooks: - id: multi-gem always_run: true ", hook_repo.to_path_buf().display(), rev }); context.git_add(".pre-commit-config.yaml"); let filters = [ (r"rainbow=\d+\.\d+\.\d+", "rainbow=X.Y.Z"), (r"udw=\d+\.\d+\.\d+", "udw=X.Y.Z"), ] .into_iter() .chain(context.filters()) .collect::>(); cmd_snapshot!(filters, context.run().arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- Multi Gem................................................................Passed - hook id: multi-gem - duration: [TIME] rainbow=X.Y.Z udw=X.Y.Z ----- stderr ----- "); Ok(()) } /// Test that pre-built platform-specific gems skip compilation while source gems /// compile natively. Installs `sqlite3` (publishes precompiled platform gems) and /// `msgpack` (source-only, requires C compilation) side by side. #[test] fn prebuilt_vs_compiled_gems() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); context .work_dir() .child("check_gems.rb") .write_str(indoc::indoc! {r#" require 'sqlite3' require 'msgpack' db = SQLite3::Database.new(":memory:") db.execute("CREATE TABLE t (v TEXT)") db.execute("INSERT INTO t VALUES (?)", [MessagePack.pack("ok")]) puts "sqlite3=#{SQLite3::VERSION} msgpack=#{MessagePack::VERSION}" "#})?; context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: test-native-gems name: test-native-gems language: ruby entry: ruby check_gems.rb additional_dependencies: ['sqlite3', 'msgpack'] pass_filenames: false always_run: true "}); context.git_add("."); let filters = [ (r"sqlite3=\d+\.\d+\.\d+", "sqlite3=X.Y.Z"), (r"msgpack=\d+\.\d+\.\d+", "msgpack=X.Y.Z"), ] .into_iter() .chain(context.filters()) .collect::>(); cmd_snapshot!(filters, context.run().arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- test-native-gems.........................................................Passed - hook id: test-native-gems - duration: [TIME] sqlite3=X.Y.Z msgpack=X.Y.Z ----- stderr ----- "); // Verify installation methods by inspecting the gem directories. // Pre-built gems include a platform suffix (e.g. sqlite3-X.Y.Z-x86_64-linux-gnu), // while compiled-from-source gems do not (e.g. msgpack-X.Y.Z). let hooks_dir = context.home_dir().join("hooks"); let gems_dir = fs_err::read_dir(&hooks_dir)? .filter_map(Result::ok) .find(|e| e.file_name().to_string_lossy().starts_with("ruby-")) .map(|e| e.path().join("gems").join("gems")) .expect("No ruby hook directory found"); let gem_dirs: Vec = fs_err::read_dir(&gems_dir)? .filter_map(Result::ok) .map(|e| e.file_name().to_string_lossy().to_string()) .collect(); // sqlite3 should have a platform suffix (precompiled) let sqlite3_dir = gem_dirs .iter() .find(|d| d.starts_with("sqlite3-")) .expect("sqlite3 gem not found"); assert!( sqlite3_dir.contains("x86_64-linux") || sqlite3_dir.contains("aarch64-linux") || sqlite3_dir.contains("arm64-darwin") || sqlite3_dir.contains("x64-mingw"), "sqlite3 should be a prebuilt platform gem, got: {sqlite3_dir}" ); // msgpack should NOT have a platform suffix (compiled from source) let msgpack_dir = gem_dirs .iter() .find(|d| d.starts_with("msgpack-")) .expect("msgpack gem not found"); assert!( !msgpack_dir.contains("linux") && !msgpack_dir.contains("darwin") && !msgpack_dir.contains("mingw"), "msgpack should be compiled from source (no platform suffix), got: {msgpack_dir}" ); Ok(()) } /// Test Ruby hook that processes files #[test] fn process_files() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Create a Ruby script that validates file extensions context .work_dir() .child("check_ruby.rb") .write_str(indoc::indoc! {r#" ARGV.sort.each do |file| unless file.end_with?('.rb') puts "Error: #{file} is not a Ruby file" exit 1 end puts "OK: #{file}" end "#})?; context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: check-ruby-files name: check-ruby-files language: ruby entry: ruby check_ruby.rb language_version: system files: \.rb$ verbose: true "}); // Create a Ruby file context .work_dir() .child("test.rb") .write_str("puts 'hello'")?; // Create a text file context.work_dir().child("test.txt").write_str("hello")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check-ruby-files.........................................................Passed - hook id: check-ruby-files - duration: [TIME] OK: check_ruby.rb OK: test.rb ----- stderr ----- "); Ok(()) } /// Test that Ruby is auto-downloaded from rv-ruby when a specific version /// is requested that isn't available on the system. /// Windows doesn't support auto-download, so this test is only for non-Windows platforms. /// The Windows-specific message is tested in `specific_ruby_unavailable`. #[test] #[cfg(not(target_os = "windows"))] fn auto_download() -> anyhow::Result<()> { use assert_fs::assert::PathAssert; use prek_consts::env_vars::EnvVars; if !EnvVars::is_set(EnvVars::CI) { // Skip when not running in CI: local environments may have // unexpected Ruby versions installed. return Ok(()); } let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: ruby-downloaded name: ruby-downloaded language: ruby entry: ruby --version language_version: '3.3' pass_filenames: false always_run: true - id: ruby-system name: ruby-system language: ruby entry: ruby --version language_version: '3.4' pass_filenames: false always_run: true "}); context.git_add("."); let ruby_dir = context.home_dir().child("tools").child("ruby"); ruby_dir.assert(predicates::path::missing()); let filters = [( r"ruby (\d+\.\d+)\.\d+(?:p\d+)? \(\d{4}-\d{2}-\d{2} revision [0-9a-f]{0,10}\).*?\[.+\]", "ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]", )] .into_iter() .chain(context.filters()) .collect::>(); cmd_snapshot!(filters, context.run().arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- ruby-downloaded..........................................................Passed - hook id: ruby-downloaded - duration: [TIME] ruby 3.3.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM] ruby-system..............................................................Passed - hook id: ruby-system - duration: [TIME] ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM] ----- stderr ----- "); // Verify that only Ruby 3.3 was downloaded (3.4 should use system Ruby) let installed_versions = ruby_dir .read_dir()? .flatten() .filter_map(|d| { let filename = d.file_name().to_string_lossy().to_string(); if filename.starts_with('.') { None } else { Some(filename) } }) .collect::>(); assert_eq!( installed_versions.len(), 1, "Expected only one Ruby version to be downloaded, but found: {installed_versions:?}" ); assert!( installed_versions.iter().any(|v| v.starts_with("3.3.")), "Expected Ruby 3.3.x to be downloaded, but found: {installed_versions:?}" ); // Record the mtime of the downloaded Ruby directory so we can verify // step 2 doesn't re-download it. let ruby_version_dir = ruby_dir .read_dir()? .flatten() .find(|d| d.file_name().to_string_lossy().starts_with("3.3.")) .expect("Expected a 3.3.x directory"); let mtime_before = ruby_version_dir.metadata()?.modified()?; // Step 2: Re-run with a looser version match that should reuse the // already-downloaded 3.3.x without hitting the network again. context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: ruby-reused name: ruby-reused language: ruby entry: ruby --version language_version: '>=3.3, <3.4' pass_filenames: false always_run: true "}); context.git_add("."); let filters = [( r"ruby (\d+\.\d+)\.\d+(?:p\d+)? \(\d{4}-\d{2}-\d{2} revision [0-9a-f]{0,10}\).*?\[.+\]", "ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]", )] .into_iter() .chain(context.filters()) .collect::>(); cmd_snapshot!(filters, context.run().arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- ruby-reused..............................................................Passed - hook id: ruby-reused - duration: [TIME] ruby 3.3.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM] ----- stderr ----- "); // Verify the directory wasn't re-downloaded: mtime should be unchanged let mtime_after = ruby_version_dir.metadata()?.modified()?; assert_eq!( mtime_before, mtime_after, "Ruby directory was modified during reuse, suggests it was re-downloaded" ); Ok(()) } ================================================ FILE: crates/prek/tests/languages/rust.rs ================================================ use anyhow::Result; use assert_fs::assert::PathAssert; use assert_fs::fixture::PathChild; use prek_consts::env_vars::EnvVars; use crate::common::{TestContext, cmd_snapshot}; /// Test `language_version` parsing and installation for Rust hooks. #[test] fn language_version() -> Result<()> { if !EnvVars::is_set(EnvVars::CI) { // Skip when not running in CI, as we may have other rust versions installed locally. return Ok(()); } let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: rust-system name: rust-system language: rust entry: rustc --version language_version: system pass_filenames: false always_run: true - id: rust-1.70 # should auto install 1.70.X name: rust-1.70 language: rust entry: rustc --version language_version: '1.70' always_run: true pass_filenames: false - id: rust-1.70 # run again to ensure reusing the installed version name: rust-1.70 language: rust entry: rustc --version language_version: '1.70' always_run: true pass_filenames: false "}); context.git_add("."); let rust_dir = context.home_dir().child("tools/rustup/toolchains"); rust_dir.assert(predicates::path::missing()); let filters = [ (r"rustc (1\.70)\.\d{1,2} .+", "rustc $1.X"), // Keep 1.70.X format (r"rustc 1\.\d{1,3}\.\d{1,2} .+", "rustc 1.X.X"), // Others become 1.X.X ] .into_iter() .chain(context.filters()) .collect::>(); cmd_snapshot!(filters, context.run().arg("-v"), @r#" success: true exit_code: 0 ----- stdout ----- rust-system..............................................................Passed - hook id: rust-system - duration: [TIME] rustc 1.X.X rust-1.70................................................................Passed - hook id: rust-1.70 - duration: [TIME] rustc 1.70.X rust-1.70................................................................Passed - hook id: rust-1.70 - duration: [TIME] rustc 1.70.X ----- stderr ----- "#); // Ensure that only Rust 1.70.X is installed. let installed_versions = rust_dir .read_dir()? .flatten() .filter_map(|d| { let filename = d.file_name().to_string_lossy().to_string(); if filename.starts_with('.') { None } else { Some(filename) } }) .collect::>(); assert_eq!( installed_versions.len(), 1, "Expected only one Rust version to be installed, but found: {installed_versions:?}" ); assert!( installed_versions.iter().any(|v| v.starts_with("1.70")), "Expected Rust 1.70.X to be installed, but found: {installed_versions:?}" ); Ok(()) } /// Test `rustup` installer. #[test] fn rustup_installer() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: rustup-test name: rustup-test language: rust entry: rustc --version "}); context.git_add("."); let filters = [(r"rustc 1\.\d{1,3}\.\d{1,2} .+", "rustc 1.X.X")] .into_iter() .chain(context.filters()) .collect::>(); cmd_snapshot!(filters, context.run().arg("-v").env(EnvVars::PREK_INTERNAL__RUSTUP_BINARY_NAME, "non-exist-rustup"), @r#" success: true exit_code: 0 ----- stdout ----- rustup-test..............................................................Passed - hook id: rustup-test - duration: [TIME] rustc 1.X.X ----- stderr ----- "#); } /// Test that `additional_dependencies` with cli: prefix are installed correctly. #[test] fn additional_dependencies_cli() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: rust-cli name: rust-cli language: rust entry: prek-rust-echo Hello, Prek! additional_dependencies: ["cli:prek-rust-echo"] always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- rust-cli.................................................................Passed - hook id: rust-cli - duration: [TIME] Hello, Prek! ----- stderr ----- "); } /// Test that remote Rust hooks are installed and run correctly. #[test] fn remote_hooks() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: https://github.com/prek-test-repos/rust-hooks rev: v1.0.0 hooks: - id: hello-world verbose: true pass_filenames: false always_run: true args: ["Hello World"] "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- Hello World..............................................................Passed - hook id: hello-world - duration: [TIME] Hello World ----- stderr ----- "); } /// Test that remote Rust hooks from non-workspace repos are installed and run correctly. #[test] fn remote_hook_non_workspace() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/prek-test-repos/rust-hooks-non-workspace rev: v1.0.0 hooks: - id: hello-world verbose: true pass_filenames: false always_run: true "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- hello-world..............................................................Passed - hook id: hello-world - duration: [TIME] Hello, Prek! ----- stderr ----- "); } /// Test that library dependencies (non-cli: prefix) work correctly on remote hooks. /// This verifies that the shared repo is not modified when adding dependencies. #[test] fn remote_hooks_with_lib_deps() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: https://github.com/prek-test-repos/rust-hooks rev: v1.0.0 hooks: - id: hello-world-lib-deps additional_dependencies: ["itoa:1"] verbose: true pass_filenames: false always_run: true "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- Hello World Lib Deps.....................................................Passed - hook id: hello-world-lib-deps - duration: [TIME] 42 ----- stderr ----- "); } ================================================ FILE: crates/prek/tests/languages/script.rs ================================================ use anyhow::Result; use assert_fs::fixture::{FileWriteStr, PathChild}; use crate::common::{TestContext, cmd_snapshot}; #[cfg(unix)] mod unix { use super::*; use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; use prek_consts::PRE_COMMIT_CONFIG_YAML; use std::os::unix::fs::PermissionsExt; #[test] fn script_run() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/prek-test-repos/script-hooks rev: v1.0.0 hooks: - id: echo-env env: VAR2: universe verbose: true - id: echo-env env: VAR1: everyone VAR2: galaxy verbose: true "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- echo-env.................................................................Passed - hook id: echo-env - duration: [TIME] Hello world and universe! echo-env.................................................................Passed - hook id: echo-env - duration: [TIME] Hello everyone and galaxy! ----- stderr ----- "); } #[test] fn workspace_script_run() -> Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc::indoc! {r#" repos: - repo: local hooks: - id: script name: script language: script entry: ./script.sh env: MESSAGE: "Hello, World" verbose: true "#}; context.write_pre_commit_config(config); context .work_dir() .child("script.sh") .write_str(indoc::indoc! {r#" #!/usr/bin/env bash echo "$MESSAGE!" "#})?; let child = context.work_dir().child("child"); child.create_dir_all()?; child.child(PRE_COMMIT_CONFIG_YAML).write_str(config)?; child.child("script.sh").write_str(indoc::indoc! {r#" #!/usr/bin/env bash echo "$MESSAGE from child!" "#})?; fs_err::set_permissions( context.work_dir().child("script.sh"), std::fs::Permissions::from_mode(0o755), )?; fs_err::set_permissions( child.child("script.sh"), std::fs::Permissions::from_mode(0o755), )?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `child`: script...................................................................Passed - hook id: script - duration: [TIME] Hello, World from child! Running hooks for `.`: script...................................................................Passed - hook id: script - duration: [TIME] Hello, World! ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().current_dir(&child), @r" success: true exit_code: 0 ----- stdout ----- script...................................................................Passed - hook id: script - duration: [TIME] Hello, World from child! ----- stderr ----- "); Ok(()) } #[test] fn local_repo_bash_shebang() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: echo name: echo language: script entry: ./echo.sh verbose: true "}); let script = context.work_dir().child("echo.sh"); script.write_str(indoc::indoc! {r#" #!/usr/bin/env bash echo "Hello, World!" "#})?; fs_err::set_permissions(&script, std::fs::Permissions::from_mode(0o755))?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- echo.....................................................................Passed - hook id: echo - duration: [TIME] Hello, World! ----- stderr ----- "); Ok(()) } } /// Test that a script with a shebang line works correctly on Windows. /// The interpreter must exist in the PATH, the script is not needed to be executable. #[test] fn windows_script_run() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: echo name: echo language: script entry: ./echo.sh verbose: true "}); let script = context.work_dir().child("echo.sh"); script.write_str(indoc::indoc! {r#" #!/usr/bin/env python3 print("Hello, World!") "#})?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- echo.....................................................................Passed - hook id: echo - duration: [TIME] Hello, World! ----- stderr ----- "); Ok(()) } ================================================ FILE: crates/prek/tests/languages/swift.rs ================================================ use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; use prek_consts::PRE_COMMIT_HOOKS_YAML; use prek_consts::env_vars::EnvVars; use crate::common::{TestContext, cmd_snapshot, git_cmd}; /// Test that a local Swift hook with a system command works. #[test] fn local_hook_system_command() { if !EnvVars::is_set(EnvVars::CI) { return; } let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: echo-swift name: echo-swift language: swift entry: echo "Swift hook ran" always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- echo-swift...............................................................Passed - hook id: echo-swift - duration: [TIME] Swift hook ran ----- stderr ----- "); } /// Test that `language_version` is rejected for Swift. #[test] fn language_version_rejected() { if !EnvVars::is_set(EnvVars::CI) { return; } let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: local name: local language: swift entry: swift --version language_version: '6.0' always_run: true pass_filenames: false "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to init hooks caused by: Invalid hook `local` caused by: Hook specified `language_version: 6.0` but the language `swift` does not support toolchain installation for now "); } /// Test that health check works after install. #[test] fn health_check() { if !EnvVars::is_set(EnvVars::CI) { return; } let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: swift-echo name: swift-echo language: swift entry: echo "Hello" always_run: true verbose: true pass_filenames: false "#}); context.git_add("."); // First run - installs cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- swift-echo...............................................................Passed - hook id: swift-echo - duration: [TIME] Hello ----- stderr ----- "); // Second run - health check cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- swift-echo...............................................................Passed - hook id: swift-echo - duration: [TIME] Hello ----- stderr ----- "); } /// Test that a Swift Package.swift is built and the executable is available. #[test] fn local_package_build() -> anyhow::Result<()> { if !EnvVars::is_set(EnvVars::CI) { return Ok(()); } let swift_hook = TestContext::new(); swift_hook.init_project(); // Create a minimal Swift package swift_hook .work_dir() .child("Package.swift") .write_str(indoc::indoc! {r#" // swift-tools-version:6.0 import PackageDescription let package = Package( name: "prek-swift-test", targets: [ .executableTarget(name: "prek-swift-test", path: "Sources") ] ) "#})?; swift_hook.work_dir().child("Sources").create_dir_all()?; swift_hook .work_dir() .child("Sources/main.swift") .write_str(indoc::indoc! {r#" print("Hello from Swift package!") "#})?; swift_hook .work_dir() .child(PRE_COMMIT_HOOKS_YAML) .write_str(indoc::indoc! {r" - id: swift-package-test name: swift-package-test entry: prek-swift-test language: swift "})?; swift_hook.git_add("."); swift_hook.git_commit("Initial commit"); git_cmd(swift_hook.work_dir()) .args(["tag", "v1.0", "-m", "v1.0"]) .output()?; let context = TestContext::new(); context.init_project(); let hook_url = swift_hook.work_dir().to_str().unwrap(); context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - repo: {hook_url} rev: v1.0 hooks: - id: swift-package-test verbose: true always_run: true pass_filenames: false ", hook_url = hook_url}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- swift-package-test.......................................................Passed - hook id: swift-package-test - duration: [TIME] Hello from Swift package! ----- stderr ----- "); Ok(()) } ================================================ FILE: crates/prek/tests/languages/unimplemented.rs ================================================ use crate::common::{TestContext, cmd_snapshot}; #[test] fn unimplemented_language() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: unimplemented-language-hook name: r-hook language: r entry: rscript --version "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @" success: true exit_code: 0 ----- stdout ----- r-hook...............................................(unimplemented yet)Skipped ----- stderr ----- warning: Some hooks were skipped because their languages are unimplemented. We're working hard to support more languages. Check out current support status at https://prek.j178.dev/languages/. "); } ================================================ FILE: crates/prek/tests/languages/unsupported.rs ================================================ /// Test `language: unsupported` and `language: unsupported_script` works. #[cfg(unix)] #[test] fn unsupported_language() -> anyhow::Result<()> { use crate::common::{TestContext, cmd_snapshot}; use assert_fs::fixture::{FileWriteStr, PathChild}; let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: unsupported name: unsupported language: unsupported entry: echo verbose: true - id: unsupported-script name: unsupported-script language: unsupported_script entry: ./script.sh verbose: true "}); context .work_dir() .child("script.sh") .write_str(indoc::indoc! {r#" #!/usr/bin/env bash echo "Hello, World!" "#})?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- unsupported..............................................................Passed - hook id: unsupported - duration: [TIME] script.sh .pre-commit-config.yaml unsupported-script.......................................................Passed - hook id: unsupported-script - duration: [TIME] Hello, World! ----- stderr ----- "); Ok(()) } ================================================ FILE: crates/prek/tests/list.rs ================================================ use crate::common::{TestContext, cmd_snapshot}; use indoc::indoc; mod common; #[test] fn list_basic() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: check-yaml name: Check YAML entry: check-yaml language: system types: [yaml] - id: check-json name: Check JSON entry: check-json language: system types: [json] description: Validate JSON files "}); cmd_snapshot!(context.filters(), context.list(), @r" success: true exit_code: 0 ----- stdout ----- .:check-yaml .:check-json ----- stderr ----- "); } #[test] fn list_verbose() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: check-yaml name: Check YAML entry: check-yaml language: system types: [yaml] - id: check-json name: Check JSON entry: check-json language: system types: [json] description: Validate JSON files fail_fast: true verbose: true "}); cmd_snapshot!(context.filters(), context.list().arg("--verbose"), @r" success: true exit_code: 0 ----- stdout ----- .:check-yaml ID: check-yaml Name: Check YAML Language: system Stages: all .:check-json ID: check-json Name: Check JSON Description: Validate JSON files Language: system Stages: all ----- stderr ----- "); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: custom-formatter name: Custom Code Formatter entry: ./format.sh language: script description: Custom formatting tool with specific requirements files: \.(py|rs|js)$ exclude: vendor/ types: [text] types_or: [python, rust, javascript] exclude_types: [binary] args: [--check, --diff] always_run: true fail_fast: true pass_filenames: false require_serial: true verbose: true stages: [pre-commit, pre-push] alias: fmt "}); cmd_snapshot!(context.filters(), context.list().arg("--verbose"), @r" success: true exit_code: 0 ----- stdout ----- .:custom-formatter ID: custom-formatter Alias: fmt Name: Custom Code Formatter Description: Custom formatting tool with specific requirements Language: script Stages: pre-commit, pre-push ----- stderr ----- "); } #[test] fn list_with_hook_ids_filter() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: check-yaml name: Check YAML entry: check-yaml language: system types: [yaml] - id: check-json name: Check JSON entry: check-json language: system types: [json] - id: check-toml name: Check TOML entry: check-toml language: system types: [toml] "}); // Test filtering by specific hook ID cmd_snapshot!(context.filters(), context.list().arg("check-yaml"), @r" success: true exit_code: 0 ----- stdout ----- .:check-yaml ----- stderr ----- "); // Test filtering by multiple hook IDs cmd_snapshot!(context.filters(), context.list().arg("check-yaml").arg("check-json"), @r" success: true exit_code: 0 ----- stdout ----- .:check-yaml .:check-json ----- stderr ----- "); } #[test] fn list_with_language_filter() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: check-yaml name: Check YAML entry: check-yaml language: system types: [yaml] - id: format-python name: Format Python entry: black language: python types: [python] - id: lint-rust name: Lint Rust entry: clippy language: rust types: [rust] "}); // Test filtering by language cmd_snapshot!(context.filters(), context.list().arg("--language").arg("system"), @r" success: true exit_code: 0 ----- stdout ----- .:check-yaml ----- stderr ----- "); cmd_snapshot!(context.filters(), context.list().arg("--language").arg("python"), @r" success: true exit_code: 0 ----- stdout ----- .:format-python ----- stderr ----- "); } #[test] fn list_with_stage_filter() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: check-yaml name: Check YAML entry: check-yaml language: system types: [yaml] - id: check-json name: Check JSON entry: check-json language: system types: [json] stages: [pre-push] - id: check-toml name: Check TOML entry: check-toml language: system types: [toml] stages: [pre-commit, pre-push] "}); // Test filtering by stage cmd_snapshot!(context.filters(), context.list().arg("--hook-stage").arg("pre-commit"), @r" success: true exit_code: 0 ----- stdout ----- .:check-yaml .:check-toml ----- stderr ----- "); cmd_snapshot!(context.filters(), context.list().arg("--hook-stage").arg("pre-push"), @r" success: true exit_code: 0 ----- stdout ----- .:check-yaml .:check-json .:check-toml ----- stderr ----- "); } #[test] fn list_with_aliases() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: check-yaml name: Check YAML entry: check-yaml language: system types: [yaml] alias: yaml-check - id: check-json name: Check JSON entry: check-json language: system types: [json] "}); // Test that aliases are recognized cmd_snapshot!(context.filters(), context.list().arg("yaml-check"), @r" success: true exit_code: 0 ----- stdout ----- .:check-yaml ----- stderr ----- "); // Test verbose shows alias information cmd_snapshot!(context.filters(), context.list().arg("--verbose").arg("check-yaml"), @r" success: true exit_code: 0 ----- stdout ----- .:check-yaml ID: check-yaml Alias: yaml-check Name: Check YAML Language: system Stages: all ----- stderr ----- "); } #[test] fn list_empty_config() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config("repos: []"); cmd_snapshot!(context.filters(), context.list(), @r#" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- "#); cmd_snapshot!(context.filters(), context.list().arg("--verbose"), @r#" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- "#); } #[test] fn list_no_config_file() { let context = TestContext::new(); context.init_project(); // No config file exists cmd_snapshot!(context.filters(), context.list(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: No `prek.toml` or `.pre-commit-config.yaml` found in the current directory or parent directories. hint: If you just added one, rerun your command with the `--refresh` flag to rescan the workspace. "); } #[test] fn list_json_output() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: check-yaml name: Check YAML entry: check-yaml language: system types: [yaml] alias: yaml-check - id: check-json name: Check JSON entry: check-json language: system types: [json] description: Validate JSON files "}); // Test JSON output for all hooks cmd_snapshot!(context.filters(), context.list().arg("--output-format=json"), @r#" success: true exit_code: 0 ----- stdout ----- [ { "id": "check-yaml", "full_id": ".:check-yaml", "name": "Check YAML", "alias": "yaml-check", "language": "system", "description": null, "stages": [ "manual", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg" ] }, { "id": "check-json", "full_id": ".:check-json", "name": "Check JSON", "alias": "", "language": "system", "description": "Validate JSON files", "stages": [ "manual", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg" ] } ] ----- stderr ----- "#); // Test filtered JSON output cmd_snapshot!(context.filters(), context.list().arg("check-json").arg("--output-format=json"), @r#" success: true exit_code: 0 ----- stdout ----- [ { "id": "check-json", "full_id": ".:check-json", "name": "Check JSON", "alias": "", "language": "system", "description": "Validate JSON files", "stages": [ "manual", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg" ] } ] ----- stderr ----- "#); } #[test] fn workspace_list() -> anyhow::Result<()> { let context = TestContext::new(); let cwd = context.work_dir(); context.init_project(); let config = indoc! {r" repos: - repo: local hooks: - id: show-cwd name: Show CWD language: python entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])' verbose: true "}; context.setup_workspace( &[ "project2", "project3", "nested/project4", "project3/project5", ], config, )?; context.git_add("."); cmd_snapshot!(context.filters(), context.list(), @r" success: true exit_code: 0 ----- stdout ----- nested/project4:show-cwd project3/project5:show-cwd project2:show-cwd project3:show-cwd .:show-cwd ----- stderr ----- "); let mut filters = context.filters(); filters.push((r"\\/", "/")); // Normalize Windows path separators in JSON output cmd_snapshot!(filters, context.list().arg("--output-format=json"), @r#" success: true exit_code: 0 ----- stdout ----- [ { "id": "show-cwd", "full_id": "nested/project4:show-cwd", "name": "Show CWD", "alias": "", "language": "python", "description": null, "stages": [ "manual", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg" ] }, { "id": "show-cwd", "full_id": "project3/project5:show-cwd", "name": "Show CWD", "alias": "", "language": "python", "description": null, "stages": [ "manual", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg" ] }, { "id": "show-cwd", "full_id": "project2:show-cwd", "name": "Show CWD", "alias": "", "language": "python", "description": null, "stages": [ "manual", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg" ] }, { "id": "show-cwd", "full_id": "project3:show-cwd", "name": "Show CWD", "alias": "", "language": "python", "description": null, "stages": [ "manual", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg" ] }, { "id": "show-cwd", "full_id": ".:show-cwd", "name": "Show CWD", "alias": "", "language": "python", "description": null, "stages": [ "manual", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg" ] } ] ----- stderr ----- "#); cmd_snapshot!(context.filters(), context.list().current_dir(cwd.join("project3")), @r" success: true exit_code: 0 ----- stdout ----- project5:show-cwd .:show-cwd ----- stderr ----- "); cmd_snapshot!(context.filters(), context.list().current_dir(cwd.join("project3")).arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- project5:show-cwd ID: show-cwd Name: Show CWD Language: python Stages: all .:show-cwd ID: show-cwd Name: Show CWD Language: python Stages: all ----- stderr ----- "); Ok(()) } #[test] fn list_with_selectors() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc! {r" repos: - repo: local hooks: - id: show-cwd name: Show CWD language: python entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])' verbose: true "}; context.setup_workspace( &[ "project2", "project3", "nested/project4", "project3/project5", ], config, )?; context.git_add("."); cmd_snapshot!(context.filters(), context.list().arg("project2/"), @r" success: true exit_code: 0 ----- stdout ----- project2:show-cwd ----- stderr ----- "); cmd_snapshot!(context.filters(), context.list().arg("--skip").arg("project2/"), @r" success: true exit_code: 0 ----- stdout ----- nested/project4:show-cwd project3/project5:show-cwd project3:show-cwd .:show-cwd ----- stderr ----- "); cmd_snapshot!(context.filters(), context.list().arg("--skip").arg("nested/").arg("--skip").arg("project3/"), @r" success: true exit_code: 0 ----- stdout ----- project2:show-cwd .:show-cwd ----- stderr ----- "); cmd_snapshot!(context.filters(), context.list().arg("show-cwd"), @r" success: true exit_code: 0 ----- stdout ----- nested/project4:show-cwd project3/project5:show-cwd project2:show-cwd project3:show-cwd .:show-cwd ----- stderr ----- "); cmd_snapshot!(context.filters(), context.list().arg("project2:show-cwd"), @r" success: true exit_code: 0 ----- stdout ----- project2:show-cwd ----- stderr ----- "); cmd_snapshot!(context.filters(), context.list().arg(".:show-cwd"), @r" success: true exit_code: 0 ----- stdout ----- .:show-cwd ----- stderr ----- "); cmd_snapshot!(context.filters(), context.list().arg("--skip").arg("show-cwd"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- "); cmd_snapshot!(context.filters(), context.list().arg("--skip").arg("project2:show-cwd").arg("--skip").arg("nested:show-cwd"), @r" success: true exit_code: 0 ----- stdout ----- nested/project4:show-cwd project3/project5:show-cwd project3:show-cwd .:show-cwd ----- stderr ----- warning: selector `--skip=nested:show-cwd` did not match any hooks "); cmd_snapshot!(context.filters(), context.list().arg("--skip").arg("non-exist"), @r" success: true exit_code: 0 ----- stdout ----- nested/project4:show-cwd project3/project5:show-cwd project2:show-cwd project3:show-cwd .:show-cwd ----- stderr ----- warning: selector `--skip=non-exist` did not match any hooks "); cmd_snapshot!(context.filters(), context.list().arg("--skip").arg("../"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Invalid selector: `../` caused by: Invalid project path: `../` caused by: path is outside the workspace root "); cmd_snapshot!(context.filters(), context.list().current_dir(context.work_dir().join("project2")), @r" success: true exit_code: 0 ----- stdout ----- .:show-cwd ----- stderr ----- "); Ok(()) } ================================================ FILE: crates/prek/tests/list_builtins.rs ================================================ use crate::common::{TestContext, cmd_snapshot}; mod common; #[test] fn list_builtins_basic() { let context = TestContext::new(); cmd_snapshot!(context.filters(), context.command().arg("util").arg("list-builtins"), @r" success: true exit_code: 0 ----- stdout ----- check-added-large-files check-case-conflict check-executables-have-shebangs check-json check-json5 check-merge-conflict check-symlinks check-toml check-xml check-yaml detect-private-key end-of-file-fixer fix-byte-order-marker mixed-line-ending no-commit-to-branch trailing-whitespace ----- stderr ----- "); } #[test] fn list_builtins_verbose() { let context = TestContext::new(); cmd_snapshot!(context.filters(), context.command().arg("util").arg("list-builtins").arg("--verbose"), @r" success: true exit_code: 0 ----- stdout ----- check-added-large-files prevents giant files from being committed. check-case-conflict checks for files that would conflict in case-insensitive filesystems check-executables-have-shebangs ensures that (non-binary) executables have a shebang. check-json checks json files for parseable syntax. check-json5 checks json5 files for parseable syntax. check-merge-conflict checks for files that contain merge conflict strings. check-symlinks checks for symlinks which do not point to anything. check-toml checks toml files for parseable syntax. check-xml checks xml files for parseable syntax. check-yaml checks yaml files for parseable syntax. detect-private-key detects the presence of private keys. end-of-file-fixer ensures that a file is either empty, or ends with one newline. fix-byte-order-marker removes utf-8 byte order marker. mixed-line-ending replaces or checks mixed line ending. no-commit-to-branch trailing-whitespace trims trailing whitespace. ----- stderr ----- "); } #[test] fn list_builtins_json() { let context = TestContext::new(); cmd_snapshot!(context.filters(), context.command().arg("util").arg("list-builtins").arg("--output-format=json"), @r#" success: true exit_code: 0 ----- stdout ----- [ { "id": "check-added-large-files", "name": "check for added large files", "description": "prevents giant files from being committed." }, { "id": "check-case-conflict", "name": "check for case conflicts", "description": "checks for files that would conflict in case-insensitive filesystems" }, { "id": "check-executables-have-shebangs", "name": "check that executables have shebangs", "description": "ensures that (non-binary) executables have a shebang." }, { "id": "check-json", "name": "check json", "description": "checks json files for parseable syntax." }, { "id": "check-json5", "name": "check json5", "description": "checks json5 files for parseable syntax." }, { "id": "check-merge-conflict", "name": "check for merge conflicts", "description": "checks for files that contain merge conflict strings." }, { "id": "check-symlinks", "name": "check for broken symlinks", "description": "checks for symlinks which do not point to anything." }, { "id": "check-toml", "name": "check toml", "description": "checks toml files for parseable syntax." }, { "id": "check-xml", "name": "check xml", "description": "checks xml files for parseable syntax." }, { "id": "check-yaml", "name": "check yaml", "description": "checks yaml files for parseable syntax." }, { "id": "detect-private-key", "name": "detect private key", "description": "detects the presence of private keys." }, { "id": "end-of-file-fixer", "name": "fix end of files", "description": "ensures that a file is either empty, or ends with one newline." }, { "id": "fix-byte-order-marker", "name": "fix utf-8 byte order marker", "description": "removes utf-8 byte order marker." }, { "id": "mixed-line-ending", "name": "mixed line ending", "description": "replaces or checks mixed line ending." }, { "id": "no-commit-to-branch", "name": "don't commit to branch", "description": null }, { "id": "trailing-whitespace", "name": "trim trailing whitespace", "description": "trims trailing whitespace." } ] ----- stderr ----- "#); } ================================================ FILE: crates/prek/tests/meta_hooks.rs ================================================ mod common; use crate::common::{TestContext, cmd_snapshot}; use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; use prek_consts::PRE_COMMIT_CONFIG_YAML; #[test] fn meta_hooks() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("file.txt").write_str("Hello, world!\n")?; cwd.child("valid.json").write_str("{}")?; cwd.child("invalid.json").write_str("{}")?; cwd.child("main.py").write_str(r#"print "abc" "#)?; context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: meta hooks: - id: check-hooks-apply - id: check-useless-excludes - id: identity - repo: local hooks: - id: match-no-files name: match no files language: system entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' files: ^nonexistent$ - id: useless-exclude name: useless exclude language: system entry: python3 -c 'import sys; sys.exit(0)' exclude: $nonexistent^ "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- Check hooks apply........................................................Failed - hook id: check-hooks-apply - exit code: 1 match-no-files does not apply to this repository Check useless excludes...................................................Failed - hook id: check-useless-excludes - exit code: 1 The exclude pattern `regex: $nonexistent^` for `useless-exclude` does not match any files identity.................................................................Passed - hook id: identity - duration: [TIME] file.txt .pre-commit-config.yaml valid.json invalid.json main.py match no files.......................................(no files to check)Skipped useless exclude..........................................................Passed ----- stderr ----- "); Ok(()) } #[test] fn meta_hooks_unknown_hook() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: meta hooks: - id: this-hook-does-not-exist "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to parse `.pre-commit-config.yaml` caused by: error: line 4 column 9: unknown meta hook id `this-hook-does-not-exist` --> :4:9 | 2 | - repo: meta 3 | hooks: 4 | - id: this-hook-does-not-exist | ^ unknown meta hook id `this-hook-does-not-exist` "); } #[test] fn check_useless_excludes_remote() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // When checking useless excludes, remote hooks are not actually cloned, // so hook options defined from HookManifest are not used. // If applied, "types_or: [python, pyi]" from black-pre-commit-mirror // will filter out html files first, so the excludes would not be useless, and the test would fail. let pre_commit_config = indoc::formatdoc! {r" repos: - repo: https://github.com/psf/black-pre-commit-mirror rev: 25.1.0 hooks: - id: black exclude: '^html/' - repo: local hooks: - id: echo name: echo entry: echo 'echoing' language: system exclude: '^useless/$' - repo: meta hooks: - id: check-useless-excludes "}; context.work_dir().child("html").create_dir_all()?; context .work_dir() .child("html") .child("file1.html") .write_str("")?; context.write_pre_commit_config(&pre_commit_config); context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("check-useless-excludes"), @r" success: false exit_code: 1 ----- stdout ----- Check useless excludes...................................................Failed - hook id: check-useless-excludes - exit code: 1 The exclude pattern `regex: ^useless/$` for `echo` does not match any files ----- stderr ----- "); Ok(()) } #[test] fn meta_hooks_workspace() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); let app = context.work_dir().child("app"); app.create_dir_all()?; app.child(PRE_COMMIT_CONFIG_YAML) .write_str(indoc::indoc! {r" repos: - repo: meta hooks: - id: check-hooks-apply - id: check-useless-excludes - id: identity - repo: local hooks: - id: match-no-files name: match no files language: system entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' files: ^nonexistent$ - id: useless-exclude name: useless exclude language: system entry: python3 -c 'import sys; sys.exit(0)' exclude: $nonexistent^ "})?; app.child("file.txt").write_str("Hello, world!\n")?; app.child("valid.json").write_str("{}")?; app.child("invalid.json").write_str("{x}")?; app.child("main.py").write_str(r#"print "abc" "#)?; context.write_pre_commit_config("repos: []"); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- Running hooks for `app`: Check hooks apply........................................................Failed - hook id: check-hooks-apply - exit code: 1 match-no-files does not apply to this repository Check useless excludes...................................................Failed - hook id: check-useless-excludes - exit code: 1 The exclude pattern `regex: $nonexistent^` for `useless-exclude` does not match any files identity.................................................................Passed - hook id: identity - duration: [TIME] file.txt .pre-commit-config.yaml valid.json invalid.json main.py match no files.......................................(no files to check)Skipped useless exclude..........................................................Passed ----- stderr ----- "); Ok(()) } #[test] fn check_useless_excludes_workspace_paths_are_project_relative() -> anyhow::Result<()> { let context = TestContext::new(); context.init_project(); // Workspace layout: // - Root project has no hooks. // - Nested project `app/` runs `check-useless-excludes`. // // Regression: in workspace mode, `files`/`exclude` matching must use paths *relative to the // nested project root* (so anchored patterns like `^...$` work as expected). let app = context.work_dir().child("app"); app.create_dir_all()?; app.child(PRE_COMMIT_CONFIG_YAML) .write_str(indoc::indoc! {r" exclude: '^global_excluded$' repos: - repo: meta hooks: - id: check-useless-excludes - repo: local hooks: - id: ok name: ok language: system entry: python3 -c 'import sys; sys.exit(0)' exclude: '^hook_excluded$' "})?; // These files exist specifically so the anchored patterns above are NOT useless. // If the meta hook mistakenly matches against `app/` instead of ``, it will fail. app.child("global_excluded").write_str("ignored\n")?; app.child("hook_excluded").write_str("ignored\n")?; context.write_pre_commit_config("repos: []"); context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("check-useless-excludes"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `app`: Check useless excludes...................................................Passed ----- stderr ----- "); Ok(()) } ================================================ FILE: crates/prek/tests/run.rs ================================================ use std::path::Path; use anyhow::Result; use assert_cmd::assert::OutputAssertExt; use assert_fs::prelude::*; use insta::assert_snapshot; use predicates::prelude::predicate; use prek_consts::env_vars::EnvVars; use prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_CONFIG_YML, PREK_TOML}; use crate::common::{TestContext, cmd_snapshot, git_cmd}; mod common; #[test] fn run_basic() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-json "}); // Create a repository with some files. cwd.child("file.txt").write_str("Hello, world!\n")?; cwd.child("valid.json").write_str("{}")?; cwd.child("invalid.json").write_str("{}")?; cwd.child("main.py").write_str(r#"print "abc" "#)?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- trim trailing whitespace.................................................Failed - hook id: trailing-whitespace - exit code: 1 - files were modified by this hook Fixing main.py fix end of files.........................................................Failed - hook id: end-of-file-fixer - exit code: 1 - files were modified by this hook Fixing valid.json Fixing invalid.json Fixing main.py check json...............................................................Passed ----- stderr ----- "); context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("trailing-whitespace"), @r#" success: true exit_code: 0 ----- stdout ----- trim trailing whitespace.................................................Passed ----- stderr ----- "#); Ok(()) } #[test] fn run_glob_patterns_with_multiple_hooks() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: echo-py name: echo-py entry: python3 -c "import sys; print('PY:' + ' '.join(sys.argv[2:]))" _ language: system files: glob: src/**/*.py verbose: true - id: echo-md name: echo-md entry: python3 -c "import sys; print('MD:' + ' '.join(sys.argv[2:]))" _ language: system files: glob: "**/*.md" verbose: true "#}); let src_dir = cwd.child("src"); src_dir.create_dir_all()?; src_dir.child("main.py").write_str("print('hi')")?; cwd.child("docs").create_dir_all()?; cwd.child("docs/readme.md").write_str("# Docs")?; cwd.child("notes.txt").write_str("note")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r" success: true exit_code: 0 ----- stdout ----- echo-py..................................................................Passed - hook id: echo-py - duration: [TIME] PY:src/main.py echo-md..................................................................Passed - hook id: echo-md - duration: [TIME] MD:docs/readme.md ----- stderr ----- "); Ok(()) } #[test] fn run_in_non_git_repo() { let context = TestContext::new(); let mut filters = context.filters(); filters.push((r"exit code: ", "exit status: ")); cmd_snapshot!(filters, context.run(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Command `get git root` exited with an error: [status] exit status: 128 [stderr] fatal: not a git repository (or any of the parent directories): .git "); } #[test] fn invalid_config() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config("invalid: config"); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to parse `.pre-commit-config.yaml` caused by: error: line 1 column 1: missing field `repos` --> :1:1 | 1 | invalid: config | ^ missing field `repos` "); context.write_pre_commit_config(indoc::indoc! {r" files: 12 repos: [] "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to parse `.pre-commit-config.yaml` caused by: error: line 1 column 1: invalid type: integer `12`, expected a regex string or a mapping with `glob` set to a string or list of strings --> :1:1 | 1 | files: 12 | ^ invalid type: integer `12`, expected a regex string or a mapping with `glob` set to a string or list of strings 2 | repos: [] | "); context.write_pre_commit_config(indoc::indoc! {r#" files: glog: "*.rs" repos: [] "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to parse `.pre-commit-config.yaml` caused by: error: line 2 column 3: unknown field `glog`, expected one of glob --> :2:3 | 1 | files: 2 | glog: \"*.rs\" | ^ unknown field `glog`, expected one of glob 3 | repos: [] | "); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: dotnet additional_dependencies: ["dotnet@6"] entry: echo Hello, world! "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to init hooks caused by: Invalid hook `trailing-whitespace` caused by: Hook specified `additional_dependencies: dotnet@6` but the language `dotnet` does not support installing dependencies for now "); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: fail language_version: '6' entry: echo Hello, world! "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to init hooks caused by: Invalid hook `trailing-whitespace` caused by: Hook specified `language_version: 6` but the language `fail` does not support toolchain installation for now "); } /// Use same repo multiple times, with same or different revisions. #[test] fn same_repo() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: trailing-whitespace "}); cwd.child("file.txt").write_str("Hello, world!\n")?; cwd.child("valid.json").write_str("{}")?; cwd.child("invalid.json").write_str("{}")?; cwd.child("main.py").write_str(r#"print "abc" "#)?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- trim trailing whitespace.................................................Failed - hook id: trailing-whitespace - exit code: 1 - files were modified by this hook Fixing main.py trim trailing whitespace.................................................Passed trim trailing whitespace.................................................Passed ----- stderr ----- "); Ok(()) } #[test] fn local() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: local name: local language: system entry: echo Hello, world! always_run: true "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: true exit_code: 0 ----- stdout ----- local....................................................................Passed ----- stderr ----- "#); } /// Test multiple hook IDs scenarios. #[test] fn multiple_hook_ids() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: hook1 name: First Hook language: system entry: echo hook1 - id: hook2 name: Second Hook language: system entry: echo hook2 - id: shared-name name: Shared Hook A language: system entry: echo shared-a - id: shared-name-2 name: Shared Hook B language: system entry: echo shared-b alias: shared-name "}); context.git_add("."); // Multiple repeated hook-id (should deduplicate) cmd_snapshot!(context.filters(), context.run().arg("hook1").arg("hook1").arg("hook1"), @r#" success: true exit_code: 0 ----- stdout ----- First Hook...............................................................Passed ----- stderr ----- "#); // Hook-id that matches multiple hooks (by alias) cmd_snapshot!(context.filters(), context.run().arg("shared-name"), @r#" success: true exit_code: 0 ----- stdout ----- Shared Hook A............................................................Passed Shared Hook B............................................................Passed ----- stderr ----- "#); // Hook-id matches nothing cmd_snapshot!(context.filters(), context.run().arg("nonexistent-hook"), @r" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- warning: selector `nonexistent-hook` did not match any hooks error: No hooks found after filtering with the given selectors "); // Multiple hook_ids match nothing cmd_snapshot!(context.filters(), context.run().arg("nonexistent-hook").arg("nonexistent-hook").arg("nonexistent-hook-2"), @r" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- warning: the following selectors did not match any hooks or projects: - `nonexistent-hook` - `nonexistent-hook-2` error: No hooks found after filtering with the given selectors "); // Hook-id matches one hook cmd_snapshot!(context.filters(), context.run().arg("hook2"), @r#" success: true exit_code: 0 ----- stdout ----- Second Hook..............................................................Passed ----- stderr ----- "#); // Multiple hook-ids with mixed results (some exist, some don't) cmd_snapshot!(context.filters(), context.run().arg("hook1").arg("nonexistent").arg("hook2"), @r" success: true exit_code: 0 ----- stdout ----- First Hook...............................................................Passed Second Hook..............................................................Passed ----- stderr ----- warning: selector `nonexistent` did not match any hooks "); // Multiple valid hook-ids cmd_snapshot!(context.filters(), context.run().arg("hook1").arg("hook2").arg("nonexistent-hook"), @r" success: true exit_code: 0 ----- stdout ----- First Hook...............................................................Passed Second Hook..............................................................Passed ----- stderr ----- warning: selector `nonexistent-hook` did not match any hooks "); // Multiple hook-ids with some duplicates and aliases cmd_snapshot!(context.filters(), context.run().arg("hook1").arg("shared-name").arg("hook1"), @r#" success: true exit_code: 0 ----- stdout ----- First Hook...............................................................Passed Shared Hook A............................................................Passed Shared Hook B............................................................Passed ----- stderr ----- "#); } #[test] fn priorities_respected() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: late name: Late Hook language: system entry: python3 -c "print('late')" always_run: true priority: 10 - id: early name: Early Hook language: system entry: python3 -c "print('early')" always_run: true priority: 0 - id: middle name: Middle Hook language: system entry: python3 -c "print('middle')" always_run: true priority: 5 "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: true exit_code: 0 ----- stdout ----- Early Hook...............................................................Passed Middle Hook..............................................................Passed Late Hook................................................................Passed ----- stderr ----- "#); } #[test] fn priority_fail_fast_stops_later_groups() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: fail-fast name: Failing Hook language: system entry: python3 -c "import sys; sys.exit(1)" always_run: true priority: 5 fail_fast: true - id: sibling name: Same Priority Sibling language: system entry: python3 -c "import time; time.sleep(0.2)" always_run: true priority: 5 - id: later name: Later Hook language: system entry: python3 -c "print('later ran')" always_run: true priority: 10 "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: false exit_code: 1 ----- stdout ----- Failing Hook.............................................................Failed - hook id: fail-fast - exit code: 1 Same Priority Sibling....................................................Passed ----- stderr ----- "#); } #[test] fn priority_group_modified_files_is_group_failure_and_output_is_indented() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("file.txt").write_str("hello\n")?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: modify name: Modifies File language: system entry: python3 -c "from pathlib import Path; p = Path('file.txt'); p.write_text(p.read_text() + 'x')" always_run: true verbose: true priority: 0 - id: loud name: Prints Output language: system entry: python3 -c "print('hello from loud')" always_run: true verbose: true priority: 0 - id: quiet name: No Output language: system entry: python3 -c "import time; time.sleep(0.1)" always_run: true priority: 0 - id: later name: Later Hook language: system entry: python3 -c "print('later ran')" always_run: true verbose: true priority: 10 "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- Files were modified by following hooks...................................Failed ┌ Modifies File........................................................Passed │ - hook id: modify │ - duration: [TIME] │ Prints Output........................................................Passed │ - hook id: loud │ - duration: [TIME] │ │ hello from loud └ No Output............................................................Passed Later Hook...............................................................Passed - hook id: later - duration: [TIME] later ran ----- stderr ----- "); Ok(()) } /// `.pre-commit-config.yaml` is not staged. #[test] fn config_not_staged() -> Result<()> { let context = TestContext::new(); context.init_project(); context.work_dir().child(PRE_COMMIT_CONFIG_YAML).touch()?; context.git_add("."); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -V "}); cmd_snapshot!(context.filters(), context.run().arg("invalid-hook-id"), @r#" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: prek configuration file is not staged, run `git add .pre-commit-config.yaml` to stage it "#); Ok(()) } /// `.pre-commit-config.yaml` outside the repository should not be checked. #[test] fn config_outside_repo() -> Result<()> { let context = TestContext::new(); // Initialize a git repository in ./work. let root = context.work_dir().child("work"); root.create_dir_all()?; git_cmd(&root).arg("init").assert().success(); // Create a configuration file in . (outside the repository). context .work_dir() .child("c.yaml") .write_str(indoc::indoc! {r#" repos: - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c 'print("Hello world")' "#})?; cmd_snapshot!(context.filters(), context.run().current_dir(&root).arg("-c").arg("../c.yaml"), @r#" success: true exit_code: 0 ----- stdout ----- trailing-whitespace..................................(no files to check)Skipped ----- stderr ----- "#); Ok(()) } /// Test the output format for a hook with a CJK name. #[test] fn cjk_hook_name() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: trailing-whitespace name: 去除行尾空格 language: system entry: python3 -V - id: end-of-file-fixer name: fix end of files language: system entry: python3 -V "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: true exit_code: 0 ----- stdout ----- 去除行尾空格.............................................................Passed fix end of files.........................................................Passed ----- stderr ----- "#); } /// Skips hooks based on the `SKIP` environment variable. #[test] fn skips() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c "exit(1)" - id: end-of-file-fixer name: fix end of files language: system entry: python3 -c "exit(1)" - id: check-json name: check json language: system entry: python3 -c "exit(1)" "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run().env("SKIP", "end-of-file-fixer"), @r" success: false exit_code: 1 ----- stdout ----- trailing-whitespace......................................................Failed - hook id: trailing-whitespace - exit code: 1 check json...............................................................Failed - hook id: check-json - exit code: 1 ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().env("SKIP", "trailing-whitespace,end-of-file-fixer"), @r" success: false exit_code: 1 ----- stdout ----- check json...............................................................Failed - hook id: check-json - exit code: 1 ----- stderr ----- "); } /// Run hooks with matched `stage`. #[test] fn stage() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: manual-stage name: manual-stage language: system entry: echo manual-stage stages: [ manual ] # Defaults to all stages. - id: default-stage name: default-stage language: system entry: echo default-stage - id: post-commit-stage name: post-commit-stage language: system entry: echo post-commit-stage stages: [ post-commit ] "}); context.git_add("."); // By default, run hooks with `pre-commit` stage. cmd_snapshot!(context.filters(), context.run(), @r#" success: true exit_code: 0 ----- stdout ----- default-stage............................................................Passed ----- stderr ----- "#); // Run hooks with `manual` stage. cmd_snapshot!(context.filters(), context.run().arg("--hook-stage").arg("manual"), @r#" success: true exit_code: 0 ----- stdout ----- manual-stage.............................................................Passed default-stage............................................................Passed ----- stderr ----- "#); // Run hooks with `post-commit` stage. cmd_snapshot!(context.filters(), context.run().arg("--hook-stage").arg("post-commit"), @r#" success: true exit_code: 0 ----- stdout ----- default-stage........................................(no files to check)Skipped post-commit-stage....................................(no files to check)Skipped ----- stderr ----- "#); } #[test] fn fallback_to_manual_stage() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: manual-only name: manual-only language: system entry: echo manual-only stages: [ manual ] - id: another-manual name: another-manual language: system entry: echo another-manual stages: [ manual ] - id: default-stage name: default-stage language: system entry: echo default-stage - id: pre-push name: pre-push language: system entry: echo pre-push stages: [ pre-push ] "}); context.git_add("."); // With pre-commit hooks present, default `prek run` stays on pre-commit. cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- default-stage............................................................Passed ----- stderr ----- "); // Explicit `--hook-stage pre-commit` keeps execution scoped to that stage. cmd_snapshot!(context.filters(), context.run().arg("--hook-stage").arg("pre-commit").arg("default-stage").arg("manual-only"), @r" success: true exit_code: 0 ----- stdout ----- default-stage............................................................Passed ----- stderr ----- "); // Selecting manual + pre-commit hooks still runs only the pre-commit ones. cmd_snapshot!(context.filters(), context.run().arg("manual-only").arg("default-stage"), @r" success: true exit_code: 0 ----- stdout ----- default-stage............................................................Passed ----- stderr ----- "); // Selecting only manual hooks should still succeed via fallback. cmd_snapshot!(context.filters(), context.run().arg("manual-only").arg("another-manual"), @r" success: true exit_code: 0 ----- stdout ----- manual-only..............................................................Passed another-manual...........................................................Passed ----- stderr ----- "); // Mixing `pre-push` and manual selectors still runs the manual hook via fallback. cmd_snapshot!(context.filters(), context.run().arg("pre-push").arg("manual-only"), @r" success: true exit_code: 0 ----- stdout ----- manual-only..............................................................Passed ----- stderr ----- "); } /// Test global `files`, `exclude`, and hook level `files`, `exclude`. #[test] fn files_and_exclude() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("file.txt").write_str("Hello, world! \n")?; cwd.child("valid.json").write_str("{}\n ")?; cwd.child("invalid.json").write_str("{}")?; cwd.child("main.py").write_str(r#"print "abc" "#)?; // Global files and exclude. context.write_pre_commit_config(indoc::indoc! {r" files: file.txt repos: - repo: local hooks: - id: trailing-whitespace name: trailing whitespace language: system entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' types: [text] - id: end-of-file-fixer name: fix end of files language: system entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' types: [text] - id: check-json name: check json language: system entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' types: [json] "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- trailing whitespace......................................................Failed - hook id: trailing-whitespace - exit code: 1 ['file.txt'] fix end of files.........................................................Failed - hook id: end-of-file-fixer - exit code: 1 ['file.txt'] check json...........................................(no files to check)Skipped ----- stderr ----- "); // Override hook level files and exclude. context.write_pre_commit_config(indoc::indoc! {r" files: file.txt repos: - repo: local hooks: - id: trailing-whitespace name: trailing whitespace language: system entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' files: valid.json - id: end-of-file-fixer name: fix end of files language: system entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' exclude: (valid.json|main.py) - id: check-json name: check json language: system entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- trailing whitespace..................................(no files to check)Skipped fix end of files.........................................................Failed - hook id: end-of-file-fixer - exit code: 1 ['file.txt'] check json...............................................................Failed - hook id: check-json - exit code: 1 ['file.txt'] ----- stderr ----- "); Ok(()) } /// Test selecting files by type, `types`, `types_or`, and `exclude_types`. #[test] fn file_types() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); cwd.child("file.txt").write_str("Hello, world! ")?; cwd.child("json.json").write_str("{}\n ")?; cwd.child("main.py").write_str(r#"print "abc" "#)?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' types: ["json"] - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' types_or: ["json", "python"] - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' exclude_types: ["json"] - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c 'import sys; print(sys.argv[1:]); exit(1)' types: ["json" ] exclude_types: ["json"] "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- trailing-whitespace......................................................Failed - hook id: trailing-whitespace - exit code: 1 ['json.json'] trailing-whitespace......................................................Failed - hook id: trailing-whitespace - exit code: 1 ['main.py', 'json.json'] trailing-whitespace......................................................Failed - hook id: trailing-whitespace - exit code: 1 ['file.txt', '.pre-commit-config.yaml', 'main.py'] trailing-whitespace..................................(no files to check)Skipped ----- stderr ----- "); Ok(()) } /// Abort the run if a hook fails. #[test] fn fail_fast() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c 'print("Fixing files"); exit(1)' always_run: true fail_fast: false - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c 'print("Fixing files"); exit(1)' always_run: true fail_fast: true - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -V always_run: true - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -V always_run: true "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- trailing-whitespace......................................................Failed - hook id: trailing-whitespace - exit code: 1 Fixing files trailing-whitespace......................................................Failed - hook id: trailing-whitespace - exit code: 1 Fixing files ----- stderr ----- "); } /// Test --fail-fast CLI flag stops execution after first failure. #[test] fn fail_fast_cli_flag() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: failing-hook name: failing-hook language: system entry: python3 -c 'print("Failed"); exit(1)' always_run: true - id: passing-hook name: passing-hook language: system entry: python3 -c 'print("Passed")' always_run: true "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- failing-hook.............................................................Failed - hook id: failing-hook - exit code: 1 Failed passing-hook.............................................................Passed ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().arg("--fail-fast"), @r" success: false exit_code: 1 ----- stdout ----- failing-hook.............................................................Failed - hook id: failing-hook - exit code: 1 Failed ----- stderr ----- "); } /// Run from a subdirectory. File arguments should be fixed to be relative to the root. #[test] fn subdirectory() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); let child = cwd.child("foo/bar/baz"); child.create_dir_all()?; child.child("file.txt").write_str("Hello, world!\n")?; context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c 'import sys; print(sys.argv[1]); exit(1)' always_run: true "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run().current_dir(&child).arg("--files").arg("file.txt"), @r" success: false exit_code: 1 ----- stdout ----- trailing-whitespace......................................................Failed - hook id: trailing-whitespace - exit code: 1 foo/bar/baz/file.txt ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().arg("--cd").arg(&*child).arg("--files").arg("file.txt"), @r" success: false exit_code: 1 ----- stdout ----- trailing-whitespace......................................................Failed - hook id: trailing-whitespace - exit code: 1 foo/bar/baz/file.txt ----- stderr ----- "); Ok(()) } /// Test hook `log_file` option. #[test] fn log_file() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c 'print("Fixing files"); exit(1)' always_run: true log_file: log.txt "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: false exit_code: 1 ----- stdout ----- trailing-whitespace......................................................Failed - hook id: trailing-whitespace - exit code: 1 ----- stderr ----- "#); let log = context.read("log.txt"); assert_eq!(log, "Fixing files"); } /// Pass pre-commit environment variables to the hook. #[test] fn pass_env_vars() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: env-vars name: Pass environment language: system entry: python3 -c "import os, sys; print(os.getenv('PRE_COMMIT')); sys.exit(1)" always_run: true "#}); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- Pass environment.........................................................Failed - hook id: env-vars - exit code: 1 1 ----- stderr ----- "); } #[test] fn staged_files_only() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c 'print(open("file.txt", "rt").read())' verbose: true types: [text] "#}); context .work_dir() .child("file.txt") .write_str("Hello, world!")?; context.git_add("."); // Non-staged files should be stashed and restored. context .work_dir() .child("file.txt") .write_str("Hello world again!")?; let filters: Vec<_> = context .filters() .into_iter() .chain([(r"/\d+-\d+.patch", "/[TIME]-[PID].patch")]) .collect(); cmd_snapshot!(filters, context.run(), @r" success: true exit_code: 0 ----- stdout ----- trailing-whitespace......................................................Passed - hook id: trailing-whitespace - duration: [TIME] Hello, world! ----- stderr ----- Unstaged changes detected, stashing unstaged changes to `[HOME]/patches/[TIME]-[PID].patch` Restored working tree changes from `[HOME]/patches/[TIME]-[PID].patch` "); let content = context.read("file.txt"); assert_snapshot!(content, @"Hello world again!"); Ok(()) } #[cfg(unix)] #[test] fn restore_on_interrupt() -> Result<()> { let context = TestContext::new(); context.init_project(); // The hook will sleep for 3 seconds. context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c 'import time; open("out.txt", "wt").write(open("file.txt", "rt").read()); time.sleep(10)' verbose: true types: [text] "#}); context .work_dir() .child("file.txt") .write_str("Hello, world!")?; context.git_add("."); // Non-staged files should be stashed and restored. context .work_dir() .child("file.txt") .write_str("Hello world again!")?; let mut child = context.run().spawn()?; let child_id = child.id(); // Send an interrupt signal to the process. let handle = std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(1)); #[allow(clippy::cast_possible_wrap)] unsafe { libc::kill(child_id as i32, libc::SIGINT) }; }); handle.join().unwrap(); child.wait()?; let content = context.read("out.txt"); assert_snapshot!(content, @"Hello, world!"); let content = context.read("file.txt"); assert_snapshot!(content, @"Hello world again!"); Ok(()) } /// When in merge conflict, runs on files that have conflicts fixed. #[test] fn merge_conflicts() -> Result<()> { let context = TestContext::new(); context.init_project(); // Create a merge conflict. let cwd = context.work_dir(); cwd.child("file.txt").write_str("Hello, world!")?; context.git_add("."); context.git_commit("Initial commit"); git_cmd(cwd) .arg("checkout") .arg("-b") .arg("feature") .assert() .success(); cwd.child("file.txt").write_str("Hello, world again!")?; context.git_add("."); context.git_commit("Feature commit"); git_cmd(cwd) .arg("checkout") .arg("master") .assert() .success(); cwd.child("file.txt") .write_str("Hello, world from master!")?; context.git_add("."); context.git_commit("Master commit"); git_cmd(cwd).arg("merge").arg("feature").assert().code(1); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: trailing-whitespace name: trailing-whitespace language: system entry: python3 -c 'import sys; print(sorted(sys.argv[1:]))' verbose: true "}); // Abort on merge conflicts. cmd_snapshot!(context.filters(), context.run(), @r#" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: You have unmerged paths. Resolve them before running prek "#); // Fix the conflict and run again. context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- trailing-whitespace......................................................Passed - hook id: trailing-whitespace - duration: [TIME] ['.pre-commit-config.yaml', 'file.txt'] ----- stderr ----- "); Ok(()) } /// Local python hook with no additional dependencies. #[test] fn local_python_hook() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: local-python-hook name: local-python-hook language: python entry: python3 -c 'import sys; print("Hello, world!"); sys.exit(1)' "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 1 ----- stdout ----- local-python-hook........................................................Failed - hook id: local-python-hook - exit code: 1 Hello, world! ----- stderr ----- "); } /// Invalid `entry` #[test] fn invalid_entry() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: entry name: entry language: python entry: '"' "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to run hook `entry` caused by: Invalid hook `entry` caused by: Failed to parse entry `"` as commands "#); } /// Initialize a repo that does not exist. #[test] fn init_nonexistent_repo() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://notexistentatallnevergonnahappen.com/nonexistent/repo rev: v1.0.0 hooks: - id: nonexistent name: nonexistent "}); context.git_add("."); let filters = context .filters() .into_iter() .chain([(r"exit code: ", "exit status: "), // Normalize Git error message to handle environment-specific variations ( r"fatal: unable to access 'https://notexistentatallnevergonnahappen\.com/nonexistent/repo/':.*", r"fatal: unable to access 'https://notexistentatallnevergonnahappen.com/nonexistent/repo/': [error]" ), ]) .collect::>(); cmd_snapshot!(filters, context.run(), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to init hooks caused by: Failed to clone repo `https://notexistentatallnevergonnahappen.com/nonexistent/repo` caused by: Command `git full clone` exited with an error: [status] exit status: 128 [stderr] fatal: unable to access 'https://notexistentatallnevergonnahappen.com/nonexistent/repo/': [error] "); } /// Test hooks that specifies `types: [directory]`. #[test] fn types_directory() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: directory name: directory language: system entry: echo types: [directory] "}); context.work_dir().child("dir").create_dir_all()?; context .work_dir() .child("dir/file.txt") .write_str("Hello, world!")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: true exit_code: 0 ----- stdout ----- directory............................................(no files to check)Skipped ----- stderr ----- "#); cmd_snapshot!(context.filters(), context.run().arg("--files").arg("dir"), @r#" success: true exit_code: 0 ----- stdout ----- directory................................................................Passed ----- stderr ----- "#); cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r#" success: true exit_code: 0 ----- stdout ----- directory............................................(no files to check)Skipped ----- stderr ----- "#); cmd_snapshot!(context.filters(), context.run().arg("--files").arg("non-exist-files"), @r" success: true exit_code: 0 ----- stdout ----- directory............................................(no files to check)Skipped ----- stderr ----- warning: This file does not exist and will be ignored: `non-exist-files` "); Ok(()) } #[test] fn run_last_commit() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer "}); // Create initial files and make first commit cwd.child("file1.txt").write_str("Hello, world!\n")?; cwd.child("file2.txt") .write_str("Initial content with trailing spaces \n")?; // This has issues but won't be in last commit context.git_add("."); context.git_commit("Initial commit"); // Modify files and make second commit with trailing whitespace cwd.child("file1.txt").write_str("Hello, world! \n")?; // trailing whitespace cwd.child("file3.txt").write_str("New file")?; // missing newline // Note: file2.txt is NOT modified in this commit, so it should be filtered out by --last-commit context.git_add("."); context.git_commit("Second commit with issues"); // Run with --last-commit should only check files from the last commit // This should only process file1.txt and file3.txt, NOT file2.txt cmd_snapshot!(context.filters(), context.run().arg("--last-commit"), @r" success: false exit_code: 1 ----- stdout ----- trim trailing whitespace.................................................Failed - hook id: trailing-whitespace - exit code: 1 - files were modified by this hook Fixing file1.txt fix end of files.........................................................Failed - hook id: end-of-file-fixer - exit code: 1 - files were modified by this hook Fixing file3.txt ----- stderr ----- "); // Now reset the files to their problematic state for comparison cwd.child("file1.txt").write_str("Hello, world! \n")?; // trailing whitespace cwd.child("file3.txt").write_str("New file")?; // missing newline // Run with --all-files should check ALL files including file2.txt // This demonstrates that file2.txt was indeed filtered out in the previous test cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r" success: false exit_code: 1 ----- stdout ----- trim trailing whitespace.................................................Failed - hook id: trailing-whitespace - exit code: 1 - files were modified by this hook Fixing file1.txt Fixing file2.txt fix end of files.........................................................Failed - hook id: end-of-file-fixer - exit code: 1 - files were modified by this hook Fixing file3.txt ----- stderr ----- "); Ok(()) } /// Test `prek run --files` with multiple files. #[test] fn run_multiple_files() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: multiple-files name: multiple-files language: system entry: echo verbose: true types: [text] "}); let cwd = context.work_dir(); cwd.child("file1.txt").write_str("Hello, world!")?; cwd.child("file2.txt").write_str("Hello, world!")?; context.git_add("."); // `--files` with multiple files cmd_snapshot!(context.filters(), context.run().arg("--files").arg("file1.txt").arg("file2.txt"), @r" success: true exit_code: 0 ----- stdout ----- multiple-files...........................................................Passed - hook id: multiple-files - duration: [TIME] file2.txt file1.txt ----- stderr ----- "); Ok(()) } /// Test `prek run --files` with no files. #[test] fn run_no_files() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: no-files name: no-files language: system entry: echo verbose: true "}); context.git_add("."); // `--files` with no files cmd_snapshot!(context.filters(), context.run().arg("--files"), @r" success: true exit_code: 0 ----- stdout ----- no-files.................................................................Passed - hook id: no-files - duration: [TIME] .pre-commit-config.yaml ----- stderr ----- "); } /// Test `prek run --directory` flags. #[test] fn run_directory() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: directory name: directory language: system entry: echo verbose: true "}); let cwd = context.work_dir(); cwd.child("dir1").create_dir_all()?; cwd.child("dir1/file.txt").write_str("Hello, world!")?; cwd.child("dir2").create_dir_all()?; cwd.child("dir2/file.txt").write_str("Hello, world!")?; context.git_add("."); // one `--directory` cmd_snapshot!(context.filters(), context.run().arg("--directory").arg("dir1"), @r" success: true exit_code: 0 ----- stdout ----- directory................................................................Passed - hook id: directory - duration: [TIME] dir1/file.txt ----- stderr ----- "); // repeated `--directory` cmd_snapshot!(context.filters(), context.run().arg("--directory").arg("dir1").arg("--directory").arg("dir1"), @r" success: true exit_code: 0 ----- stdout ----- directory................................................................Passed - hook id: directory - duration: [TIME] dir1/file.txt ----- stderr ----- "); // multiple `--directory` cmd_snapshot!(context.filters(), context.run().arg("--directory").arg("dir1").arg("--directory").arg("dir2"), @r" success: true exit_code: 0 ----- stdout ----- directory................................................................Passed - hook id: directory - duration: [TIME] dir2/file.txt dir1/file.txt ----- stderr ----- "); // non-existing directory cmd_snapshot!(context.filters(), context.run().arg("--directory").arg("non-existing-dir"), @r" success: true exit_code: 0 ----- stdout ----- directory............................................(no files to check)Skipped ----- stderr ----- "); // `--directory` with `--files` cmd_snapshot!(context.filters(), context.run().arg("--directory").arg("dir1").arg("--files").arg("dir1/file.txt"), @r" success: true exit_code: 0 ----- stdout ----- directory................................................................Passed - hook id: directory - duration: [TIME] dir1/file.txt ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().arg("--directory").arg("dir1").arg("--files").arg("dir2/file.txt"), @r" success: true exit_code: 0 ----- stdout ----- directory................................................................Passed - hook id: directory - duration: [TIME] dir2/file.txt dir1/file.txt ----- stderr ----- "); // run `--directory` inside a subdirectory cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join("dir1")).arg("--directory").arg("."), @r" success: true exit_code: 0 ----- stdout ----- directory................................................................Passed - hook id: directory - duration: [TIME] dir1/file.txt ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().arg("--cd").arg("dir1").arg("--directory").arg("."), @r" success: true exit_code: 0 ----- stdout ----- directory................................................................Passed - hook id: directory - duration: [TIME] dir1/file.txt ----- stderr ----- "); Ok(()) } /// Test `minimum_prek_version` option. #[test] fn minimum_prek_version() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" minimum_prek_version: 10.0.0 repos: - repo: local hooks: - id: directory name: directory language: system entry: echo verbose: true "}); context.git_add("."); let filters = context .filters() .into_iter() .chain([( r"current version `\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?`", "current version `[CURRENT_VERSION]`", )]) .collect::>(); cmd_snapshot!(filters, context.run(), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to parse `.pre-commit-config.yaml` caused by: error: line 1 column 23: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek --> :1:23 | 1 | minimum_prek_version: 10.0.0 | ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`; Please consider updating prek 2 | repos: 3 | - repo: local | "); } /// Run hooks that would echo color. #[test] #[cfg(not(windows))] fn color() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: color name: color language: python entry: python ./color.py verbose: true pass_filenames: false "}); let script = indoc::indoc! {r" import sys if sys.stdout.isatty(): print('\033[1;32mHello, world!\033[0m') else: print('Hello, world!') sys.stdout.flush() "}; context.work_dir().child("color.py").write_str(script)?; context.git_add("."); // Run default. In integration tests, we don't have a TTY. // So this prints without color. cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- color....................................................................Passed - hook id: color - duration: [TIME] Hello, world! ----- stderr ----- "); // Force color output cmd_snapshot!(context.filters(), context.run().arg("--color=always"), @r" success: true exit_code: 0 ----- stdout ----- color....................................................................Passed - hook id: color - duration: [TIME] Hello, world! ----- stderr ----- "); Ok(()) } /// Test running hook whose `entry` is script with shebang on Windows. #[test] fn shebang_script() -> Result<()> { let context = TestContext::new(); context.init_project(); // Create a script with shebang. let script = indoc::indoc! {r" #!/usr/bin/env python import sys print('Hello, world!') sys.exit(0) "}; context.work_dir().child("script.py").write_str(script)?; context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: shebang-script name: shebang-script language: python entry: script.py verbose: true pass_filenames: false always_run: true "}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- shebang-script...........................................................Passed - hook id: shebang-script - duration: [TIME] Hello, world! ----- stderr ----- "); Ok(()) } /// Test `git commit -a` works without `.git/index.lock exists` error. #[test] fn git_commit_a() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: echo name: echo language: system entry: echo verbose: true "}); // Create a file and commit it. let cwd = context.work_dir(); let file = cwd.child("file.txt"); file.write_str("Hello, world!\n")?; cmd_snapshot!(context.filters(), context.install(), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); context.git_add("."); context.git_commit("Initial commit"); // Edit the file file.write_str("Hello, world again!\n")?; let mut commit = git_cmd(cwd); commit .arg("commit") .arg("-a") .arg("-m") .arg("Update file") .env(EnvVars::PREK_HOME, &**context.home_dir()); let filters = context .filters() .into_iter() .chain([ (r"\[master \w{7}\]", r"[master COMMIT]"), ("7c8398204bbc95c33a6d2543f86a27621647cf78", "[HASH]"), ]) .collect::>(); cmd_snapshot!(filters, commit, @r" success: true exit_code: 0 ----- stdout ----- [master COMMIT] Update file 1 file changed, 1 insertion(+), 1 deletion(-) ----- stderr ----- echo.....................................................................Passed - hook id: echo - duration: [TIME] file.txt "); Ok(()) } #[cfg(unix)] #[test] fn git_commit_a_currently_fails_when_hook_writes_to_temp_git_index() -> Result<()> { let context = TestContext::new(); context.init_project(); // Repro for #1786 documenting the current behavior. // `git commit -a` exports `GIT_INDEX_FILE=.git/index.lock` to the pre-commit hook // process. If the hook inherits that env var and then runs a git command that writes // to an index in a different repository, Git will write those entries into the parent // repo's temporary index instead. // // The important detail is that the temp repo stages `file.txt`, matching a tracked // path in the parent repo. That makes prek's post-hook `git diff` read the corrupted // parent index entry and fail with `fatal: unable to read `. context .work_dir() .child("hook.sh") .write_str(indoc::indoc! {r#" set -eu tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT cd "$tmpdir" git init >/dev/null 2>&1 printf 'hook version\n' > file.txt git add file.txt "#})?; context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: write-temp-index name: write-temp-index language: system entry: sh hook.sh pass_filenames: false always_run: true verbose: true "}); let cwd = context.work_dir(); let file = cwd.child("file.txt"); file.write_str("Hello, world!\n")?; cmd_snapshot!(context.filters(), context.install(), @r#" success: true exit_code: 0 ----- stdout ----- prek installed at `.git/hooks/pre-commit` ----- stderr ----- "#); context.git_add("."); context.git_commit("Initial commit"); // `git commit` does not set `GIT_INDEX_FILE`; `git commit -a` does. // The repro only triggers on the `-a` path. file.write_str("Hello again!\n")?; let mut commit = git_cmd(cwd); commit .arg("commit") .arg("-a") .arg("-m") .arg("Update file") .env(EnvVars::PREK_HOME, &**context.home_dir()); let filters = context .filters() .into_iter() .chain([ (r"\[master \w{7}\]", r"[master COMMIT]"), ( r"fatal: unable to read [0-9a-f]{40}", "fatal: unable to read [HASH]", ), ]) .collect::>(); cmd_snapshot!(filters, commit, @r" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- error: Command `git diff` exited with an error: [status] exit status: 128 [stderr] fatal: unable to read [HASH] " ); Ok(()) } fn write_pre_commit_config(path: &Path, hooks: &[(&str, &str)]) -> Result<()> { let mut yaml = String::from(indoc::indoc! {" repos: - repo: local hooks: "}); for (id, name) in hooks { let hook = textwrap::indent( &indoc::formatdoc! {" - id: {} name: {} entry: echo language: system ", id, name }, " ", ); yaml.push_str(&hook); } std::fs::create_dir_all(path)?; std::fs::write(path.join(PRE_COMMIT_CONFIG_YAML), yaml)?; Ok(()) } #[cfg(unix)] #[test] fn selectors_completion() -> Result<()> { let context = TestContext::new(); let cwd = context.work_dir(); context.init_project(); // Root project with one hook write_pre_commit_config(cwd, &[("root-hook", "Root Hook")])?; // Nested project at app/ with one hook let app = cwd.join("app"); write_pre_commit_config(&app, &[("app-hook", "App Hook")])?; // Deeper nested project at app/lib/ with one hook let app_lib = app.join("lib"); write_pre_commit_config(&app_lib, &[("lib-hook", "Lib Hook")])?; // Unrelated non-project dir should not appear in subdir suggestions cwd.child("scratch").create_dir_all()?; cmd_snapshot!(context.filters(), context.run().env("COMPLETE", "fish").arg("--").arg("prek").arg(""), @" success: true exit_code: 0 ----- stdout ----- install Install prek Git shims under the `.git/hooks/` directory prepare-hooks Prepare environments for all hooks used in the config file run Run hooks list List hooks configured in the current workspace uninstall Uninstall prek Git shims validate-config Validate configuration files (prek.toml or .pre-commit-config.yaml) validate-manifest Validate `.pre-commit-hooks.yaml` files sample-config Produce a sample configuration file (prek.toml or .pre-commit-config.yaml) auto-update Auto-update the `rev` field of repositories in the config file to the latest version cache Manage the prek cache try-repo Try the pre-commit hooks in the current repo util Utility commands self `prek` self management app/ app: app-hook App Hook lib-hook Lib Hook root-hook Root Hook --skip Skip the specified hooks or projects --all-files Run on all files in the repo --files Specific filenames to run hooks on --directory Run hooks on all files in the specified directories --from-ref The original ref in a `...` diff expression. Files changed in this diff will be run through the hooks --to-ref The destination ref in a `from_ref...to_ref` diff expression. Defaults to `HEAD` if `from_ref` is specified --last-commit Run hooks against the last commit. Equivalent to `--from-ref HEAD~1 --to-ref HEAD` --stage The stage during which the hook is fired --show-diff-on-failure When hooks fail, run `git diff` directly afterward --fail-fast Stop running hooks after the first failure --dry-run Do not run the hooks, but print the hooks that would have been run --config Path to alternate config file --cd Change to directory before running --color Whether to use color in output --refresh Refresh all cached data --help Display the concise help for this command --no-progress Hide all progress outputs --quiet Use quiet output --verbose Use verbose output --log-file Write trace logs to the specified file. If not specified, trace logs will be written to `$PREK_HOME/prek.log` --version Display the prek version ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().env("COMPLETE", "fish").arg("--").arg("prek").arg("ap"), @r" success: true exit_code: 0 ----- stdout ----- app/ app: app-hook App Hook ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().env("COMPLETE", "fish").arg("--").arg("prek").arg("app:"), @r" success: true exit_code: 0 ----- stdout ----- app:app-hook App Hook ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().env("COMPLETE", "fish").arg("--").arg("prek").arg("app:app"), @r" success: true exit_code: 0 ----- stdout ----- app:app-hook App Hook ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().env("COMPLETE", "fish").arg("--").arg("prek").arg("app/"), @r" success: true exit_code: 0 ----- stdout ----- app/lib/ app/lib: ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().env("COMPLETE", "fish").arg("--").arg("prek").arg("app/li"), @r" success: true exit_code: 0 ----- stdout ----- app/lib/ app/lib: ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().env("COMPLETE", "fish").arg("--").arg("prek").arg("app/lib:"), @r" success: true exit_code: 0 ----- stdout ----- app/lib:lib-hook Lib Hook ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().env("COMPLETE", "fish").arg("--").arg("prek").arg("app/lib/"), @r" success: true exit_code: 0 ----- stdout ----- app/lib/ ----- stderr ----- "); Ok(()) } /// Test reusing hook environments only when dependencies are exactly same. (ignore order) #[test] fn reuse_env() -> Result<()> { let context = TestContext::new(); context.init_project(); let pkg_dir = context.work_dir().child("local_pkg"); pkg_dir.create_dir_all()?; pkg_dir.child("setup.py").write_str(indoc::indoc! {r#" from setuptools import setup setup( name="local-pkg", version="0.1.0", py_modules=["local_pkg"], ) "#})?; pkg_dir .child("local_pkg.py") .write_str("def hello():\n print('hello')\n")?; context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: reuse-env name: reuse-env language: python entry: python -c "import local_pkg; local_pkg.hello()" pass_filenames: false additional_dependencies: ["./local_pkg"] verbose: true "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- reuse-env................................................................Passed - hook id: reuse-env - duration: [TIME] hello ----- stderr ----- "); // Remove dependencies, so the environment should not be reused. context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: reuse-env name: reuse-env language: python entry: python -c "print('ok')" pass_filenames: false verbose: true "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- reuse-env................................................................Passed - hook id: reuse-env - duration: [TIME] ok ----- stderr ----- "); // There should be two hook environments. assert_eq!(context.home_dir().child("hooks").read_dir()?.count(), 2); Ok(()) } #[test] fn dry_run() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: fail name: fail entry: fail language: fail "}); context.git_add("."); // Run with `--dry-run` cmd_snapshot!(context.filters(), context.run().arg("--dry-run").arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- fail....................................................................Dry Run - hook id: fail - duration: [TIME] `fail` would be run on 1 files: - .pre-commit-config.yaml ----- stderr ----- "); } /// Supports reading `pre-commit-config.yml` as well. #[test] fn alternate_config_file() -> Result<()> { let context = TestContext::new(); context.init_project(); context .work_dir() .child(PRE_COMMIT_CONFIG_YML) .write_str(indoc::indoc! {r#" repos: - repo: local hooks: - id: local-python-hook name: local-python-hook language: python entry: python3 -c 'import sys; print("Hello, world!")' "#})?; context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- local-python-hook........................................................Passed - hook id: local-python-hook - duration: [TIME] Hello, world! ----- stderr ----- "); context .work_dir() .child(PRE_COMMIT_CONFIG_YAML) .write_str(indoc::indoc! {r#" repos: - repo: local hooks: - id: local-python-hook name: local-python-hook language: python entry: python3 -c 'import sys; print("Hello, world!")' "#})?; context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("--refresh").arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- local-python-hook........................................................Passed - hook id: local-python-hook - duration: [TIME] Hello, world! ----- stderr ----- warning: Multiple configuration files found (`.pre-commit-config.yaml`, `.pre-commit-config.yml`); using `[TEMP_DIR]/.pre-commit-config.yaml` "); context .work_dir() .child(PREK_TOML) .write_str(indoc::indoc! {r#" [[repos]] repo = "local" hooks = [ { id = "local-python-hook", name = "local-python-hook", language = "python", entry = "python3 -c 'import sys; print(\"Hello, world!\")'" } ] "#})?; context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("--refresh").arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- local-python-hook........................................................Passed - hook id: local-python-hook - duration: [TIME] Hello, world! ----- stderr ----- warning: Multiple configuration files found (`prek.toml`, `.pre-commit-config.yaml`, `.pre-commit-config.yml`); using `[TEMP_DIR]/prek.toml` "); Ok(()) } /// Supports `prek.toml` as configuration file. #[test] fn prek_toml() -> Result<()> { let context = TestContext::new(); context.init_project(); context .work_dir() .child(PREK_TOML) .write_str(indoc::indoc! {r#" [[repos]] repo = "local" hooks = [ { id = "local-python-hook", name = "local-python-hook", language = "python", entry = "python3 -c 'import sys; print(\"Hello, world!\")'" } ] "#})?; context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("-v"), @r" success: true exit_code: 0 ----- stdout ----- local-python-hook........................................................Passed - hook id: local-python-hook - duration: [TIME] Hello, world! ----- stderr ----- "); Ok(()) } #[test] fn show_diff_on_failure() -> Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc::indoc! {r#" repos: - repo: local hooks: - id: modify name: modify language: python entry: python -c "import sys; open('file.txt', 'a').write('Added line\n')" pass_filenames: false "#}; context.write_pre_commit_config(config); context .work_dir() .child("file.txt") .write_str("Original line\n")?; context.git_add("."); let mut filters = context.filters(); filters.push((r"index \w{7}\.\.\w{7} \d{6}", "index [OLD]..[NEW] 100644")); // When failed in CI environment cmd_snapshot!(filters.clone(), context.run().env(EnvVars::CI, "1").arg("--show-diff-on-failure").arg("-v"), @" success: false exit_code: 1 ----- stdout ----- modify...................................................................Failed - hook id: modify - duration: [TIME] - files were modified by this hook hint: Some hooks made changes to the files. If you are seeing this message in CI, reproduce locally with: `prek run --all-files` To run prek as part of Git workflow, use `prek install` to set up Git shims. All changes made by hooks: diff --git a/file.txt b/file.txt index [OLD]..[NEW] 100644 --- a/file.txt +++ b/file.txt @@ -1 +1,2 @@ Original line +Added line ----- stderr ----- "); context .work_dir() .child("file.txt") .write_str("Original line\n")?; context.git_add("."); // When failed in non-CI environment cmd_snapshot!(filters.clone(), context.run().env_remove(EnvVars::CI).arg("--show-diff-on-failure").arg("-v"), @r" success: false exit_code: 1 ----- stdout ----- modify...................................................................Failed - hook id: modify - duration: [TIME] - files were modified by this hook All changes made by hooks: diff --git a/file.txt b/file.txt index [OLD]..[NEW] 100644 --- a/file.txt +++ b/file.txt @@ -1 +1,2 @@ Original line +Added line ----- stderr ----- "); // Run in the `app` subproject. let app = context.work_dir().child("app"); app.create_dir_all()?; app.child("file.txt").write_str("Original line\n")?; app.child(PRE_COMMIT_CONFIG_YAML).write_str(config)?; git_cmd(&app).arg("add").arg(".").assert().success(); cmd_snapshot!(filters.clone(), context.run().env_remove(EnvVars::CI).current_dir(&app).arg("--show-diff-on-failure"), @r" success: false exit_code: 1 ----- stdout ----- modify...................................................................Failed - hook id: modify - files were modified by this hook All changes made by hooks: diff --git a/app/file.txt b/app/file.txt index [OLD]..[NEW] 100644 --- a/app/file.txt +++ b/app/file.txt @@ -1 +1,2 @@ Original line +Added line ----- stderr ----- "); context.git_add("."); // Run in the root // Since we add a new subproject, use `--refresh` to find that. cmd_snapshot!(filters.clone(), context.run().env_remove(EnvVars::CI).arg("--show-diff-on-failure").arg("--refresh"), @r" success: false exit_code: 1 ----- stdout ----- Running hooks for `app`: modify...................................................................Failed - hook id: modify - files were modified by this hook Running hooks for `.`: modify...................................................................Failed - hook id: modify - files were modified by this hook All changes made by hooks: diff --git a/app/file.txt b/app/file.txt index [OLD]..[NEW] 100644 --- a/app/file.txt +++ b/app/file.txt @@ -1,2 +1,3 @@ Original line Added line +Added line diff --git a/file.txt b/file.txt index [OLD]..[NEW] 100644 --- a/file.txt +++ b/file.txt @@ -1,2 +1,3 @@ Original line Added line +Added line ----- stderr ----- "); Ok(()) } #[test] fn run_quiet() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: success name: success entry: echo language: system - id: fail name: fail entry: fail language: fail "}); context.git_add("."); // Run with `--quiet`, only print failed hooks. cmd_snapshot!(context.filters(), context.run().arg("--quiet"), @r" success: false exit_code: 1 ----- stdout ----- fail.....................................................................Failed - hook id: fail - exit code: 1 fail .pre-commit-config.yaml ----- stderr ----- "); // Run with `-qq`, do not print anything. cmd_snapshot!(context.filters(), context.run().arg("-qq"), @r" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- "); } /// Test `PREK_QUIET` environment variable. #[test] fn run_quiet_env() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: success name: success entry: echo language: system - id: fail name: fail entry: fail language: fail "}); context.git_add("."); // Run with `PREK_QUIET=1`, only print failed hooks. cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_QUIET, "1"), @r" success: false exit_code: 1 ----- stdout ----- fail.....................................................................Failed - hook id: fail - exit code: 1 fail .pre-commit-config.yaml ----- stderr ----- "); // Run with `PREK_QUIET=2`, does not print anything (silent mode). cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_QUIET, "2"), @r" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- "); } /// Test `prek run --log-file ` flag. #[test] fn run_log_file() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: fail name: fail entry: fail language: fail "}); context.git_add("."); // Run with `--no-log-file`, no `prek.log` is created. cmd_snapshot!(context.filters(), context.run().arg("--no-log-file"), @r" success: false exit_code: 1 ----- stdout ----- fail.....................................................................Failed - hook id: fail - exit code: 1 fail .pre-commit-config.yaml ----- stderr ----- "); context .home_dir() .child("prek.log") .assert(predicate::path::missing()); // Write log to `log`. cmd_snapshot!(context.filters(), context.run().arg("--log-file").arg("log"), @r" success: false exit_code: 1 ----- stdout ----- fail.....................................................................Failed - hook id: fail - exit code: 1 fail .pre-commit-config.yaml ----- stderr ----- "); context .work_dir() .child("log") .assert(predicate::path::exists()); } /// Test `language_version: system` works and disables downloading. #[test] fn system_language_version() { if !EnvVars::is_set(EnvVars::CI) { // Skip when not running in CI, as we may not have toolchains installed locally. return; } let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: system-node name: system-node language: node language_version: system entry: node -v pass_filenames: false - id: system-go name: system-go language: golang language_version: system entry: go version pass_filenames: false - id: system-bun name: system-bun language: bun language_version: system entry: bun -e 'console.log(`Bun ${Bun.version}`)' pass_filenames: false "}); context.git_add("."); // Binaries can't be found, `system` must fail. cmd_snapshot!( context.filters(), context.run() .arg("system-node") .env(EnvVars::PREK_INTERNAL__GO_BINARY_NAME, "go-never-exist") .env(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME, "node-never-exist") .env(EnvVars::PREK_INTERNAL__BUN_BINARY_NAME, "bun-never-exist"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to install hook `system-node` caused by: Failed to install node caused by: No suitable system Node version found and downloads are disabled "); cmd_snapshot!( context.filters(), context.run() .arg("system-go") .env(EnvVars::PREK_INTERNAL__GO_BINARY_NAME, "go-never-exist") .env(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME, "node-never-exist") .env(EnvVars::PREK_INTERNAL__BUN_BINARY_NAME, "bun-never-exist"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to install hook `system-go` caused by: Failed to install go caused by: No suitable system Go version found and downloads are disabled "); cmd_snapshot!( context.filters(), context.run() .arg("system-bun") .env(EnvVars::PREK_INTERNAL__GO_BINARY_NAME, "go-never-exist") .env(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME, "node-never-exist") .env(EnvVars::PREK_INTERNAL__BUN_BINARY_NAME, "bun-never-exist"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to install hook `system-bun` caused by: Failed to install bun caused by: No suitable system Bun version found and downloads are disabled "); } /// Tests that empty `entry` field. #[test] fn empty_entry() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: local name: local language: python entry: '' pass_filenames: false "}); context.git_add("."); // Go and Node can't be found, `system` must fail. cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to run hook `local` caused by: Invalid hook `local` caused by: Failed to parse entry: entry is empty "); } /// Test that hooks are run with stdin closed. #[test] fn run_with_stdin_closed() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: check-stdin name: check-stdin language: python entry: python -c 'import sys; sys.stdin.read(); print("STDIN closed"); sys.stdout.flush()' pass_filenames: false verbose: true "#}); context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- check-stdin..............................................................Passed - hook id: check-stdin - duration: [TIME] STDIN closed ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().arg("--color").arg("always"), @r" success: true exit_code: 0 ----- stdout ----- check-stdin..............................................................Passed - hook id: check-stdin - duration: [TIME] STDIN closed ----- stderr ----- "); } /// Test `prek --version` outputs version info. #[test] fn version_info() { // skip if not built in the git repository if option_env!("PREK_COMMIT_HASH").is_none() { return; } let context = TestContext::new(); let filters = context .filters() .into_iter() .chain([( r"prek \d+\.\d+\.\d+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+\d+)? \(\w{9} [\d\-T:\.]+\)", "prek [CURRENT_VERSION] ([COMMIT] [DATE])", )]) .collect::>(); cmd_snapshot!(filters, context.command().arg("--version"), @r" success: true exit_code: 0 ----- stdout ----- prek [CURRENT_VERSION] ([COMMIT] [DATE]) ----- stderr ----- "); } #[test] fn expands_tilde_in_prek_home() -> Result<()> { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: ok name: ok entry: echo ok language: system "}); context.git_add("."); let fake_home = context.work_dir().child("fake-home"); fake_home.create_dir_all()?; cmd_snapshot!(context.filters(), context .run() .env("HOME", fake_home.path()) .env("USERPROFILE", fake_home.path()) // For Windows .env(EnvVars::PREK_HOME, "~/prek-store"), @r" success: true exit_code: 0 ----- stdout ----- ok.......................................................................Passed ----- stderr ----- "); let store = fake_home.child("prek-store"); store.child("README").assert(predicate::path::exists()); store.child("repos").assert(predicate::path::is_dir()); store.child("hooks").assert(predicate::path::is_dir()); store.child("scratch").assert(predicate::path::is_dir()); // Ensure we didn't create a literal `./~` directory under the project. context .work_dir() .child("~") .assert(predicate::path::missing()); Ok(()) } #[test] fn run_with_tree_object_as_ref() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local hooks: - id: echo-files name: echo files entry: echo language: system pass_filenames: true "}); // Create initial commit cwd.child("file1.txt").write_str("hello")?; context.git_add("."); context.git_commit("Initial commit"); // Create some changes and stage them cwd.child("file2.txt").write_str("world")?; context.git_add("file2.txt"); // Get the tree object from the staged changes let tree_output = git_cmd(cwd) .arg("write-tree") .output() .expect("Failed to run git write-tree"); let tree_sha = String::from_utf8_lossy(&tree_output.stdout) .trim() .to_string(); // Run prek with tree object as to-ref (should work with .. syntax) cmd_snapshot!(context.filters(), context.run() .arg("--from-ref").arg("HEAD") .arg("--to-ref").arg(&tree_sha), @r" success: true exit_code: 0 ----- stdout ----- echo files...............................................................Passed ----- stderr ----- "); Ok(()) } /// `pass_filenames: n` limits each invocation to at most n files. /// With n=1, each matched file gets its own invocation. #[test] fn pass_filenames_1_limits_batch_size() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); // Use a script that errors if it receives more than one filename argument. context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: one-at-a-time name: one at a time entry: python -c "import sys; args = sys.argv[1:]; sys.exit(0 if len(args) <= 1 else 1)" language: system pass_filenames: 1 require_serial: true verbose: true "#}); cwd.child("a.txt").write_str("a")?; cwd.child("b.txt").write_str("b")?; cwd.child("c.txt").write_str("c")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r" success: true exit_code: 0 ----- stdout ----- one at a time............................................................Passed - hook id: one-at-a-time - duration: [TIME] ----- stderr ----- "); Ok(()) } /// `pass_filenames: n` limits each invocation to at most n files. /// With n=2 and more than 2 matching files, multiple batches are spawned. #[test] fn pass_filenames_2_limits_batch_size() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); // Use a script that errors if it receives more than two filename arguments. context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: two-at-a-time name: two at a time entry: python -c "import sys; args = sys.argv[1:]; sys.exit(0 if len(args) <= 2 else 1)" language: system pass_filenames: 2 require_serial: true verbose: true "#}); cwd.child("a.txt").write_str("a")?; cwd.child("b.txt").write_str("b")?; cwd.child("c.txt").write_str("c")?; cwd.child("d.txt").write_str("d")?; cwd.child("e.txt").write_str("e")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r" success: true exit_code: 0 ----- stdout ----- two at a time............................................................Passed - hook id: two-at-a-time - duration: [TIME] ----- stderr ----- "); Ok(()) } ================================================ FILE: crates/prek/tests/sample_config.rs ================================================ use prek_consts::PRE_COMMIT_CONFIG_YAML; use crate::common::{TestContext, cmd_snapshot}; mod common; #[test] fn sample_config() -> anyhow::Result<()> { let context = TestContext::new(); cmd_snapshot!(context.filters(), context.sample_config(), @" success: true exit_code: 0 ----- stdout ----- # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: 'https://github.com/pre-commit/pre-commit-hooks' rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files ----- stderr ----- "); cmd_snapshot!(context.filters(), context.sample_config().arg("-f"), @r#" success: true exit_code: 0 ----- stdout ----- Written to `.pre-commit-config.yaml` ----- stderr ----- "#); insta::assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r##" # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: 'https://github.com/pre-commit/pre-commit-hooks' rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files "##); cmd_snapshot!(context.filters(), context.sample_config().arg("-f").arg("sample.yaml"), @r#" success: true exit_code: 0 ----- stdout ----- Written to `sample.yaml` ----- stderr ----- "#); insta::assert_snapshot!(context.read("sample.yaml"), @r##" # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: 'https://github.com/pre-commit/pre-commit-hooks' rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files "##); let child = context.work_dir().join("child"); std::fs::create_dir(&child)?; cmd_snapshot!(context.filters(), context.sample_config().current_dir(&*child).arg("-f").arg("sample.yaml"), @r#" success: true exit_code: 0 ----- stdout ----- Written to `sample.yaml` ----- stderr ----- "#); insta::assert_snapshot!(context.read("child/sample.yaml"), @r##" # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: 'https://github.com/pre-commit/pre-commit-hooks' rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files "##); Ok(()) } #[test] fn sample_config_toml() { let context = TestContext::new(); cmd_snapshot!(context.filters(), context.sample_config().arg("-f").arg("prek.toml"), @r#" success: true exit_code: 0 ----- stdout ----- Written to `prek.toml` ----- stderr ----- "#); insta::assert_snapshot!(context.read("prek.toml"), @r#" # Configuration file for `prek`, a git hook framework written in Rust. # See https://prek.j178.dev for more information. #:schema https://www.schemastore.org/prek.json [[repos]] repo = "builtin" hooks = [ { id = "trailing-whitespace" }, { id = "end-of-file-fixer" }, { id = "check-added-large-files" }, ] "#); } #[test] fn sample_config_format() { let context = TestContext::new(); cmd_snapshot!(context.filters(), context.sample_config().arg("--format").arg("toml"), @r#" success: true exit_code: 0 ----- stdout ----- # Configuration file for `prek`, a git hook framework written in Rust. # See https://prek.j178.dev for more information. #:schema https://www.schemastore.org/prek.json [[repos]] repo = "builtin" hooks = [ { id = "trailing-whitespace" }, { id = "end-of-file-fixer" }, { id = "check-added-large-files" }, ] ----- stderr ----- "#); cmd_snapshot!(context.filters(), context.sample_config().arg("--format").arg("yaml"), @" success: true exit_code: 0 ----- stdout ----- # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: 'https://github.com/pre-commit/pre-commit-hooks' rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files ----- stderr ----- "); cmd_snapshot!(context.filters(), context.sample_config().arg("--format").arg("json"), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: invalid value 'json' for '--format ' [possible values: yaml, toml] For more information, try '--help'. "); } #[test] fn respect_format() { let context = TestContext::new(); // Write YAML format even with `.toml` extension. cmd_snapshot!(context.filters(), context.sample_config().arg("--format").arg("yaml").arg("-f").arg("prek.toml"), @" success: true exit_code: 0 ----- stdout ----- Written to `prek.toml` ----- stderr ----- "); insta::assert_snapshot!(context.read("prek.toml"), @" # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: 'https://github.com/pre-commit/pre-commit-hooks' rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files "); } #[test] fn respect_format_if_filename_missing() { let context = TestContext::new(); // Create `prek.toml` when TOML format is specified but filename is not given. cmd_snapshot!(context.filters(), context.sample_config().arg("--format").arg("toml").arg("-f"), @" success: true exit_code: 0 ----- stdout ----- Written to `prek.toml` ----- stderr ----- "); insta::assert_snapshot!(context.read("prek.toml"), @r#" # Configuration file for `prek`, a git hook framework written in Rust. # See https://prek.j178.dev for more information. #:schema https://www.schemastore.org/prek.json [[repos]] repo = "builtin" hooks = [ { id = "trailing-whitespace" }, { id = "end-of-file-fixer" }, { id = "check-added-large-files" }, ] "#); } ================================================ FILE: crates/prek/tests/skipped_hooks.rs ================================================ //! Integration tests for hook skip behavior. //! //! These tests verify that prek correctly identifies and reports skipped hooks //! in various scenarios: file pattern mismatches, dry-run mode, and mixed //! execution across priority groups. //! //! Includes regression tests for #1335: when all hooks in a group are skipped, //! prek should not call `git diff` to check for file modifications. use anyhow::Result; use assert_fs::prelude::*; use crate::common::{TestContext, cmd_snapshot}; mod common; /// All hooks skip when no staged files match their file patterns. #[test] fn all_hooks_skipped_no_matching_files() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: python-check name: python-check language: system entry: echo "checking python" files: \.py$ - id: rust-check name: rust-check language: system entry: echo "checking rust" files: \.rs$ - id: go-check name: go-check language: system entry: echo "checking go" files: \.go$ "#}); cwd.child("readme.txt").write_str("Hello")?; cwd.child("data.json").write_str("{}")?; cwd.child("config.yaml").write_str("key: value")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: true exit_code: 0 ----- stdout ----- python-check.........................................(no files to check)Skipped rust-check...........................................(no files to check)Skipped go-check.............................................(no files to check)Skipped ----- stderr ----- "#); Ok(()) } /// `--dry-run` skips hooks without executing them. #[test] fn dry_run_skips_all_hooks() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: formatter name: formatter language: system entry: python3 -c "import sys; open(sys.argv[1], 'a').write('modified')" files: \.txt$ - id: linter name: linter language: system entry: echo "linting" files: \.txt$ "#}); cwd.child("file.txt").write_str("content")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("--dry-run"), @r#" success: true exit_code: 0 ----- stdout ----- formatter...............................................................Dry Run linter..................................................................Dry Run ----- stderr ----- "#); assert_eq!(context.read("file.txt"), "content"); Ok(()) } /// Hooks that match staged files run; others are skipped. #[test] fn mixed_skipped_and_executed_hooks() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: txt-check name: txt-check language: system entry: echo "checking txt" files: \.txt$ - id: py-check name: py-check language: system entry: echo "checking py" files: \.py$ - id: rs-check name: rs-check language: system entry: echo "checking rs" files: \.rs$ "#}); cwd.child("readme.txt").write_str("Hello")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run(), @r#" success: true exit_code: 0 ----- stdout ----- txt-check................................................................Passed py-check.............................................(no files to check)Skipped rs-check.............................................(no files to check)Skipped ----- stderr ----- "#); Ok(()) } /// Skipped hooks across multiple priority groups /// /// Hooks with different `priority` values form separate priority groups. Each /// group is processed sequentially. This test verifies: /// 1. Skip behavior works correctly across group boundaries /// 2. `git diff` is only called once (initial baseline), not per-group /// /// Note: This test uses manual output capture instead of `cmd_snapshot!` because /// we need to count `get_diff` occurrences in trace-level stderr. Trace output /// contains non-deterministic timestamps and timing data unsuitable for snapshots. #[test] fn all_hooks_skipped_multiple_priority_groups() -> Result<()> { let context = TestContext::new(); context.init_project(); let cwd = context.work_dir(); context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local hooks: - id: priority-10 name: priority-10 language: system entry: echo "priority 10" files: \.py$ priority: 10 - id: priority-20 name: priority-20 language: system entry: echo "priority 20" files: \.rs$ priority: 20 - id: priority-30 name: priority-30 language: system entry: echo "priority 30" files: \.go$ priority: 30 "#}); cwd.child("data.json").write_str("{}")?; context.git_add("."); // Run with trace logging to verify #1335 fix let output = context.run().env("RUST_LOG", "prek::git=trace").output()?; assert!(output.status.success(), "prek should succeed"); // Verify all hooks skipped let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("priority-10") && stdout.contains("Skipped")); assert!(stdout.contains("priority-20") && stdout.contains("Skipped")); assert!(stdout.contains("priority-30") && stdout.contains("Skipped")); // Regression test for #1335: only 1 get_diff call (initial baseline) // Without fix: 4 calls (1 initial + 3 per priority group) let stderr = String::from_utf8_lossy(&output.stderr); let get_diff_calls = stderr.matches("get_diff").count(); assert_eq!( get_diff_calls, 1, "Expected 1 get_diff call (initial baseline) when all hooks skip, found {get_diff_calls}.\n\ Trace output:\n{stderr}" ); Ok(()) } ================================================ FILE: crates/prek/tests/try_repo.rs ================================================ mod common; use anyhow::Result; use assert_cmd::assert::OutputAssertExt; use assert_fs::prelude::*; use std::path::PathBuf; use crate::common::{TestContext, cmd_snapshot, git_cmd}; use assert_fs::fixture::ChildPath; use prek_consts::PRE_COMMIT_HOOKS_YAML; fn create_hook_repo(context: &TestContext, repo_name: &str) -> Result { let repo_dir = context.home_dir().child(format!("test-repos/{repo_name}")); repo_dir.create_dir_all()?; git_cmd(&repo_dir).arg("init").assert().success(); // Configure the author specifically for this hook repository git_cmd(&repo_dir) .arg("config") .arg("user.name") .arg("Prek Test") .assert() .success(); git_cmd(&repo_dir) .arg("config") .arg("user.email") .arg("test@prek.dev") .assert() .success(); // Disable autocrlf for test consistency git_cmd(&repo_dir) .arg("config") .arg("core.autocrlf") .arg("false") .assert() .success(); repo_dir .child(PRE_COMMIT_HOOKS_YAML) .write_str(indoc::indoc! {r#" - id: test-hook name: Test Hook entry: echo language: system files: "\\.txt$" - id: another-hook name: Another Hook entry: python3 -c "print('hello')" language: python "#})?; // Add a dummy setup.py to make it an installable Python package repo_dir .child("setup.py") .write_str("from setuptools import setup; setup(name='dummy-pkg', version='0.0.1')")?; git_cmd(&repo_dir).arg("add").arg(".").assert().success(); git_cmd(&repo_dir) .arg("commit") .arg("-m") .arg("Initial commit") .assert() .success(); Ok(repo_dir.to_path_buf()) } // Helper for a repo with a hook that is designed to fail fn create_failing_hook_repo(context: &TestContext, repo_name: &str) -> Result { let repo_dir = context.home_dir().child(format!("test-repos/{repo_name}")); repo_dir.create_dir_all()?; git_cmd(&repo_dir).arg("init").assert().success(); git_cmd(&repo_dir) .arg("config") .arg("user.name") .arg("Prek Test") .assert() .success(); git_cmd(&repo_dir) .arg("config") .arg("user.email") .arg("test@prek.dev") .assert() .success(); // Disable autocrlf for test consistency git_cmd(&repo_dir) .arg("config") .arg("core.autocrlf") .arg("false") .assert() .success(); repo_dir .child(PRE_COMMIT_HOOKS_YAML) .write_str(indoc::indoc! {r#" - id: failing-hook name: Always Fail entry: "false" language: system "#})?; git_cmd(&repo_dir).arg("add").arg(".").assert().success(); git_cmd(&repo_dir) .arg("commit") .arg("-m") .arg("Initial commit") .assert() .success(); Ok(repo_dir.to_path_buf()) } #[test] fn try_repo_basic() -> Result<()> { let context = TestContext::new(); context.init_project(); context.work_dir().child("test.txt").write_str("test")?; context.git_add("."); let repo_path = create_hook_repo(&context, "try-repo-basic")?; let mut filters = context.filters(); filters.extend([(r"[a-f0-9]{40}", "[COMMIT_SHA]"), ("'", "\"")]); cmd_snapshot!(filters, context.try_repo().arg(&repo_path).arg("--skip").arg("another-hook"), @r#" success: true exit_code: 0 ----- stdout ----- Using generated `prek.toml`: [[repos]] repo = "[HOME]/test-repos/try-repo-basic" rev = "[COMMIT_SHA]" hooks = [ { id = "test-hook" }, ] Test Hook................................................................Passed ----- stderr ----- "#); Ok(()) } #[test] fn try_repo_failing_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); context.work_dir().child("test.txt").write_str("test")?; context.git_add("."); let repo_path = create_failing_hook_repo(&context, "try-repo-failing")?; let mut filters = context.filters(); filters.extend([(r"[a-f0-9]{40}", "[COMMIT_SHA]"), ("'", "\"")]); cmd_snapshot!(filters, context.try_repo().arg(&repo_path), @r#" success: false exit_code: 1 ----- stdout ----- Using generated `prek.toml`: [[repos]] repo = "[HOME]/test-repos/try-repo-failing" rev = "[COMMIT_SHA]" hooks = [ { id = "failing-hook" }, ] Always Fail..............................................................Failed - hook id: failing-hook - exit code: 1 ----- stderr ----- "#); Ok(()) } #[test] fn try_repo_specific_hook() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_hook_repo(&context, "try-repo-specific-hook")?; context.work_dir().child("test.txt").write_str("test")?; context.git_add("."); let mut filters = context.filters(); filters.extend([(r"[a-f0-9]{40}", "[COMMIT_SHA]"), ("'", "\"")]); cmd_snapshot!(filters, context.try_repo().arg(&repo_path).arg("another-hook"), @r#" success: true exit_code: 0 ----- stdout ----- Using generated `prek.toml`: [[repos]] repo = "[HOME]/test-repos/try-repo-specific-hook" rev = "[COMMIT_SHA]" hooks = [ { id = "another-hook" }, ] Another Hook.............................................................Passed ----- stderr ----- "#); Ok(()) } #[test] fn try_repo_specific_rev() -> Result<()> { let context = TestContext::new(); context.init_project(); context.work_dir().child("test.txt").write_str("test")?; context.git_add("."); let repo_path = create_hook_repo(&context, "try-repo-specific-rev")?; let initial_rev = git_cmd(&repo_path) .arg("rev-parse") .arg("HEAD") .output()? .stdout; let initial_rev = String::from_utf8_lossy(&initial_rev).trim().to_string(); // Make a new commit ChildPath::new(&repo_path) .child(PRE_COMMIT_HOOKS_YAML) .write_str(indoc::indoc! {r" - id: new-hook name: New Hook entry: echo new language: system "})?; git_cmd(&repo_path).arg("add").arg(".").assert().success(); git_cmd(&repo_path) .arg("commit") .arg("-m") .arg("second") .assert() .success(); let mut filters = context.filters(); filters.extend([ (r"[a-f0-9]{40}", "[COMMIT_SHA]"), (&initial_rev, "[COMMIT_SHA]"), ("'", "\""), ]); cmd_snapshot!(filters, context.try_repo().arg(&repo_path) .arg("--ref") .arg(&initial_rev), @r#" success: true exit_code: 0 ----- stdout ----- Using generated `prek.toml`: [[repos]] repo = "[HOME]/test-repos/try-repo-specific-rev" rev = "[COMMIT_SHA]" hooks = [ { id = "test-hook" }, { id = "another-hook" }, ] Test Hook................................................................Passed Another Hook.............................................................Passed ----- stderr ----- "#); Ok(()) } #[test] fn try_repo_uncommitted_changes() -> Result<()> { let context = TestContext::new(); context.init_project(); let repo_path = create_hook_repo(&context, "try-repo-uncommitted")?; // Make uncommitted changes ChildPath::new(&repo_path) .child(PRE_COMMIT_HOOKS_YAML) .write_str(indoc::indoc! {r" - id: uncommitted-hook name: Uncommitted Hook entry: echo uncommitted language: system "})?; ChildPath::new(&repo_path) .child("new-file.txt") .write_str("new")?; git_cmd(&repo_path) .arg("add") .arg("new-file.txt") .assert() .success(); context.work_dir().child("test.txt").write_str("test")?; context.git_add("."); let mut filters = context.filters(); filters.extend([ (r"try-repo-[^/\\]+", "[REPO]"), (r"[a-f0-9]{40}", "[COMMIT_SHA]"), ("'", "\""), ]); cmd_snapshot!(filters, context.try_repo().arg(&repo_path), @r#" success: true exit_code: 0 ----- stdout ----- Using generated `prek.toml`: [[repos]] repo = "[HOME]/scratch/[REPO]/shadow-repo" rev = "[COMMIT_SHA]" hooks = [ { id = "uncommitted-hook" }, ] Uncommitted Hook.........................................................Passed ----- stderr ----- warning: Creating temporary repo with uncommitted changes... "#); Ok(()) } #[test] fn try_repo_relative_path() -> Result<()> { let context = TestContext::new(); context.init_project(); context.work_dir().child("test.txt").write_str("test")?; context.git_add("."); let _repo_path = create_hook_repo(&context, "try-repo-relative")?; let relative_path = "../home/test-repos/try-repo-relative".to_string(); let mut filters = context.filters(); filters.extend([(r"[a-f0-9]{40}", "[COMMIT_SHA]")]); cmd_snapshot!(filters, context.try_repo().arg(&relative_path), @r#" success: true exit_code: 0 ----- stdout ----- Using generated `prek.toml`: [[repos]] repo = "../home/test-repos/try-repo-relative" rev = "[COMMIT_SHA]" hooks = [ { id = "test-hook" }, { id = "another-hook" }, ] Test Hook................................................................Passed Another Hook.............................................................Passed ----- stderr ----- "#); Ok(()) } ================================================ FILE: crates/prek/tests/validate.rs ================================================ use assert_fs::fixture::{FileWriteStr, PathChild}; use prek_consts::PRE_COMMIT_CONFIG_YAML; use crate::common::{TestContext, cmd_snapshot}; mod common; #[test] fn validate_config() -> anyhow::Result<()> { let context = TestContext::new(); // No files to validate. cmd_snapshot!(context.filters(), context.validate_config(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- warning: No configs to check "); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-json "}); // Validate one file. cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- success: All configs are valid "); context .work_dir() .child("config-1.yaml") .write_str(indoc::indoc! {r" repos: - repo: https://github.com/pre-commit/pre-commit-hooks "})?; // Validate multiple files. cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML).arg("config-1.yaml"), @" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- error: Failed to parse `config-1.yaml` caused by: error: line 2 column 5: missing field `rev` --> :2:5 | 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks | ^ missing field `rev` "); Ok(()) } #[test] fn invalid_config_error() { let context = TestContext::new(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/pre-commit/pre-commit-hooks hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-json rev: 1.0 "}); cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML), @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- success: All configs are valid "); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - repo: local hooks: - name: check-json "}); cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML), @" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- error: Failed to parse `.pre-commit-config.yaml` caused by: error: line 9 column 9: missing field `id` --> :9:9 | 7 | - repo: local 8 | hooks: 9 | - name: check-json | ^ missing field `id` "); } #[test] fn validate_manifest() -> anyhow::Result<()> { let context = TestContext::new(); // No files to validate. cmd_snapshot!(context.filters(), context.validate_manifest(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- warning: No manifests to check "); context .work_dir() .child(".pre-commit-hooks.yaml") .write_str(indoc::indoc! {r" - id: check-added-large-files name: check for added large files description: prevents giant files from being committed. entry: check-added-large-files language: python stages: [pre-commit, pre-push, manual] minimum_pre_commit_version: 3.2.0 "})?; // Validate one file. cmd_snapshot!(context.filters(), context.validate_manifest().arg(".pre-commit-hooks.yaml"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- success: All manifests are valid "); context .work_dir() .child("hooks-1.yaml") .write_str(indoc::indoc! {r" - id: check-added-large-files name: check for added large files description: prevents giant files from being committed. language: python stages: [pre-commit, pre-push, manual] minimum_pre_commit_version: 3.2.0 "})?; // Validate multiple files. cmd_snapshot!(context.filters(), context.validate_manifest().arg(".pre-commit-hooks.yaml").arg("hooks-1.yaml"), @" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- error: Failed to parse `hooks-1.yaml` caused by: error: line 6 column 5: missing field `entry` --> :6:5 | 4 | language: python 5 | stages: [pre-commit, pre-push, manual] 6 | minimum_pre_commit_version: 3.2.0 | ^ missing field `entry` "); Ok(()) } #[test] fn unexpected_keys_warning() { let context = TestContext::new(); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local unexpected_repo_key: some_value hooks: - id: test-hook name: Test Hook entry: echo test language: system unexpected_top_level_key: some_value another_unknown: test minimum_pre_commit_version: 1.0.0 "}); cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- warning: Ignored unexpected keys in `.pre-commit-config.yaml`: `another_unknown`, `unexpected_top_level_key`, `repos[0].unexpected_repo_key` success: All configs are valid "); context.write_pre_commit_config(indoc::indoc! {r" repos: - repo: local unexpected_repo_key: some_value hooks: - id: test-hook name: Test Hook entry: echo test language: system unexpected_hook_key_1: some_value unexpected_hook_key_2: some_value unexpected_hook_key_3: some_value unexpected_hook_key_4: some_value unexpected_top_level_key: some_value another_unknown: test minimum_pre_commit_version: 1.0.0 "}); cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- warning: Ignored unexpected keys in `.pre-commit-config.yaml`: - `another_unknown` - `unexpected_top_level_key` - `repos[0].unexpected_repo_key` - `repos[0].hooks[0].unexpected_hook_key_1` - `repos[0].hooks[0].unexpected_hook_key_2` - `repos[0].hooks[0].unexpected_hook_key_3` - `repos[0].hooks[0].unexpected_hook_key_4` success: All configs are valid "); } ================================================ FILE: crates/prek/tests/workspace.rs ================================================ mod common; use anyhow::Result; use assert_cmd::assert::OutputAssertExt; use assert_fs::fixture::{FileWriteStr, PathChild}; use indoc::indoc; use prek_consts::env_vars::EnvVars; use crate::common::{TestContext, cmd_snapshot, git_cmd}; #[test] fn basic_discovery() -> Result<()> { let context = TestContext::new(); let cwd = context.work_dir(); context.init_project(); let config = indoc! {r" repos: - repo: local hooks: - id: show-cwd name: Show CWD language: python entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])' verbose: true "}; context.setup_workspace( &[ "project2", "project3", "nested/project4", "project3/project5", ], config, )?; context.git_add("."); // Run from the root directory cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `nested/project4`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/nested/project4 ['.pre-commit-config.yaml'] Running hooks for `project3/project5`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3/project5 ['.pre-commit-config.yaml'] Running hooks for `project2`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project2 ['.pre-commit-config.yaml'] Running hooks for `project3`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml'] [TEMP_DIR]/ ['project3/.pre-commit-config.yaml'] ----- stderr ----- "); // Run from a subdirectory cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join("project2")), @r" success: true exit_code: 0 ----- stdout ----- Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project2 ['.pre-commit-config.yaml'] ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join("project2")).arg("--all-files"), @r" success: true exit_code: 0 ----- stdout ----- Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project2 ['.pre-commit-config.yaml'] ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join("project3")), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project5`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3/project5 ['.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml'] ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().arg("--cd").arg(cwd.join("project3")), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project5`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3/project5 ['.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml'] ----- stderr ----- "); // Ignore `project5` in `project3` context .work_dir() .child("project3/.prekignore") .write_str("project5/\n")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("--refresh").arg("--cd").arg(cwd.join("project3")), @r" success: true exit_code: 0 ----- stdout ----- Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['.prekignore', '.pre-commit-config.yaml', 'project5/.pre-commit-config.yaml'] ----- stderr ----- "); // Ignoring everything under project3, but when runs from project3, it’s still getting picked up. context .work_dir() .child("project3/.prekignore") .write_str("*\n")?; context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("--refresh").arg("--cd").arg(cwd.join("project3")), @r" success: true exit_code: 0 ----- stdout ----- Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['.prekignore', '.pre-commit-config.yaml', 'project5/.pre-commit-config.yaml'] ----- stderr ----- "); Ok(()) } #[test] fn config_not_staged() -> Result<()> { let context = TestContext::new(); let cwd = context.work_dir(); context.init_project(); let config = indoc! {r" repos: - repo: local hooks: - id: show-cwd name: Show CWD language: python entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])' verbose: true "}; context.setup_workspace( &[ "project2", "project3", "nested/project4", "project3/project5", ], config, )?; context.git_add("."); let config = indoc! {r" repos: - repo: local hooks: - id: show-cwd-modified name: Show CWD language: python entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])' verbose: true "}; // Setup again to modify files after git add context.setup_workspace( &[ "project2", "project3", "nested/project4", "project3/project5", ], config, )?; // Run from the root directory cmd_snapshot!(context.filters(), context.run(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: The following configuration files are not staged, `git add` them first: .pre-commit-config.yaml nested/project4/.pre-commit-config.yaml project2/.pre-commit-config.yaml project3/.pre-commit-config.yaml project3/project5/.pre-commit-config.yaml "); // Run from a subdirectory cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join("project3")), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: The following configuration files are not staged, `git add` them first: .pre-commit-config.yaml project5/.pre-commit-config.yaml "); cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join("project2")), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: prek configuration file is not staged, run `git add .pre-commit-config.yaml` to stage it "); Ok(()) } #[test] fn run_with_selectors() -> Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc! {r" repos: - repo: local hooks: - id: show-cwd name: Show CWD language: python entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])' verbose: true "}; context.setup_workspace( &[ "project2", "project3", "nested/project4", "project3/project5", ], config, )?; context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("project2/"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project2`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project2 ['.pre-commit-config.yaml'] ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("project2/"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `nested/project4`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/nested/project4 ['.pre-commit-config.yaml'] Running hooks for `project3/project5`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3/project5 ['.pre-commit-config.yaml'] Running hooks for `project3`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml'] [TEMP_DIR]/ ['project3/.pre-commit-config.yaml'] ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("nested/").arg("--skip").arg("project3/"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project2`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project2 ['.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml'] [TEMP_DIR]/ ['project3/.pre-commit-config.yaml'] ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().arg("show-cwd"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `nested/project4`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/nested/project4 ['.pre-commit-config.yaml'] Running hooks for `project3/project5`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3/project5 ['.pre-commit-config.yaml'] Running hooks for `project2`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project2 ['.pre-commit-config.yaml'] Running hooks for `project3`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml'] [TEMP_DIR]/ ['project3/.pre-commit-config.yaml'] ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().arg("project2:show-cwd"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project2`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project2 ['.pre-commit-config.yaml'] ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().arg(".:show-cwd"), @r" success: true exit_code: 0 ----- stdout ----- Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml'] [TEMP_DIR]/ ['project3/.pre-commit-config.yaml'] ----- stderr ----- "); cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("show-cwd"), @r" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- error: No hooks found after filtering with the given selectors "); cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("project2:show-cwd").arg("--skip").arg("nested:show-cwd"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `nested/project4`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/nested/project4 ['.pre-commit-config.yaml'] Running hooks for `project3/project5`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3/project5 ['.pre-commit-config.yaml'] Running hooks for `project3`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml'] [TEMP_DIR]/ ['project3/.pre-commit-config.yaml'] ----- stderr ----- warning: selector `--skip=nested:show-cwd` did not match any hooks "); cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("non-exist"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `nested/project4`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/nested/project4 ['.pre-commit-config.yaml'] Running hooks for `project3/project5`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3/project5 ['.pre-commit-config.yaml'] Running hooks for `project2`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project2 ['.pre-commit-config.yaml'] Running hooks for `project3`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['project5/.pre-commit-config.yaml', '.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['nested/project4/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project5/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml'] [TEMP_DIR]/ ['project3/.pre-commit-config.yaml'] ----- stderr ----- warning: selector `--skip=non-exist` did not match any hooks "); cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("../"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Invalid selector: `../` caused by: Invalid project path: `../` caused by: path is outside the workspace root "); cmd_snapshot!(context.filters(), context.run().current_dir(context.work_dir().join("project2")), @r" success: true exit_code: 0 ----- stdout ----- Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project2 ['.pre-commit-config.yaml'] ----- stderr ----- "); Ok(()) } #[test] fn skips() -> Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc! {r" repos: - repo: local hooks: - id: show-cwd name: Show CWD language: python entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])' verbose: true "}; context.setup_workspace(&["project2", "project3", "project3/project4"], config)?; context.git_add("."); // Test CLI skip cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("project2/"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project3/project4`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3/project4 ['.pre-commit-config.yaml'] Running hooks for `project3`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['project4/.pre-commit-config.yaml', '.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml'] ----- stderr ----- "); // Test PREK_SKIP environment variable cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_SKIP, "project2/"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project3/project4`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3/project4 ['.pre-commit-config.yaml'] Running hooks for `project3`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['project4/.pre-commit-config.yaml', '.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml'] ----- stderr ----- "); // Test SKIP environment variable cmd_snapshot!(context.filters(), context.run().env(EnvVars::SKIP, "project2/"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project3/project4`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3/project4 ['.pre-commit-config.yaml'] Running hooks for `project3`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['project4/.pre-commit-config.yaml', '.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml'] ----- stderr ----- "); // Test precedence: CLI --skip overrides PREK_SKIP cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("project2/").env(EnvVars::PREK_SKIP, "project3/"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project3/project4`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3/project4 ['.pre-commit-config.yaml'] Running hooks for `project3`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['project4/.pre-commit-config.yaml', '.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml'] ----- stderr ----- "); // Test precedence: PREK_SKIP overrides SKIP cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_SKIP, "project2/").env(EnvVars::SKIP, "project3/"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project3/project4`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3/project4 ['.pre-commit-config.yaml'] Running hooks for `project3`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project3 ['project4/.pre-commit-config.yaml', '.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml'] ----- stderr ----- "); // Test multiple selectors in environment variable cmd_snapshot!(context.filters(), context.run().env("PREK_SKIP", "project2/,project3/,non-exist-hook"), @r" success: true exit_code: 0 ----- stdout ----- Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml'] ----- stderr ----- warning: selector `PREK_SKIP=non-exist-hook` did not match any hooks "); // Add an invalid config context .work_dir() .child("project3/.pre-commit-config.yaml") .write_str("invalid_yaml: [")?; context.git_add("."); // Should error out because of the invalid config cmd_snapshot!(context.filters(), context.run(), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to parse `project3/.pre-commit-config.yaml` caused by: error: line 1 column 15: unclosed bracket '[' --> :1:15 | 1 | invalid_yaml: [ | ^ unclosed bracket '[' "); // Should skip the invalid config cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("project3/"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project2`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project2 ['.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml'] ----- stderr ----- "); Ok(()) } #[test] fn workspace_no_projects() { let context = TestContext::new(); context.init_project(); context.write_pre_commit_config("repos: []"); context.git_add("."); cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("."), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: No `prek.toml` or `.pre-commit-config.yaml` found in the current directory or parent directories. hint: If you just added one, rerun your command with the `--refresh` flag to rescan the workspace. "); } #[test] fn gitignore_respected() -> Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc! {r" repos: - repo: local hooks: - id: show-cwd name: Show CWD language: python entry: python -c 'import sys, os; print(os.getcwd()); print(sorted(sys.argv[1:]))' verbose: true "}; // Create a project structure with directories that should be ignored context.setup_workspace( &[ "src", "node_modules/ignored", // Should be ignored by .gitignore "target/ignored", // Should be ignored by .gitignore ], config, )?; // Create .gitignore that ignores node_modules and target context .work_dir() .child(".gitignore") .write_str("node_modules/\ntarget/\n")?; context.git_add("."); // Run from the root - should not discover projects in node_modules or target cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `src`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/src ['.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['.gitignore', '.pre-commit-config.yaml', 'src/.pre-commit-config.yaml'] ----- stderr ----- "); Ok(()) } #[test] fn nested_project_exclude_is_relative() -> Result<()> { let context = TestContext::new(); context.init_project(); // Regression test for nested workspaces: // `exclude` must be evaluated against paths *relative to each project root*. // // Concretely: // - In the nested project, the file is seen as `excluded_by_project` and should be excluded by `^excluded_by_project$`. // - In the root project, the same file is seen as `nested/excluded_by_project` and should NOT be excluded. let config = indoc! {r#" exclude: \.pre-commit-config\.yaml$|^excluded_by_project$ repos: - repo: local hooks: - id: show-files name: Show Files language: python entry: python -c 'import sys; print("Processing {} files".format(len(sys.argv[1:]))); [print(" - {}".format(f)) for f in sys.argv[1:]]' pass_filenames: true verbose: true "#}; // Workspace with a nested project. context.setup_workspace(&["nested"], config)?; // A root-level file which should be excluded by the root project (path is `excluded_by_project`). // This keeps the snapshot focused on the nested files, while proving the regex is not // accidentally matching `nested/excluded_by_project`. context .work_dir() .child("excluded_by_project") .write_str("")?; // Files inside the nested project: one that should be included and one excluded. context.work_dir().child("nested/include").write_str("")?; context .work_dir() .child("nested/excluded_by_project") .write_str("")?; context.git_add("."); // When running from the root with --all-files, the nested project's exclude // pattern should see paths relative to `nested/`, so `noinclude` is excluded // there but still visible from the root project. cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @" success: true exit_code: 0 ----- stdout ----- Running hooks for `nested`: Show Files...............................................................Passed - hook id: show-files - duration: [TIME] Processing 1 files - include Running hooks for `.`: Show Files...............................................................Passed - hook id: show-files - duration: [TIME] Processing 2 files - nested/include - nested/excluded_by_project ----- stderr ----- "); Ok(()) } /// Tests that `--files` arguments references files in other projects, should be filtered out properly. #[test] fn reference_files_across_projects() -> Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc! {r" repos: - repo: local hooks: - id: echo name: echo language: system entry: echo verbose: true "}; // Create a project structure with directories that should be ignored context.setup_workspace(&["frontend", "backend"], config)?; let cwd = context.work_dir(); cwd.child("backend/app.py") .write_str("print('Hello from backend')")?; context.git_add("."); // Run with --files referencing a file in another project cmd_snapshot!(context.filters(), context.run().current_dir(cwd.child("frontend")).arg("--files").arg("../backend/app.py").arg("../backend/non-exist.py"), @r" success: true exit_code: 0 ----- stdout ----- echo.................................................(no files to check)Skipped ----- stderr ----- warning: This file does not exist and will be ignored: `../backend/non-exist.py` "); Ok(()) } #[test] fn submodule_discovery() -> Result<()> { let context = TestContext::new(); let cwd = context.work_dir(); context.init_project(); let config = indoc! {r" repos: - repo: local hooks: - id: show-cwd name: Show CWD language: python entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])' verbose: true "}; context.setup_workspace(&["project2"], config)?; // Create a submodule let submodule_path = cwd.child("submodule"); let submodule_context = TestContext::new_at(submodule_path.to_path_buf()); submodule_context.init_project(); submodule_context.write_pre_commit_config(config); submodule_context.git_add("."); submodule_context.git_commit("Initial commit"); // Add submodule to the main project git_cmd(cwd) .args(["submodule", "add", "./submodule"]) .assert() .success(); context.git_add("."); // 1. Test that workspace discovery does not recurse into git submodules cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project2`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project2 ['.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['.pre-commit-config.yaml', '.gitmodules', 'project2/.pre-commit-config.yaml'] ----- stderr ----- "); // 2. Test that current directory is in the submodule with a .pre-commit-config cmd_snapshot!(context.filters(), context.run().current_dir(&submodule_path).arg("--all-files"), @r" success: true exit_code: 0 ----- stdout ----- Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/submodule ['.pre-commit-config.yaml'] ----- stderr ----- "); // 3. Test that current directory is in the submodule without .pre-commit-config // Remove the config file in the submodule std::fs::remove_file(submodule_path.join(".pre-commit-config.yaml"))?; submodule_context.git_add("."); submodule_context.git_commit("Remove config"); cmd_snapshot!(context.filters(), context.run().current_dir(&submodule_path), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: No `prek.toml` or `.pre-commit-config.yaml` found in the current directory or parent directories. hint: If you just added one, rerun your command with the `--refresh` flag to rescan the workspace. "); Ok(()) } #[test] fn cookiecutter_template_directories_are_skipped() -> Result<()> { let context = TestContext::new(); context.init_project(); let config = indoc! {r" repos: - repo: local hooks: - id: show-cwd name: Show CWD language: python entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])' verbose: true "}; context.setup_workspace(&["project2", "{{cookiecutter.project_slug}}"], config)?; // Stage only the configs that should participate in discovery. context.git_add(".pre-commit-config.yaml"); context.git_add("project2/.pre-commit-config.yaml"); // The cookiecutter directory would otherwise be discovered as a project. cmd_snapshot!(context.filters(), context.run().arg("--refresh").arg("--all-files"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `project2`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/project2 ['.pre-commit-config.yaml'] Running hooks for `.`: Show CWD.................................................................Passed - hook id: show-cwd - duration: [TIME] [TEMP_DIR]/ ['project2/.pre-commit-config.yaml', '.pre-commit-config.yaml'] ----- stderr ----- "); Ok(()) } #[test] fn orphan_projects() -> Result<()> { let context = TestContext::new(); context.init_project(); // Create a hook that shows which files it processes let config = indoc! {r#" exclude: \.pre-commit-config\.yaml$ repos: - repo: local hooks: - id: show-files name: Show Files language: python entry: python -c 'import sys; print("Processing {} files".format(len(sys.argv[1:]))); [print(" - {}".format(f)) for f in sys.argv[1:]]' pass_filenames: true verbose: true "#}; // Setup workspace with nested projects context .work_dir() .child("src/backend/.pre-commit-config.yaml") .write_str(config)?; context .work_dir() .child("src/.pre-commit-config.yaml") .write_str(config)?; context .work_dir() .child(".pre-commit-config.yaml") .write_str(config)?; // Create test files context .work_dir() .child("src/backend/test.py") .write_str("")?; context.work_dir().child("src/test.py").write_str("")?; context.work_dir().child("test.py").write_str("")?; context.git_add("."); // Without `orphan`: files in subprojects are processed multiple times cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `src/backend`: Show Files...............................................................Passed - hook id: show-files - duration: [TIME] Processing 1 files - test.py Running hooks for `src`: Show Files...............................................................Passed - hook id: show-files - duration: [TIME] Processing 2 files - test.py - backend/test.py Running hooks for `.`: Show Files...............................................................Passed - hook id: show-files - duration: [TIME] Processing 3 files - src/test.py - src/backend/test.py - test.py ----- stderr ----- "); // Enable `orphan` context .work_dir() .child("src/backend/.pre-commit-config.yaml") .write_str(indoc! {r#" orphan: true exclude: \.pre-commit-config\.yaml$ repos: - repo: local hooks: - id: show-files name: Show Files language: python entry: python -c 'import sys; print("Processing {} files".format(len(sys.argv[1:]))); [print(" - {}".format(f)) for f in sys.argv[1:]]' pass_filenames: true verbose: true "#})?; // `files` match nothing, but files are still "consumed" context .work_dir() .child("src/.pre-commit-config.yaml") .write_str(indoc! {r#" orphan: true files: ^$ exclude: \.pre-commit-config\.yaml$ repos: - repo: local hooks: - id: show-files name: Show Files language: python entry: python -c 'import sys; print("Processing {} files".format(len(sys.argv[1:]))); [print(" - {}".format(f)) for f in sys.argv[1:]]' pass_filenames: true verbose: true "#})?; context .work_dir() .child(".pre-commit-config.yaml") .write_str(indoc! {r#" orphan: false exclude: \.pre-commit-config\.yaml$ repos: - repo: local hooks: - id: show-files name: Show Files language: python entry: python -c 'import sys; print("Processing {} files".format(len(sys.argv[1:]))); [print(" - {}".format(f)) for f in sys.argv[1:]]' pass_filenames: true verbose: true "#})?; // In orphan project, files are "consumed" and not processed again in parent projects cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `src/backend`: Show Files...............................................................Passed - hook id: show-files - duration: [TIME] Processing 1 files - test.py Running hooks for `src`: Show Files...........................................(no files to check)Skipped Running hooks for `.`: Show Files...............................................................Passed - hook id: show-files - duration: [TIME] Processing 1 files - test.py ----- stderr ----- "); // If hooks in orphan projects are not selected, files should be "consumed" as well cmd_snapshot!(context.filters(), context.run().arg("--all-files").arg("--skip").arg("src/"), @r" success: true exit_code: 0 ----- stdout ----- Show Files...............................................................Passed - hook id: show-files - duration: [TIME] Processing 1 files - test.py ----- stderr ----- "); Ok(()) } /// Test that relative repo paths in subproject configs resolve from the config /// file's directory, not from the process's current working directory. /// /// Regression test for #[test] fn relative_repo_path_resolution() -> Result<()> { use assert_fs::fixture::PathCreateDir; use prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_HOOKS_YAML}; let context = TestContext::new(); context.init_project(); // Create a local hook repository at the root level let hook_repo = context.work_dir().child("hook-repo"); hook_repo.create_dir_all()?; git_cmd(&hook_repo).args(["init"]).assert().success(); git_cmd(&hook_repo) .args(["config", "user.name", "Test"]) .assert() .success(); git_cmd(&hook_repo) .args(["config", "user.email", "test@test.com"]) .assert() .success(); git_cmd(&hook_repo) .args(["config", "core.autocrlf", "false"]) .assert() .success(); hook_repo.child(PRE_COMMIT_HOOKS_YAML).write_str(indoc! {r" - id: test-hook name: Test Hook entry: echo test language: system always_run: true "})?; git_cmd(&hook_repo).args(["add", "."]).assert().success(); git_cmd(&hook_repo) .args(["commit", "--no-si", "-m", "Initial commit"]) .assert() .success(); // Get the commit SHA let output = git_cmd(&hook_repo).args(["rev-parse", "HEAD"]).output()?; let commit_sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); // Create a subproject that references the hook repo with a relative path let subproject = context.work_dir().child("subproject"); subproject.create_dir_all()?; // From subproject/, ../hook-repo should resolve to the hook-repo at root subproject .child(PRE_COMMIT_CONFIG_YAML) .write_str(&indoc::formatdoc! {r" repos: - repo: ../hook-repo rev: {commit_sha} hooks: - id: test-hook always_run: true "})?; subproject.child("test.txt").write_str("test content")?; // Root config so workspace discovery works context.write_pre_commit_config(indoc! {r" repos: - repo: local hooks: - id: noop name: Noop entry: echo noop language: system always_run: true "}); context.git_add("."); // Run from the root directory - the relative path ../hook-repo should resolve // from subproject/.pre-commit-config.yaml's location, not from CWD cmd_snapshot!(context.filters(), context.run(), @r" success: true exit_code: 0 ----- stdout ----- Running hooks for `subproject`: Test Hook................................................................Passed Running hooks for `.`: Noop.....................................................................Passed ----- stderr ----- "); Ok(()) } ================================================ FILE: crates/prek/tests/yaml_to_toml.rs ================================================ use assert_fs::assert::PathAssert; use assert_fs::fixture::{FileWriteStr, PathChild}; use prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_CONFIG_YML, PREK_TOML}; use crate::common::{TestContext, cmd_snapshot}; mod common; const YAML_CONFIG: &str = r#" fail_fast: true default_install_hook_types: [pre-push] exclude: | (?x)^( .*/(snapshots)/.*| )$ repos: - repo: builtin hooks: - id: trailing-whitespace - id: mixed-line-ending - id: check-yaml - id: check-toml - id: end-of-file-fixer - repo: https://github.com/crate-ci/typos rev: v1.42.3 hooks: - id: typos - repo: https://github.com/executablebooks/mdformat rev: '1.0.0' hooks: - id: mdformat language: python # ensures that Renovate can update additional_dependencies args: [--number, --compact-tables, --align-semantic-breaks-in-lists] env: Hello: World priority: 1 additional_dependencies: - mdformat-mkdocs==5.1.4 - mdformat-simple-breaks==0.1.0 - repo: local hooks: - id: taplo-fmt name: taplo fmt env: EnvVar: Value AnotherEnvVar: AnotherValue entry: taplo fmt --config .config/taplo.toml language: python additional_dependencies: ["taplo==0.9.3"] types: [toml] "#; #[test] fn yaml_to_toml_writes_default_output() -> anyhow::Result<()> { let context = TestContext::new(); context .work_dir() .child("config.yaml") .write_str(YAML_CONFIG)?; cmd_snapshot!( context.filters(), context .command() .args(["util", "yaml-to-toml", "config.yaml"]), @" success: true exit_code: 0 ----- stdout ----- Converted `config.yaml` → `prek.toml` ----- stderr ----- " ); insta::assert_snapshot!(context.read(PREK_TOML), @r#" # Configuration file for `prek`, a git hook framework written in Rust. # See https://prek.j178.dev for more information. #:schema https://www.schemastore.org/prek.json fail_fast = true default_install_hook_types = ["pre-push"] exclude = """ (?x)^( .*/(snapshots)/.*| )$ """ [[repos]] repo = "builtin" hooks = [ { id = "trailing-whitespace" }, { id = "mixed-line-ending" }, { id = "check-yaml" }, { id = "check-toml" }, { id = "end-of-file-fixer" } ] [[repos]] repo = "https://github.com/crate-ci/typos" rev = "v1.42.3" hooks = [ { id = "typos" } ] [[repos]] repo = "https://github.com/executablebooks/mdformat" rev = "1.0.0" hooks = [ { id = "mdformat", language = "python", args = [ "--number", "--compact-tables", "--align-semantic-breaks-in-lists" ], env = { Hello = "World" }, priority = 1, additional_dependencies = [ "mdformat-mkdocs==5.1.4", "mdformat-simple-breaks==0.1.0" ] } ] [[repos]] repo = "local" hooks = [ { id = "taplo-fmt", name = "taplo fmt", env = { EnvVar = "Value", AnotherEnvVar = "AnotherValue" }, entry = "taplo fmt --config .config/taplo.toml", language = "python", additional_dependencies = ["taplo==0.9.3"], types = ["toml"] } ] "#); Ok(()) } #[test] fn yaml_to_toml_force_overwrite() -> anyhow::Result<()> { let context = TestContext::new(); context .work_dir() .child("config.yaml") .write_str(YAML_CONFIG)?; context.work_dir().child(PREK_TOML).write_str("existing")?; cmd_snapshot!( context.filters(), context .command() .args(["util", "yaml-to-toml", "config.yaml"]), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: File `prek.toml` already exists (use `--force` to overwrite) " ); cmd_snapshot!( context.filters(), context .command() .args(["util", "yaml-to-toml", "config.yaml", "--force"]), @" success: true exit_code: 0 ----- stdout ----- Converted `config.yaml` → `prek.toml` ----- stderr ----- " ); Ok(()) } #[test] fn yaml_to_toml_rejects_invalid_config() -> anyhow::Result<()> { let context = TestContext::new(); context .work_dir() .child("config.yaml") .write_str("repos: 123")?; cmd_snapshot!( context.filters(), context .command() .args(["util", "yaml-to-toml", "config.yaml"]), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Failed to parse `config.yaml` caused by: error: line 1 column 8: unexpected event: expected sequence start --> :1:8 | 1 | repos: 123 | ^ unexpected event: expected sequence start " ); Ok(()) } #[test] fn yaml_to_toml_same_output() -> anyhow::Result<()> { let context = TestContext::new(); context .work_dir() .child("config.yaml") .write_str(YAML_CONFIG)?; cmd_snapshot!( context.filters(), context .command() .args(["util", "yaml-to-toml", "config.yaml", "--output", "config.yaml"]), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: Output path `config.yaml` matches input; choose a different output path " ); context .work_dir() .child(PREK_TOML) .assert(predicates::path::missing()); Ok(()) } #[test] fn yaml_to_toml_discovers_pre_commit_config_yaml() -> anyhow::Result<()> { let context = TestContext::new(); context .work_dir() .child(PRE_COMMIT_CONFIG_YAML) .write_str(YAML_CONFIG)?; cmd_snapshot!( context.filters(), context.command().args(["util", "yaml-to-toml"]), @" success: true exit_code: 0 ----- stdout ----- Converted `.pre-commit-config.yaml` → `prek.toml` ----- stderr ----- " ); context .work_dir() .child(PREK_TOML) .assert(predicates::path::exists()); Ok(()) } #[test] fn yaml_to_toml_discovers_pre_commit_config_yml() -> anyhow::Result<()> { let context = TestContext::new(); context .work_dir() .child(PRE_COMMIT_CONFIG_YML) .write_str(YAML_CONFIG)?; cmd_snapshot!( context.filters(), context.command().args(["util", "yaml-to-toml"]), @" success: true exit_code: 0 ----- stdout ----- Converted `.pre-commit-config.yml` → `prek.toml` ----- stderr ----- " ); context .work_dir() .child(PREK_TOML) .assert(predicates::path::exists()); Ok(()) } #[test] fn yaml_to_toml_prefers_yaml_over_yml() -> anyhow::Result<()> { let context = TestContext::new(); // Write different content to each file so we can verify which was used. let yaml_only = indoc::indoc! {r" repos: - repo: builtin hooks: - id: trailing-whitespace "}; let yml_only = indoc::indoc! {r" repos: - repo: builtin hooks: - id: end-of-file-fixer "}; context .work_dir() .child(PRE_COMMIT_CONFIG_YAML) .write_str(yaml_only)?; context .work_dir() .child(PRE_COMMIT_CONFIG_YML) .write_str(yml_only)?; cmd_snapshot!( context.filters(), context.command().args(["util", "yaml-to-toml"]), @" success: true exit_code: 0 ----- stdout ----- Converted `.pre-commit-config.yaml` → `prek.toml` ----- stderr ----- " ); // The .yaml file contains trailing-whitespace, the .yml contains end-of-file-fixer. let output = context.read(PREK_TOML); assert!( output.contains("trailing-whitespace"), "Expected .yaml to be preferred over .yml" ); Ok(()) } #[test] fn yaml_to_toml_error_when_no_config_found() { let context = TestContext::new(); cmd_snapshot!( context.filters(), context.command().args(["util", "yaml-to-toml"]), @r#" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: No `.pre-commit-config.yaml` or `.pre-commit-config.yml` found in the current directory hint: Provide a path explicitly: prek util yaml-to-toml "# ); } ================================================ FILE: crates/prek-consts/Cargo.toml ================================================ [package] name = "prek-consts" description = "constant values for prek" version = { workspace = true } edition = { workspace = true } rust-version = { workspace = true } repository = { workspace = true } license = { workspace = true } [dependencies] tracing = { workspace = true } [lints] workspace = true ================================================ FILE: crates/prek-consts/src/env_vars.rs ================================================ use std::ffi::OsString; use tracing::info; pub struct EnvVars; impl EnvVars { pub const PATH: &'static str = "PATH"; pub const HOME: &'static str = "HOME"; pub const CI: &'static str = "CI"; pub const LC_ALL: &'static str = "LC_ALL"; // Git related pub const GIT_DIR: &'static str = "GIT_DIR"; pub const GIT_WORK_TREE: &'static str = "GIT_WORK_TREE"; pub const GIT_TERMINAL_PROMPT: &'static str = "GIT_TERMINAL_PROMPT"; pub const SKIP: &'static str = "SKIP"; // PREK specific environment variables, public for users pub const PREK_HOME: &'static str = "PREK_HOME"; pub const PREK_COLOR: &'static str = "PREK_COLOR"; pub const PREK_SKIP: &'static str = "PREK_SKIP"; pub const PREK_ALLOW_NO_CONFIG: &'static str = "PREK_ALLOW_NO_CONFIG"; pub const PREK_NO_CONCURRENCY: &'static str = "PREK_NO_CONCURRENCY"; pub const PREK_MAX_CONCURRENCY: &'static str = "PREK_MAX_CONCURRENCY"; pub const PREK_NO_FAST_PATH: &'static str = "PREK_NO_FAST_PATH"; pub const PREK_UV_SOURCE: &'static str = "PREK_UV_SOURCE"; pub const PREK_NATIVE_TLS: &'static str = "PREK_NATIVE_TLS"; pub const SSL_CERT_FILE: &'static str = "SSL_CERT_FILE"; pub const SSL_CERT_DIR: &'static str = "SSL_CERT_DIR"; pub const PREK_CONTAINER_RUNTIME: &'static str = "PREK_CONTAINER_RUNTIME"; pub const PREK_QUIET: &'static str = "PREK_QUIET"; pub const PREK_LOG_TRUNCATE_LIMIT: &'static str = "PREK_LOG_TRUNCATE_LIMIT"; // PREK internal environment variables pub const PREK_INTERNAL__TEST_DIR: &'static str = "PREK_INTERNAL__TEST_DIR"; pub const PREK_INTERNAL__SORT_FILENAMES: &'static str = "PREK_INTERNAL__SORT_FILENAMES"; pub const PREK_INTERNAL__SKIP_POST_CHECKOUT: &'static str = "PREK_INTERNAL__SKIP_POST_CHECKOUT"; pub const PREK_INTERNAL__RUN_ORIGINAL_PRE_COMMIT: &'static str = "PREK_INTERNAL__RUN_ORIGINAL_PRE_COMMIT"; pub const PREK_INTERNAL__BUN_BINARY_NAME: &'static str = "PREK_INTERNAL__BUN_BINARY_NAME"; pub const PREK_INTERNAL__DENO_BINARY_NAME: &'static str = "PREK_INTERNAL__DENO_BINARY_NAME"; pub const PREK_INTERNAL__GO_BINARY_NAME: &'static str = "PREK_INTERNAL__GO_BINARY_NAME"; pub const PREK_INTERNAL__NODE_BINARY_NAME: &'static str = "PREK_INTERNAL__NODE_BINARY_NAME"; pub const PREK_INTERNAL__RUSTUP_BINARY_NAME: &'static str = "PREK_INTERNAL__RUSTUP_BINARY_NAME"; pub const PREK_INTERNAL__SKIP_CABAL_UPDATE: &'static str = "PREK_INTERNAL__SKIP_CABAL_UPDATE"; pub const PREK_RUNNING_LEGACY: &'static str = "PREK_RUNNING_LEGACY"; pub const PREK_GENERATE: &'static str = "PREK_GENERATE"; // Python & uv related pub const VIRTUAL_ENV: &'static str = "VIRTUAL_ENV"; pub const PYTHONHOME: &'static str = "PYTHONHOME"; pub const UV_PYTHON: &'static str = "UV_PYTHON"; pub const UV_SYSTEM_PYTHON: &'static str = "UV_SYSTEM_PYTHON"; pub const UV_CACHE_DIR: &'static str = "UV_CACHE_DIR"; pub const UV_PYTHON_INSTALL_DIR: &'static str = "UV_PYTHON_INSTALL_DIR"; pub const UV_MANAGED_PYTHON: &'static str = "UV_MANAGED_PYTHON"; pub const UV_NO_MANAGED_PYTHON: &'static str = "UV_NO_MANAGED_PYTHON"; // Node/Npm related pub const NPM_CONFIG_USERCONFIG: &'static str = "NPM_CONFIG_USERCONFIG"; pub const NPM_CONFIG_PREFIX: &'static str = "NPM_CONFIG_PREFIX"; pub const NODE_PATH: &'static str = "NODE_PATH"; // Bun related pub const BUN_INSTALL: &'static str = "BUN_INSTALL"; // Deno related pub const DENO_DIR: &'static str = "DENO_DIR"; pub const DENO_NO_UPDATE_CHECK: &'static str = "DENO_NO_UPDATE_CHECK"; // GitHub API authentication (to avoid rate limits) pub const GITHUB_TOKEN: &'static str = "GITHUB_TOKEN"; // Go related pub const GOTOOLCHAIN: &'static str = "GOTOOLCHAIN"; pub const GOROOT: &'static str = "GOROOT"; pub const GOPATH: &'static str = "GOPATH"; pub const GOBIN: &'static str = "GOBIN"; pub const GOFLAGS: &'static str = "GOFLAGS"; // Lua related pub const LUA_PATH: &'static str = "LUA_PATH"; pub const LUA_CPATH: &'static str = "LUA_CPATH"; // Ruby related pub const PREK_RUBY_MIRROR: &'static str = "PREK_RUBY_MIRROR"; pub const GEM_HOME: &'static str = "GEM_HOME"; pub const GEM_PATH: &'static str = "GEM_PATH"; pub const BUNDLE_IGNORE_CONFIG: &'static str = "BUNDLE_IGNORE_CONFIG"; pub const BUNDLE_GEMFILE: &'static str = "BUNDLE_GEMFILE"; // Rust related pub const RUSTUP_TOOLCHAIN: &'static str = "RUSTUP_TOOLCHAIN"; pub const RUSTUP_AUTO_INSTALL: &'static str = "RUSTUP_AUTO_INSTALL"; pub const CARGO_HOME: &'static str = "CARGO_HOME"; pub const RUSTUP_HOME: &'static str = "RUSTUP_HOME"; } impl EnvVars { // Pre-commit environment variables that we support for compatibility pub const PRE_COMMIT_HOME: &'static str = "PRE_COMMIT_HOME"; const PRE_COMMIT_ALLOW_NO_CONFIG: &'static str = "PRE_COMMIT_ALLOW_NO_CONFIG"; const PRE_COMMIT_NO_CONCURRENCY: &'static str = "PRE_COMMIT_NO_CONCURRENCY"; } impl EnvVars { /// Read an environment variable, falling back to pre-commit corresponding variable if not found. pub fn var_os(name: &str) -> Option { #[allow(clippy::disallowed_methods)] std::env::var_os(name).or_else(|| { let name = Self::pre_commit_name(name)?; let val = std::env::var_os(name)?; info!("Falling back to pre-commit environment variable for {name}"); Some(val) }) } pub fn is_set(name: &str) -> bool { Self::var_os(name).is_some() } /// Return whether the current process is running under CI. pub fn is_under_ci() -> bool { Self::is_set(Self::CI) } /// Read an environment variable, falling back to pre-commit corresponding variable if not found. pub fn var(name: &str) -> Result { match Self::var_os(name) { Some(s) => s.into_string().map_err(std::env::VarError::NotUnicode), None => Err(std::env::VarError::NotPresent), } } /// Read an environment var and parse as bool. pub fn var_as_bool(name: &str) -> Option { if let Some(val) = EnvVars::var_os(name) && let Some(val) = val.to_str() && let Some(val) = EnvVars::parse_boolish(val) { Some(val) } else { None } } /// Parse a boolean from a string. /// /// Adapted from Clap's `BoolishValueParser` which is dual licensed under the MIT and Apache-2.0. /// See `clap_builder/src/util/str_to_bool.rs` fn parse_boolish(val: &str) -> Option { // True values are `y`, `yes`, `t`, `true`, `on`, and `1`. const TRUE_LITERALS: [&str; 6] = ["y", "yes", "t", "true", "on", "1"]; // False values are `n`, `no`, `f`, `false`, `off`, and `0`. const FALSE_LITERALS: [&str; 6] = ["n", "no", "f", "false", "off", "0"]; let val = val.to_lowercase(); let pat = val.as_str(); if TRUE_LITERALS.contains(&pat) { Some(true) } else if FALSE_LITERALS.contains(&pat) { Some(false) } else { None } } fn pre_commit_name(name: &str) -> Option<&str> { match name { Self::PREK_ALLOW_NO_CONFIG => Some(Self::PRE_COMMIT_ALLOW_NO_CONFIG), Self::PREK_NO_CONCURRENCY => Some(Self::PRE_COMMIT_NO_CONCURRENCY), _ => None, } } } #[cfg(test)] mod tests { use super::EnvVars; #[test] fn test_parse_boolish() { let true_values = ["y", "yes", "t", "true", "on", "1"]; let false_values = ["n", "no", "f", "false", "off", "0"]; for val in true_values { assert_eq!(EnvVars::parse_boolish(val), Some(true),); assert_eq!(EnvVars::parse_boolish(&val.to_uppercase()), Some(true),); } for val in false_values { assert_eq!(EnvVars::parse_boolish(val), Some(false),); assert_eq!(EnvVars::parse_boolish(&val.to_uppercase()), Some(false),); } assert_eq!(EnvVars::parse_boolish("maybe"), None); assert_eq!(EnvVars::parse_boolish(""), None); assert_eq!(EnvVars::parse_boolish("123"), None); } } ================================================ FILE: crates/prek-consts/src/lib.rs ================================================ pub mod env_vars; use std::ffi::OsString; use std::path::Path; use env_vars::EnvVars; pub const PRE_COMMIT_CONFIG_YAML: &str = ".pre-commit-config.yaml"; pub const PRE_COMMIT_CONFIG_YML: &str = ".pre-commit-config.yml"; pub const PREK_TOML: &str = "prek.toml"; pub const PRE_COMMIT_HOOKS_YAML: &str = ".pre-commit-hooks.yaml"; pub static CONFIG_FILENAMES: &[&str] = &[PREK_TOML, PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_CONFIG_YML]; /// Prepend paths to the current $PATH, returning the joined result. /// /// The resulting `OsString` can be used to set the `PATH` environment variable. pub fn prepend_paths(paths: &[&Path]) -> Result { std::env::join_paths( paths.iter().map(|p| p.to_path_buf()).chain( EnvVars::var_os(EnvVars::PATH) .as_ref() .iter() .flat_map(std::env::split_paths), ), ) } ================================================ FILE: crates/prek-identify/Cargo.toml ================================================ [package] name = "prek-identify" description = "File identification for prek" version = { workspace = true } edition = { workspace = true } rust-version = { workspace = true } repository = { workspace = true } license = { workspace = true } [features] serde = ["dep:serde"] schemars = ["dep:schemars"] [dependencies] anyhow = { workspace = true } fs-err = { workspace = true } phf = { workspace = true, default-features = false, features = ["macros"] } schemars = { workspace = true, optional = true } serde = { workspace = true, optional = true } shlex = { workspace = true } thiserror = { workspace = true } [dev-dependencies] indoc = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } [lints] workspace = true ================================================ FILE: crates/prek-identify/gen.py ================================================ # /// script # requires-python = ">=3.14" # dependencies = [ # "identify>=2.6.16", # ] # /// from pathlib import Path from identify.identify import ALL_TAGS from identify.interpreters import INTERPRETERS from identify.extensions import EXTENSIONS, EXTENSIONS_NEED_BINARY_CHECK, NAMES TAG_ID_CONSTS = [ ("TAG_FILE", "file"), ("TAG_DIRECTORY", "directory"), ("TAG_SYMLINK", "symlink"), ("TAG_SOCKET", "socket"), ("TAG_EXECUTABLE", "executable"), ("TAG_NON_EXECUTABLE", "non-executable"), ("TAG_TEXT", "text"), ("TAG_BINARY", "binary"), ] TAG_SET_CONSTS = [ ("TAG_SET_FILE", ["file"]), ("TAG_SET_DIRECTORY", ["directory"]), ("TAG_SET_SYMLINK", ["symlink"]), ("TAG_SET_SOCKET", ["socket"]), ("TAG_SET_TEXT", ["text"]), ("TAG_SET_TEXT_OR_BINARY", ["text", "binary"]), ("TAG_SET_EXECUTABLE_TEXT", ["executable", "text"]), ("TAG_SET_JSON", ["json"]), ("TAG_SET_JSON5", ["json5"]), ("TAG_SET_TOML", ["toml"]), ("TAG_SET_XML", ["xml"]), ("TAG_SET_YAML", ["yaml"]), ] SELF_DIR = Path(__file__).parent TAGS_FILE = SELF_DIR / "src/tags.rs" def gen(): with open(TAGS_FILE, "w", newline="\n") as f: f.write("// This file is auto-generated by gen.py. DO NOT EDIT MANUALLY.\n\n") f.write("use crate::TagSet;\n\n") tags = sorted(set(ALL_TAGS)) tag_to_id = {tag: idx for idx, tag in enumerate(tags)} def tagset_expr(tag_set): ids = sorted(tag_to_id[tag] for tag in tag_set) ids_str = ", ".join(str(tag_id) for tag_id in ids) return f"TagSet::new(&[{ids_str}])" f.write(f"pub const ALL_TAGS: [&str; {len(tags)}] = [\n") for tag in tags: f.write(f' "{tag}",\n') f.write("];\n\n") for const_name, tag in TAG_ID_CONSTS: f.write(f"pub const {const_name}: u16 = {tag_to_id[tag]};\n") f.write("\n") for const_name, const_tags in TAG_SET_CONSTS: f.write(f"pub const {const_name}: TagSet = {tagset_expr(const_tags)};\n") f.write("\n") f.write("pub const INTERPRETERS: phf::Map<&str, TagSet> = phf::phf_map! {\n") for interpreter in sorted(INTERPRETERS): tag_names = sorted(INTERPRETERS[interpreter]) tag_names_str = ", ".join(f'"{tag}"' for tag in tag_names) f.write(f" // [{tag_names_str}]\n") f.write( f' "{interpreter}" => {tagset_expr(INTERPRETERS[interpreter])},\n' ) f.write("};\n\n") EXTENSIONS.update(EXTENSIONS_NEED_BINARY_CHECK) f.write("pub const EXTENSIONS: phf::Map<&str, TagSet> = phf::phf_map! {\n") for ext in sorted(EXTENSIONS): tag_names = sorted(EXTENSIONS[ext]) tag_names_str = ", ".join(f'"{tag}"' for tag in tag_names) f.write(f" // [{tag_names_str}]\n") f.write(f' "{ext}" => {tagset_expr(EXTENSIONS[ext])},\n') f.write("};\n\n") f.write("pub const NAMES: phf::Map<&str, TagSet> = phf::phf_map! {\n") for name in sorted(NAMES): tag_names = sorted(NAMES[name]) tag_names_str = ", ".join(f'"{tag}"' for tag in tag_names) f.write(f" // [{tag_names_str}]\n") f.write(f' "{name}" => {tagset_expr(NAMES[name])},\n') f.write("};\n") def main(): gen() if __name__ == "__main__": main() ================================================ FILE: crates/prek-identify/src/lib.rs ================================================ // Copyright (c) 2017 Chris Kuehl, Anthony Sottile // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. use std::borrow::Cow; use std::io::{BufRead, Read}; use std::ops::BitOrAssign; use std::path::Path; #[cfg(feature = "serde")] use serde::de::{Error as DeError, SeqAccess, Visitor}; pub mod tags; const TAG_WORDS: usize = tags::ALL_TAGS.len().div_ceil(64); /// A compact set of file tags represented as a fixed-size bitset. /// /// Each bit corresponds to an index in [`tags::ALL_TAGS`]. /// This keeps membership / set operations fast and allocation-free. #[derive(Clone, Copy, Default)] pub struct TagSet { bits: [u64; TAG_WORDS], } impl std::fmt::Debug for TagSet { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_list().entries(self.iter()).finish() } } fn tag_id(tag: &str) -> Option { tags::ALL_TAGS.binary_search(&tag).ok() } pub struct TagSetIter<'a> { bits: &'a [u64; TAG_WORDS], word_idx: usize, cur_word: u64, } impl Iterator for TagSetIter<'_> { type Item = &'static str; fn next(&mut self) -> Option { loop { if self.cur_word != 0 { // Find index of the least-significant set bit in the current 64-bit word. let tz = self.cur_word.trailing_zeros() as usize; // Clear that least-significant set bit so the next call advances to the next tag. self.cur_word &= self.cur_word - 1; // `word_idx` is already incremented when `cur_word` was loaded, // so we use `word_idx - 1` here to compute the global tag index. let idx = (self.word_idx.saturating_sub(1) * 64) + tz; return tags::ALL_TAGS.get(idx).copied(); } if self.word_idx >= TAG_WORDS { return None; } self.cur_word = self.bits[self.word_idx]; self.word_idx += 1; } } } impl TagSet { /// Constructs a [`TagSet`] from tag ids. /// /// `tag_ids` must reference valid indexes in [`tags::ALL_TAGS_BY_ID`]. /// Duplicate ids are allowed and are automatically coalesced. pub const fn new(tag_ids: &[u16]) -> Self { let mut bits = [0u64; TAG_WORDS]; let mut idx = 0; while idx < tag_ids.len() { let tag_id = tag_ids[idx] as usize; assert!(tag_id < tags::ALL_TAGS.len(), "tag id out of range"); bits[tag_id / 64] |= 1u64 << (tag_id % 64); idx += 1; } Self { bits } } /// Constructs a [`TagSet`] from tag strings. /// /// Unknown tags are ignored in release builds and debug-asserted in debug builds. pub fn from_tags(tags: I) -> Self where I: IntoIterator, S: AsRef, { let mut bits = [0u64; TAG_WORDS]; for tag in tags { let tag = tag.as_ref(); let Some(tag_id) = tag_id(tag) else { debug_assert!(false, "unknown tag: {tag}"); continue; }; bits[tag_id / 64] |= 1u64 << (tag_id % 64); } Self { bits } } pub const fn insert(&mut self, tag_id: u16) { let tag_id = tag_id as usize; assert!(tag_id < tags::ALL_TAGS.len(), "tag id out of range"); self.bits[tag_id / 64] |= 1u64 << (tag_id % 64); } /// Returns `true` if the two sets do not share any tag. pub fn is_disjoint(&self, other: &TagSet) -> bool { for idx in 0..TAG_WORDS { if (self.bits[idx] & other.bits[idx]) != 0 { return false; } } true } /// Returns `true` if all tags in `self` are also present in `other`. pub fn is_subset(&self, other: &TagSet) -> bool { for idx in 0..TAG_WORDS { if (self.bits[idx] & !other.bits[idx]) != 0 { return false; } } true } /// Iterates tags in deterministic id order. pub fn iter(&self) -> TagSetIter<'_> { TagSetIter { bits: &self.bits, word_idx: 0, cur_word: 0, } } /// Returns `true` if the set contains no tags. pub fn is_empty(&self) -> bool { self.bits.iter().all(|&w| w == 0) } } impl BitOrAssign<&TagSet> for TagSet { fn bitor_assign(&mut self, rhs: &TagSet) { for idx in 0..TAG_WORDS { self.bits[idx] |= rhs.bits[idx]; } } } #[cfg(feature = "serde")] impl<'de> serde::Deserialize<'de> for TagSet { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { struct TagSetVisitor; impl<'de> Visitor<'de> for TagSetVisitor { type Value = TagSet; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str("a sequence of tag strings") } fn visit_seq(self, mut seq: A) -> Result where A: SeqAccess<'de>, { let mut tags = TagSet::default(); while let Some(tag) = seq.next_element::>()? { let Some(tag_id) = tag_id(&tag) else { let msg = format!( "Type tag `{tag}` is not recognized. Check for typos or upgrade prek to get new tags." ); return Err(A::Error::custom(msg)); }; let tag_id = u16::try_from(tag_id) .map_err(|_| A::Error::custom("tag id out of range"))?; tags.insert(tag_id); } Ok(tags) } } deserializer.deserialize_seq(TagSetVisitor) } } #[cfg(feature = "schemars")] impl schemars::JsonSchema for TagSet { fn inline_schema() -> bool { true } fn schema_name() -> Cow<'static, str> { Cow::Borrowed("TagSet") } fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { schemars::json_schema!({ "type": "array", "items": { "type": "string", }, "uniqueItems": true, }) } } #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Shebang(#[from] ShebangError), } /// Identify tags for a file at the given path. pub fn tags_from_path(path: &Path) -> Result { let metadata = std::fs::symlink_metadata(path)?; if metadata.is_dir() { return Ok(tags::TAG_SET_DIRECTORY); } else if metadata.is_symlink() { return Ok(tags::TAG_SET_SYMLINK); } #[cfg(unix)] { use std::os::unix::fs::FileTypeExt; let file_type = metadata.file_type(); if file_type.is_socket() { return Ok(tags::TAG_SET_SOCKET); } }; let mut tags = tags::TAG_SET_FILE; let executable; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; executable = metadata.permissions().mode() & 0o111 != 0; } #[cfg(not(unix))] { // `pre-commit/identify` uses `os.access(path, os.X_OK)` to check for executability on Windows. // This would actually return true for any file. // We keep this behavior for compatibility. executable = true; } if executable { tags.insert(tags::TAG_EXECUTABLE); } else { tags.insert(tags::TAG_NON_EXECUTABLE); } let filename_tags = tags_from_filename(path); tags |= &filename_tags; if executable { if let Ok(shebang) = parse_shebang(path) { let interpreter_tags = tags_from_interpreter(shebang[0].as_str()); tags |= &interpreter_tags; } } if tags.is_disjoint(&tags::TAG_SET_TEXT_OR_BINARY) { if is_text_file(path) { tags.insert(tags::TAG_TEXT); } else { tags.insert(tags::TAG_BINARY); } } Ok(tags) } fn tags_from_filename(filename: &Path) -> TagSet { let ext = filename.extension().and_then(|ext| ext.to_str()); let filename = filename .file_name() .and_then(|name| name.to_str()) .expect("Invalid filename"); let mut result = tags::NAMES .get(filename) .or_else(|| { // Allow e.g. "Dockerfile.xenial" to match "Dockerfile". filename .split('.') .next() .and_then(|name| tags::NAMES.get(name)) }) .copied() .unwrap_or_default(); if let Some(ext) = ext { // Check if extension is already lowercase to avoid allocation if ext.chars().all(|c| c.is_ascii_lowercase()) { if let Some(tags) = tags::EXTENSIONS.get(ext) { result |= tags; } } else { let ext_lower = ext.to_ascii_lowercase(); if let Some(tags) = tags::EXTENSIONS.get(ext_lower.as_str()) { result |= tags; } } } result } fn tags_from_interpreter(interpreter: &str) -> TagSet { let mut name = interpreter .rfind('/') .map(|pos| &interpreter[pos + 1..]) .unwrap_or(interpreter); while !name.is_empty() { if let Some(tags) = tags::INTERPRETERS.get(name) { return *tags; } // python3.12.3 should match python3.12.3, python3.12, python3, python if let Some(pos) = name.rfind('.') { name = &name[..pos]; } else { break; } } TagSet::default() } #[derive(thiserror::Error, Debug)] pub enum ShebangError { #[error("No shebang found")] NoShebang, #[error("Shebang contains non-printable characters")] NonPrintableChars, #[error("Failed to parse shebang")] ParseFailed, #[error("No command found in shebang")] NoCommand, #[error("IO error: {0}")] IoError(#[from] std::io::Error), } fn starts_with(slice: &[String], prefix: &[&str]) -> bool { slice.len() >= prefix.len() && slice.iter().zip(prefix.iter()).all(|(s, p)| s == p) } /// Parse nix-shell shebangs, which may span multiple lines. /// See: /// Example: /// `#!nix-shell -i python3 -p python3` would return `["python3"]` fn parse_nix_shebang(reader: &mut R, mut cmd: Vec) -> Vec { loop { let Ok(buf) = reader.fill_buf() else { break; }; if buf.len() < 2 || &buf[..2] != b"#!" { break; } reader.consume(2); let mut next_line = String::new(); match reader.read_line(&mut next_line) { Ok(0) => break, Ok(_) => {} Err(err) => { if err.kind() == std::io::ErrorKind::InvalidData { return cmd; } break; } } let trimmed = next_line.trim(); if trimmed.is_empty() { continue; } if let Some(line_tokens) = shlex::split(trimmed) { for idx in 0..line_tokens.len().saturating_sub(1) { if line_tokens[idx] == "-i" { if let Some(interpreter) = line_tokens.get(idx + 1) { cmd = vec![interpreter.clone()]; } } } } } cmd } pub fn parse_shebang(path: &Path) -> Result, ShebangError> { let file = std::fs::File::open(path)?; let mut reader = std::io::BufReader::new(file); let mut line = String::new(); reader.read_line(&mut line)?; if !line.starts_with("#!") { return Err(ShebangError::NoShebang); } // Require only printable ASCII if line .bytes() .any(|b| !(0x20..=0x7E).contains(&b) && !(0x09..=0x0D).contains(&b)) { return Err(ShebangError::NonPrintableChars); } let mut tokens = shlex::split(line[2..].trim()).ok_or(ShebangError::ParseFailed)?; let mut cmd = if starts_with(&tokens, &["/usr/bin/env", "-S"]) || starts_with(&tokens, &["env", "-S"]) { tokens.drain(0..2); tokens } else if starts_with(&tokens, &["/usr/bin/env"]) || starts_with(&tokens, &["env"]) { tokens.drain(0..1); tokens } else { tokens }; if cmd.is_empty() { return Err(ShebangError::NoCommand); } if cmd[0] == "nix-shell" { cmd = parse_nix_shebang(&mut reader, cmd); } if cmd.is_empty() { return Err(ShebangError::NoCommand); } Ok(cmd) } // Lookup table for text character detection. static IS_TEXT_CHAR: [u32; 8] = { let mut table = [0u32; 8]; let mut i = 0; while i < 256 { // Printable ASCII (0x20..0x7F) // High bit set (>= 0x80) // Control characters: 7, 8, 9, 10, 11, 12, 13, 27 let is_text = (i >= 0x20 && i < 0x7F) || i >= 0x80 || matches!(i, 7 | 8 | 9 | 10 | 11 | 12 | 13 | 27); if is_text { table[i / 32] |= 1 << (i % 32); } i += 1; } table }; fn is_text_char(b: u8) -> bool { let idx = b as usize; (IS_TEXT_CHAR[idx / 32] & (1 << (idx % 32))) != 0 } /// Return whether the first KB of contents seems to be binary. /// /// This is roughly based on libmagic's binary/text detection: /// fn is_text_file(path: &Path) -> bool { let mut buffer = [0; 1024]; let Ok(mut file) = fs_err::File::open(path) else { return false; }; let Ok(bytes_read) = file.read(&mut buffer) else { return false; }; if bytes_read == 0 { return true; } buffer[..bytes_read].iter().all(|&b| is_text_char(b)) } #[cfg(test)] mod tests { use super::{TagSet, tags}; use std::io::Write; use std::path::Path; fn assert_tagset(actual: &TagSet, expected: &[&'static str]) { let mut actual_vec: Vec<_> = actual.iter().collect(); actual_vec.sort_unstable(); let mut expected_vec = expected.to_vec(); expected_vec.sort_unstable(); assert_eq!(actual_vec, expected_vec); } #[test] #[cfg(unix)] fn tags_from_path() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; let src = dir.path().join("source.txt"); let dest = dir.path().join("link.txt"); fs_err::File::create(&src)?; std::os::unix::fs::symlink(&src, &dest)?; let tags = super::tags_from_path(dir.path())?; assert_tagset(&tags, &["directory"]); let tags = super::tags_from_path(&src)?; assert_tagset(&tags, &["plain-text", "non-executable", "file", "text"]); let tags = super::tags_from_path(&dest)?; assert_tagset(&tags, &["symlink"]); Ok(()) } #[test] #[cfg(windows)] fn tags_from_path() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; let src = dir.path().join("source.txt"); fs_err::File::create(&src)?; let tags = super::tags_from_path(dir.path())?; assert_tagset(&tags, &["directory"]); let tags = super::tags_from_path(&src)?; assert_tagset(&tags, &["plain-text", "executable", "file", "text"]); Ok(()) } #[test] fn tags_from_filename() { let tags = super::tags_from_filename(Path::new("test.py")); assert_tagset(&tags, &["python", "text"]); let tags = super::tags_from_filename(Path::new("bitbake.bbappend")); assert_tagset(&tags, &["bitbake", "text"]); let tags = super::tags_from_filename(Path::new("project.fsproj")); assert_tagset(&tags, &["fsproj", "msbuild", "text", "xml"]); let tags = super::tags_from_filename(Path::new("data.json")); assert_tagset(&tags, &["json", "text"]); let tags = super::tags_from_filename(Path::new("build.props")); assert_tagset(&tags, &["msbuild", "text", "xml"]); let tags = super::tags_from_filename(Path::new("profile.psd1")); assert_tagset(&tags, &["powershell", "text"]); let tags = super::tags_from_filename(Path::new("style.xslt")); assert_tagset(&tags, &["text", "xml", "xsl"]); let tags = super::tags_from_filename(Path::new("Pipfile")); assert_tagset(&tags, &["toml", "text"]); let tags = super::tags_from_filename(Path::new("Pipfile.lock")); assert_tagset(&tags, &["json", "text"]); let tags = super::tags_from_filename(Path::new("file.pdf")); assert_tagset(&tags, &["pdf", "binary"]); let tags = super::tags_from_filename(Path::new("FILE.PDF")); assert_tagset(&tags, &["pdf", "binary"]); let tags = super::tags_from_filename(Path::new(".envrc")); assert_tagset(&tags, &["bash", "shell", "text"]); let tags = super::tags_from_filename(Path::new("meson.options")); assert_tagset(&tags, &["meson", "meson-options", "text"]); let tags = super::tags_from_filename(Path::new("Tiltfile")); assert_tagset(&tags, &["text", "tiltfile"]); let tags = super::tags_from_filename(Path::new("Tiltfile.dev")); assert_tagset(&tags, &["text", "tiltfile"]); } #[test] fn tags_from_interpreter() { let tags = super::tags_from_interpreter("/usr/bin/python3"); assert_tagset(&tags, &["python", "python3"]); let tags = super::tags_from_interpreter("/usr/bin/python3.12"); assert_tagset(&tags, &["python", "python3"]); let tags = super::tags_from_interpreter("/usr/bin/python3.12.3"); assert_tagset(&tags, &["python", "python3"]); let tags = super::tags_from_interpreter("python"); assert_tagset(&tags, &["python"]); let tags = super::tags_from_interpreter("sh"); assert_tagset(&tags, &["shell", "sh"]); let tags = super::tags_from_interpreter("invalid"); assert!(tags.is_empty()); } #[test] fn tagset_new_iter_and_is_empty() { let empty = TagSet::new(&[]); assert!(empty.is_empty()); assert_eq!(empty.iter().count(), 0); let binary_id = u16::try_from(super::tag_id("binary").expect("binary id")).unwrap(); let text_id = u16::try_from(super::tag_id("text").expect("text id")).unwrap(); let set = TagSet::new(&[text_id, binary_id, text_id]); assert!(!set.is_empty()); assert_eq!(set.iter().collect::>(), vec!["binary", "text"]); } #[test] fn tagset_from_tags_intersects_subset_and_bitor_assign() { let a = TagSet::from_tags(["python", "text"]); let b = TagSet::from_tags(["python"]); let c = TagSet::from_tags(["binary"]); assert!(b.is_subset(&a)); assert!(!a.is_subset(&b)); assert!(!a.is_disjoint(&b)); assert!(a.is_disjoint(&c)); let mut merged = b; merged |= &c; assert_tagset(&merged, &["python", "binary"]); } #[test] fn tagset_new_panics_on_out_of_range_id() { let out_of_range = u16::try_from(tags::ALL_TAGS.len()).unwrap(); let result = std::panic::catch_unwind(|| TagSet::new(&[out_of_range])); assert!(result.is_err()); } #[cfg(feature = "serde")] #[test] fn tagset_deserialize_from_string_slice() { let parsed: TagSet = serde_json::from_str(r#"["python","text"]"#).expect("should parse tags"); assert_tagset(&parsed, &["python", "text"]); } #[cfg(feature = "serde")] #[test] fn tagset_deserialize_unknown_tag_errors() { let err = serde_json::from_str::(r#"["not-a-real-tag"]"#).unwrap_err(); assert!( err.to_string() .contains("Type tag `not-a-real-tag` is not recognized"), "unexpected error: {err}" ); } #[test] fn parse_shebang_nix_shell_interpreter() -> anyhow::Result<()> { let mut file = tempfile::NamedTempFile::new()?; writeln!( file, indoc::indoc! {r#" #!/usr/bin/env nix-shell #! nix-shell --pure -i bash -p "python3.withPackages (p: [ p.numpy p.sympy ])" #! nix-shell -I nixpkgs=https://example.com echo hi "#} )?; file.flush()?; let cmd = super::parse_shebang(file.path())?; assert_eq!(cmd, vec!["bash"]); Ok(()) } #[test] fn parse_shebang_nix_shell_without_interpreter() -> anyhow::Result<()> { let mut file = tempfile::NamedTempFile::new()?; writeln!( file, indoc::indoc! {r" #!/usr/bin/env nix-shell -p python3 #! nix-shell --pure -I nixpkgs=https://example.com echo hi "} )?; file.flush()?; let cmd = super::parse_shebang(file.path())?; assert_eq!(cmd, vec!["nix-shell", "-p", "python3"]); Ok(()) } } ================================================ FILE: crates/prek-identify/src/tags.rs ================================================ // This file is auto-generated by gen.py. DO NOT EDIT MANUALLY. use crate::TagSet; pub const ALL_TAGS: [&str; 311] = [ "adobe-illustrator", "alpm", "apinotes", "asar", "asciidoc", "ash", "asm", "aspectj", "astro", "audio", "avif", "avro-schema", "awk", "babelrc", "bash", "batch", "bats", "bazel", "bazelrc", "beancount", "bib", "binary", "bitbake", "bitmap", "bowerrc", "browserslistrc", "bzip2", "bzip3", "c", "c#", "c#script", "c++", "c2hs", "cargo", "cargo-lock", "cbsd", "clojure", "clojurescript", "cmake", "codespellrc", "coffee", "coveragerc", "crystal", "csh", "cson", "csproj", "css", "csslintrc", "csv", "cuda", "cue", "cylc", "cython", "dart", "dash", "dbc", "def", "diff", "directory", "dockerfile", "dockerignore", "dotenv", "dtd", "editorconfig", "edn", "ejs", "ejson", "elixir", "elm", "entitlements", "eot", "eps", "erb", "erlang", "executable", "expect", "f#", "f#script", "file", "fish", "fits", "flake8", "fortran", "fsproj", "gdscript", "geojson", "ggb", "gherkin", "gif", "gitattributes", "gitconfig", "gitignore", "gitlint", "gitmodules", "gleam", "go", "go-mod", "go-sum", "gotmpl", "gpx", "graphql", "groovy", "gyb", "gyp", "gzip", "handlebars", "haskell", "hcl", "header", "hgrc", "hlsl", "html", "icalendar", "icns", "icon", "idl", "idris", "image", "inc", "ini", "inl", "ino", "inx", "ipxe", "isort", "jade", "jar", "java", "java-properties", "javascript", "jbuilder", "jenkins", "jinja", "jpeg", "jshintrc", "json", "json5", "jsonld", "jsonnet", "jsx", "julia", "jupyter", "kml", "kotlin", "ksh", "lazarus", "lazarus-form", "lean", "lektor", "lektorproject", "less", "liquid", "literate-haskell", "lua", "m4", "magik", "mailmap", "makefile", "manifest", "map", "markdown", "mdx", "mention-bot", "meson", "meson-options", "metal", "mib", "modulemap", "msbuild", "musescore", "mustache", "myst", "ngdoc", "nim", "nimble", "nix", "non-executable", "npmignore", "nunjucks", "objective-c", "objective-c++", "ocaml", "otf", "p12", "pascal", "pdbrc", "pdf", "pem", "perl", "php", "php7", "php8", "piskel", "pkgbuild", "plain-text", "plantuml", "plist", "png", "pofile", "pom", "powershell", "ppm", "prettierignore", "prisma", "proto", "pug", "puppet", "purescript", "pyi", "pylintrc", "pypirc", "pyproj", "pyproject", "python", "python2", "python3", "pyz", "qml", "r", "relax-ng", "resx", "robot", "rst", "ruby", "rust", "salt", "salt-lint", "sas", "sass", "sbt", "scala", "scheme", "scons", "scss", "sh", "shell", "sln", "slnx", "socket", "solidity", "spec", "sql", "stylus", "svelte", "svg", "swf", "swift", "swiftdeps", "symlink", "system-verilog", "tar", "tcsh", "templ", "terraform", "tex", "text", "textproto", "thrift", "tiff", "tiltfile", "toml", "ts", "tsv", "tsx", "ttf", "twig", "twisted", "txsprofile", "urdf", "vb", "vbproj", "vcxproj", "vdx", "verilog", "vhdl", "vim", "vtl", "vue", "wav", "webp", "wheel", "wkt", "woff", "woff2", "wsdl", "wsgi", "xacro", "xaml", "xcconfig", "xcodebuild", "xcprivacy", "xcscheme", "xcsettings", "xctestplan", "xcworkspacedata", "xhtml", "xliff", "xml", "xquery", "xsd", "xsl", "yaml", "yamlld", "yamllint", "yang", "yin", "zcml", "zig", "zip", "zpt", "zsh", ]; pub const TAG_FILE: u16 = 78; pub const TAG_DIRECTORY: u16 = 58; pub const TAG_SYMLINK: u16 = 248; pub const TAG_SOCKET: u16 = 238; pub const TAG_EXECUTABLE: u16 = 74; pub const TAG_NON_EXECUTABLE: u16 = 176; pub const TAG_TEXT: u16 = 255; pub const TAG_BINARY: u16 = 21; pub const TAG_SET_FILE: TagSet = TagSet::new(&[78]); pub const TAG_SET_DIRECTORY: TagSet = TagSet::new(&[58]); pub const TAG_SET_SYMLINK: TagSet = TagSet::new(&[248]); pub const TAG_SET_SOCKET: TagSet = TagSet::new(&[238]); pub const TAG_SET_TEXT: TagSet = TagSet::new(&[255]); pub const TAG_SET_TEXT_OR_BINARY: TagSet = TagSet::new(&[21, 255]); pub const TAG_SET_EXECUTABLE_TEXT: TagSet = TagSet::new(&[74, 255]); pub const TAG_SET_JSON: TagSet = TagSet::new(&[135]); pub const TAG_SET_JSON5: TagSet = TagSet::new(&[136]); pub const TAG_SET_TOML: TagSet = TagSet::new(&[260]); pub const TAG_SET_XML: TagSet = TagSet::new(&[297]); pub const TAG_SET_YAML: TagSet = TagSet::new(&[301]); pub const INTERPRETERS: phf::Map<&str, TagSet> = phf::phf_map! { // ["ash", "shell"] "ash" => TagSet::new(&[5, 235]), // ["awk"] "awk" => TagSet::new(&[12]), // ["bash", "shell"] "bash" => TagSet::new(&[14, 235]), // ["bash", "bats", "shell"] "bats" => TagSet::new(&[14, 16, 235]), // ["cbsd", "shell"] "cbsd" => TagSet::new(&[35, 235]), // ["csh", "shell"] "csh" => TagSet::new(&[43, 235]), // ["dash", "shell"] "dash" => TagSet::new(&[54, 235]), // ["erlang"] "escript" => TagSet::new(&[73]), // ["expect"] "expect" => TagSet::new(&[75]), // ["ksh", "shell"] "ksh" => TagSet::new(&[144, 235]), // ["javascript"] "node" => TagSet::new(&[129]), // ["javascript"] "nodejs" => TagSet::new(&[129]), // ["perl"] "perl" => TagSet::new(&[188]), // ["php"] "php" => TagSet::new(&[189]), // ["php", "php7"] "php7" => TagSet::new(&[189, 190]), // ["php", "php8"] "php8" => TagSet::new(&[189, 191]), // ["python"] "python" => TagSet::new(&[213]), // ["python", "python2"] "python2" => TagSet::new(&[213, 214]), // ["python", "python3"] "python3" => TagSet::new(&[213, 215]), // ["ruby"] "ruby" => TagSet::new(&[223]), // ["sh", "shell"] "sh" => TagSet::new(&[234, 235]), // ["shell", "tcsh"] "tcsh" => TagSet::new(&[235, 251]), // ["shell", "zsh"] "zsh" => TagSet::new(&[235, 310]), }; pub const EXTENSIONS: phf::Map<&str, TagSet> = phf::phf_map! { // ["asciidoc", "text"] "adoc" => TagSet::new(&[4, 255]), // ["adobe-illustrator", "binary"] "ai" => TagSet::new(&[0, 21]), // ["aspectj", "text"] "aj" => TagSet::new(&[7, 255]), // ["apinotes", "text"] "apinotes" => TagSet::new(&[2, 255]), // ["asar", "binary"] "asar" => TagSet::new(&[3, 21]), // ["asciidoc", "text"] "asciidoc" => TagSet::new(&[4, 255]), // ["asm", "text"] "asm" => TagSet::new(&[6, 255]), // ["astro", "text"] "astro" => TagSet::new(&[8, 255]), // ["avif", "binary", "image"] "avif" => TagSet::new(&[10, 21, 117]), // ["avro-schema", "text"] "avsc" => TagSet::new(&[11, 255]), // ["bash", "shell", "text"] "bash" => TagSet::new(&[14, 235, 255]), // ["batch", "text"] "bat" => TagSet::new(&[15, 255]), // ["bash", "bats", "shell", "text"] "bats" => TagSet::new(&[14, 16, 235, 255]), // ["bazel", "text"] "bazel" => TagSet::new(&[17, 255]), // ["bitbake", "text"] "bb" => TagSet::new(&[22, 255]), // ["bitbake", "text"] "bbappend" => TagSet::new(&[22, 255]), // ["bitbake", "text"] "bbclass" => TagSet::new(&[22, 255]), // ["beancount", "text"] "beancount" => TagSet::new(&[19, 255]), // ["bib", "text"] "bib" => TagSet::new(&[20, 255]), // ["binary", "bitmap", "image"] "bmp" => TagSet::new(&[21, 23, 117]), // ["binary", "bzip2"] "bz2" => TagSet::new(&[21, 26]), // ["binary", "bzip3"] "bz3" => TagSet::new(&[21, 27]), // ["bazel", "text"] "bzl" => TagSet::new(&[17, 255]), // ["c", "text"] "c" => TagSet::new(&[28, 255]), // ["c++", "text"] "c++" => TagSet::new(&[31, 255]), // ["c++", "text"] "c++m" => TagSet::new(&[31, 255]), // ["c++", "text"] "cc" => TagSet::new(&[31, 255]), // ["c++", "text"] "ccm" => TagSet::new(&[31, 255]), // ["text"] "cfg" => TagSet::new(&[255]), // ["c2hs", "text"] "chs" => TagSet::new(&[32, 255]), // ["javascript", "text"] "cjs" => TagSet::new(&[129, 255]), // ["clojure", "text"] "clj" => TagSet::new(&[36, 255]), // ["clojure", "text"] "cljc" => TagSet::new(&[36, 255]), // ["clojure", "clojurescript", "text"] "cljs" => TagSet::new(&[36, 37, 255]), // ["cmake", "text"] "cmake" => TagSet::new(&[38, 255]), // ["batch", "text"] "cmd" => TagSet::new(&[15, 255]), // ["text"] "cnf" => TagSet::new(&[255]), // ["coffee", "text"] "coffee" => TagSet::new(&[40, 255]), // ["text"] "conf" => TagSet::new(&[255]), // ["c++", "text"] "cpp" => TagSet::new(&[31, 255]), // ["c++", "text"] "cppm" => TagSet::new(&[31, 255]), // ["crystal", "text"] "cr" => TagSet::new(&[42, 255]), // ["pem", "text"] "crt" => TagSet::new(&[187, 255]), // ["c#", "text"] "cs" => TagSet::new(&[29, 255]), // ["csh", "shell", "text"] "csh" => TagSet::new(&[43, 235, 255]), // ["cson", "text"] "cson" => TagSet::new(&[44, 255]), // ["csproj", "msbuild", "text", "xml"] "csproj" => TagSet::new(&[45, 168, 255, 297]), // ["css", "text"] "css" => TagSet::new(&[46, 255]), // ["csv", "text"] "csv" => TagSet::new(&[48, 255]), // ["c#", "c#script", "text"] "csx" => TagSet::new(&[29, 30, 255]), // ["cuda", "text"] "cu" => TagSet::new(&[49, 255]), // ["cue", "text"] "cue" => TagSet::new(&[50, 255]), // ["cuda", "text"] "cuh" => TagSet::new(&[49, 255]), // ["c++", "text"] "cxx" => TagSet::new(&[31, 255]), // ["c++", "text"] "cxxm" => TagSet::new(&[31, 255]), // ["cylc", "text"] "cylc" => TagSet::new(&[51, 255]), // ["dart", "text"] "dart" => TagSet::new(&[53, 255]), // ["dbc", "text"] "dbc" => TagSet::new(&[55, 255]), // ["def", "text"] "def" => TagSet::new(&[56, 255]), // ["diff", "text"] "diff" => TagSet::new(&[57, 255]), // ["binary"] "dll" => TagSet::new(&[21]), // ["dtd", "text"] "dtd" => TagSet::new(&[62, 255]), // ["binary", "jar", "zip"] "ear" => TagSet::new(&[21, 126, 308]), // ["clojure", "edn", "text"] "edn" => TagSet::new(&[36, 64, 255]), // ["ejs", "text"] "ejs" => TagSet::new(&[65, 255]), // ["ejson", "json", "text"] "ejson" => TagSet::new(&[66, 135, 255]), // ["elm", "text"] "elm" => TagSet::new(&[68, 255]), // ["entitlements", "plist"] "entitlements" => TagSet::new(&[69, 196]), // ["dotenv", "text"] "env" => TagSet::new(&[61, 255]), // ["binary", "eot"] "eot" => TagSet::new(&[21, 70]), // ["binary", "eps"] "eps" => TagSet::new(&[21, 71]), // ["erb", "text"] "erb" => TagSet::new(&[72, 255]), // ["erlang", "text"] "erl" => TagSet::new(&[73, 255]), // ["erlang", "text"] "escript" => TagSet::new(&[73, 255]), // ["elixir", "text"] "ex" => TagSet::new(&[67, 255]), // ["binary"] "exe" => TagSet::new(&[21]), // ["elixir", "text"] "exs" => TagSet::new(&[67, 255]), // ["text", "yaml"] "eyaml" => TagSet::new(&[255, 301]), // ["fortran", "text"] "f03" => TagSet::new(&[82, 255]), // ["fortran", "text"] "f08" => TagSet::new(&[82, 255]), // ["fortran", "text"] "f90" => TagSet::new(&[82, 255]), // ["fortran", "text"] "f95" => TagSet::new(&[82, 255]), // ["gherkin", "text"] "feature" => TagSet::new(&[87, 255]), // ["fish", "text"] "fish" => TagSet::new(&[79, 255]), // ["binary", "fits"] "fits" => TagSet::new(&[21, 80]), // ["f#", "text"] "fs" => TagSet::new(&[76, 255]), // ["fsproj", "msbuild", "text", "xml"] "fsproj" => TagSet::new(&[83, 168, 255, 297]), // ["f#", "f#script", "text"] "fsx" => TagSet::new(&[76, 77, 255]), // ["gdscript", "text"] "gd" => TagSet::new(&[84, 255]), // ["ruby", "text"] "gemspec" => TagSet::new(&[223, 255]), // ["geojson", "json", "text"] "geojson" => TagSet::new(&[85, 135, 255]), // ["binary", "ggb", "zip"] "ggb" => TagSet::new(&[21, 86, 308]), // ["binary", "gif", "image"] "gif" => TagSet::new(&[21, 88, 117]), // ["gleam", "text"] "gleam" => TagSet::new(&[94, 255]), // ["go", "text"] "go" => TagSet::new(&[95, 255]), // ["gotmpl", "text"] "gotmpl" => TagSet::new(&[98, 255]), // ["gpx", "text", "xml"] "gpx" => TagSet::new(&[99, 255, 297]), // ["groovy", "text"] "gradle" => TagSet::new(&[101, 255]), // ["graphql", "text"] "graphql" => TagSet::new(&[100, 255]), // ["groovy", "text"] "groovy" => TagSet::new(&[101, 255]), // ["gyb", "text"] "gyb" => TagSet::new(&[102, 255]), // ["gyp", "python", "text"] "gyp" => TagSet::new(&[103, 213, 255]), // ["gyp", "python", "text"] "gypi" => TagSet::new(&[103, 213, 255]), // ["binary", "gzip"] "gz" => TagSet::new(&[21, 104]), // ["c", "c++", "header", "text"] "h" => TagSet::new(&[28, 31, 108, 255]), // ["handlebars", "text"] "hbs" => TagSet::new(&[105, 255]), // ["hcl", "text"] "hcl" => TagSet::new(&[107, 255]), // ["c++", "header", "text"] "hh" => TagSet::new(&[31, 108, 255]), // ["hlsl", "text"] "hlsl" => TagSet::new(&[110, 255]), // ["hlsl", "text"] "hlsli" => TagSet::new(&[110, 255]), // ["c++", "header", "text"] "hpp" => TagSet::new(&[31, 108, 255]), // ["erlang", "text"] "hrl" => TagSet::new(&[73, 255]), // ["haskell", "text"] "hs" => TagSet::new(&[106, 255]), // ["html", "text"] "htm" => TagSet::new(&[111, 255]), // ["html", "text"] "html" => TagSet::new(&[111, 255]), // ["c++", "header", "text"] "hxx" => TagSet::new(&[31, 108, 255]), // ["binary", "icns"] "icns" => TagSet::new(&[21, 113]), // ["binary", "icon"] "ico" => TagSet::new(&[21, 114]), // ["icalendar", "text"] "ics" => TagSet::new(&[112, 255]), // ["idl", "text"] "idl" => TagSet::new(&[115, 255]), // ["idris", "text"] "idr" => TagSet::new(&[116, 255]), // ["inc", "text"] "inc" => TagSet::new(&[118, 255]), // ["ini", "text"] "ini" => TagSet::new(&[119, 255]), // ["c++", "inl", "text"] "inl" => TagSet::new(&[31, 120, 255]), // ["c++", "ino", "text"] "ino" => TagSet::new(&[31, 121, 255]), // ["inx", "text", "xml"] "inx" => TagSet::new(&[122, 255, 297]), // ["c++", "text"] "ipp" => TagSet::new(&[31, 255]), // ["ipxe", "text"] "ipxe" => TagSet::new(&[123, 255]), // ["json", "jupyter", "text"] "ipynb" => TagSet::new(&[135, 141, 255]), // ["c++", "text"] "ixx" => TagSet::new(&[31, 255]), // ["jinja", "text"] "j2" => TagSet::new(&[132, 255]), // ["jade", "text"] "jade" => TagSet::new(&[125, 255]), // ["binary", "jar", "zip"] "jar" => TagSet::new(&[21, 126, 308]), // ["java", "text"] "java" => TagSet::new(&[127, 255]), // ["jbuilder", "ruby", "text"] "jbuilder" => TagSet::new(&[130, 223, 255]), // ["groovy", "jenkins", "text"] "jenkins" => TagSet::new(&[101, 131, 255]), // ["groovy", "jenkins", "text"] "jenkinsfile" => TagSet::new(&[101, 131, 255]), // ["jinja", "text"] "jinja" => TagSet::new(&[132, 255]), // ["jinja", "text"] "jinja2" => TagSet::new(&[132, 255]), // ["julia", "text"] "jl" => TagSet::new(&[140, 255]), // ["binary", "image", "jpeg"] "jpeg" => TagSet::new(&[21, 117, 133]), // ["binary", "image", "jpeg"] "jpg" => TagSet::new(&[21, 117, 133]), // ["javascript", "text"] "js" => TagSet::new(&[129, 255]), // ["json", "text"] "json" => TagSet::new(&[135, 255]), // ["json5", "text"] "json5" => TagSet::new(&[136, 255]), // ["json", "jsonld", "text"] "jsonld" => TagSet::new(&[135, 137, 255]), // ["jsonnet", "text"] "jsonnet" => TagSet::new(&[138, 255]), // ["jsx", "text"] "jsx" => TagSet::new(&[139, 255]), // ["pem", "text"] "key" => TagSet::new(&[187, 255]), // ["kml", "text", "xml"] "kml" => TagSet::new(&[142, 255, 297]), // ["kotlin", "text"] "kt" => TagSet::new(&[143, 255]), // ["kotlin", "text"] "kts" => TagSet::new(&[143, 255]), // ["lean", "text"] "lean" => TagSet::new(&[147, 255]), // ["ini", "lektorproject", "text"] "lektorproject" => TagSet::new(&[119, 149, 255]), // ["less", "text"] "less" => TagSet::new(&[150, 255]), // ["lazarus", "lazarus-form", "text"] "lfm" => TagSet::new(&[145, 146, 255]), // ["literate-haskell", "text"] "lhs" => TagSet::new(&[152, 255]), // ["jsonnet", "text"] "libsonnet" => TagSet::new(&[138, 255]), // ["idris", "text"] "lidr" => TagSet::new(&[116, 255]), // ["liquid", "text"] "liquid" => TagSet::new(&[151, 255]), // ["lazarus", "text", "xml"] "lpi" => TagSet::new(&[145, 255, 297]), // ["lazarus", "pascal", "text"] "lpr" => TagSet::new(&[145, 184, 255]), // ["lektor", "text"] "lr" => TagSet::new(&[148, 255]), // ["lua", "text"] "lua" => TagSet::new(&[153, 255]), // ["objective-c", "text"] "m" => TagSet::new(&[179, 255]), // ["m4", "text"] "m4" => TagSet::new(&[154, 255]), // ["magik", "text"] "magik" => TagSet::new(&[155, 255]), // ["makefile", "text"] "make" => TagSet::new(&[157, 255]), // ["manifest", "text"] "manifest" => TagSet::new(&[158, 255]), // ["map", "text"] "map" => TagSet::new(&[159, 255]), // ["markdown", "text"] "markdown" => TagSet::new(&[160, 255]), // ["markdown", "text"] "md" => TagSet::new(&[160, 255]), // ["mdx", "text"] "mdx" => TagSet::new(&[161, 255]), // ["meson", "text"] "meson" => TagSet::new(&[163, 255]), // ["metal", "text"] "metal" => TagSet::new(&[165, 255]), // ["mib", "text"] "mib" => TagSet::new(&[166, 255]), // ["javascript", "text"] "mjs" => TagSet::new(&[129, 255]), // ["makefile", "text"] "mk" => TagSet::new(&[157, 255]), // ["ocaml", "text"] "ml" => TagSet::new(&[181, 255]), // ["ocaml", "text"] "mli" => TagSet::new(&[181, 255]), // ["c++", "objective-c++", "text"] "mm" => TagSet::new(&[31, 180, 255]), // ["modulemap", "text"] "modulemap" => TagSet::new(&[167, 255]), // ["musescore", "text", "xml"] "mscx" => TagSet::new(&[169, 255, 297]), // ["binary", "musescore", "zip"] "mscz" => TagSet::new(&[21, 169, 308]), // ["mustache", "text"] "mustache" => TagSet::new(&[170, 255]), // ["myst", "text"] "myst" => TagSet::new(&[171, 255]), // ["ngdoc", "text"] "ngdoc" => TagSet::new(&[172, 255]), // ["nim", "text"] "nim" => TagSet::new(&[173, 255]), // ["nimble", "text"] "nimble" => TagSet::new(&[174, 255]), // ["nim", "text"] "nims" => TagSet::new(&[173, 255]), // ["nix", "text"] "nix" => TagSet::new(&[175, 255]), // ["nunjucks", "text"] "njk" => TagSet::new(&[178, 255]), // ["binary", "otf"] "otf" => TagSet::new(&[21, 182]), // ["binary", "p12"] "p12" => TagSet::new(&[21, 183]), // ["pascal", "text"] "pas" => TagSet::new(&[184, 255]), // ["diff", "text"] "patch" => TagSet::new(&[57, 255]), // ["binary", "pdf"] "pdf" => TagSet::new(&[21, 186]), // ["pem", "text"] "pem" => TagSet::new(&[187, 255]), // ["php", "text"] "php" => TagSet::new(&[189, 255]), // ["php", "text"] "php4" => TagSet::new(&[189, 255]), // ["php", "text"] "php5" => TagSet::new(&[189, 255]), // ["php", "text"] "phtml" => TagSet::new(&[189, 255]), // ["json", "piskel", "text"] "piskel" => TagSet::new(&[135, 192, 255]), // ["perl", "text"] "pl" => TagSet::new(&[188, 255]), // ["plantuml", "text"] "plantuml" => TagSet::new(&[195, 255]), // ["plist"] "plist" => TagSet::new(&[196]), // ["perl", "text"] "pm" => TagSet::new(&[188, 255]), // ["binary", "image", "png"] "png" => TagSet::new(&[21, 117, 197]), // ["pofile", "text"] "po" => TagSet::new(&[198, 255]), // ["pom", "text", "xml"] "pom" => TagSet::new(&[199, 255, 297]), // ["puppet", "text"] "pp" => TagSet::new(&[206, 255]), // ["image", "ppm"] "ppm" => TagSet::new(&[117, 201]), // ["prisma", "text"] "prisma" => TagSet::new(&[203, 255]), // ["java-properties", "text"] "properties" => TagSet::new(&[128, 255]), // ["msbuild", "text", "xml"] "props" => TagSet::new(&[168, 255, 297]), // ["proto", "text"] "proto" => TagSet::new(&[204, 255]), // ["powershell", "text"] "ps1" => TagSet::new(&[200, 255]), // ["powershell", "text"] "psd1" => TagSet::new(&[200, 255]), // ["powershell", "text"] "psm1" => TagSet::new(&[200, 255]), // ["pug", "text"] "pug" => TagSet::new(&[205, 255]), // ["plantuml", "text"] "puml" => TagSet::new(&[195, 255]), // ["purescript", "text"] "purs" => TagSet::new(&[207, 255]), // ["cython", "text"] "pxd" => TagSet::new(&[52, 255]), // ["cython", "text"] "pxi" => TagSet::new(&[52, 255]), // ["python", "text"] "py" => TagSet::new(&[213, 255]), // ["pyi", "text"] "pyi" => TagSet::new(&[208, 255]), // ["msbuild", "pyproj", "text", "xml"] "pyproj" => TagSet::new(&[168, 211, 255, 297]), // ["python", "text"] "pyt" => TagSet::new(&[213, 255]), // ["python", "text"] "pyw" => TagSet::new(&[213, 255]), // ["cython", "text"] "pyx" => TagSet::new(&[52, 255]), // ["binary", "pyz"] "pyz" => TagSet::new(&[21, 216]), // ["binary", "pyz"] "pyzw" => TagSet::new(&[21, 216]), // ["qml", "text"] "qml" => TagSet::new(&[217, 255]), // ["r", "text"] "r" => TagSet::new(&[218, 255]), // ["ruby", "text"] "rake" => TagSet::new(&[223, 255]), // ["ruby", "text"] "rb" => TagSet::new(&[223, 255]), // ["resx", "text", "xml"] "resx" => TagSet::new(&[220, 255, 297]), // ["relax-ng", "text", "xml"] "rng" => TagSet::new(&[219, 255, 297]), // ["robot", "text"] "robot" => TagSet::new(&[221, 255]), // ["rust", "text"] "rs" => TagSet::new(&[224, 255]), // ["rst", "text"] "rst" => TagSet::new(&[222, 255]), // ["asm", "text"] "s" => TagSet::new(&[6, 255]), // ["sas", "text"] "sas" => TagSet::new(&[227, 255]), // ["sass", "text"] "sass" => TagSet::new(&[228, 255]), // ["sbt", "scala", "text"] "sbt" => TagSet::new(&[229, 230, 255]), // ["scala", "text"] "sc" => TagSet::new(&[230, 255]), // ["scala", "text"] "scala" => TagSet::new(&[230, 255]), // ["scheme", "text"] "scm" => TagSet::new(&[231, 255]), // ["scss", "text"] "scss" => TagSet::new(&[233, 255]), // ["shell", "text"] "sh" => TagSet::new(&[235, 255]), // ["sln", "text"] "sln" => TagSet::new(&[236, 255]), // ["msbuild", "slnx", "text", "xml"] "slnx" => TagSet::new(&[168, 237, 255, 297]), // ["salt", "text"] "sls" => TagSet::new(&[225, 255]), // ["binary"] "so" => TagSet::new(&[21]), // ["solidity", "text"] "sol" => TagSet::new(&[239, 255]), // ["spec", "text"] "spec" => TagSet::new(&[240, 255]), // ["sql", "text"] "sql" => TagSet::new(&[241, 255]), // ["scheme", "text"] "ss" => TagSet::new(&[231, 255]), // ["tex", "text"] "sty" => TagSet::new(&[254, 255]), // ["stylus", "text"] "styl" => TagSet::new(&[242, 255]), // ["system-verilog", "text"] "sv" => TagSet::new(&[249, 255]), // ["svelte", "text"] "svelte" => TagSet::new(&[243, 255]), // ["image", "svg", "text", "xml"] "svg" => TagSet::new(&[117, 244, 255, 297]), // ["system-verilog", "text"] "svh" => TagSet::new(&[249, 255]), // ["binary", "swf"] "swf" => TagSet::new(&[21, 245]), // ["swift", "text"] "swift" => TagSet::new(&[246, 255]), // ["swiftdeps", "text"] "swiftdeps" => TagSet::new(&[247, 255]), // ["python", "text", "twisted"] "tac" => TagSet::new(&[213, 255, 266]), // ["binary", "tar"] "tar" => TagSet::new(&[21, 250]), // ["msbuild", "text", "xml"] "targets" => TagSet::new(&[168, 255, 297]), // ["templ", "text"] "templ" => TagSet::new(&[252, 255]), // ["tex", "text"] "tex" => TagSet::new(&[254, 255]), // ["text", "textproto"] "textproto" => TagSet::new(&[255, 256]), // ["terraform", "text"] "tf" => TagSet::new(&[253, 255]), // ["terraform", "text"] "tfvars" => TagSet::new(&[253, 255]), // ["binary", "gzip"] "tgz" => TagSet::new(&[21, 104]), // ["text", "thrift"] "thrift" => TagSet::new(&[255, 257]), // ["binary", "image", "tiff"] "tiff" => TagSet::new(&[21, 117, 258]), // ["text", "toml"] "toml" => TagSet::new(&[255, 260]), // ["c++", "text"] "tpp" => TagSet::new(&[31, 255]), // ["text", "ts"] "ts" => TagSet::new(&[255, 261]), // ["text", "tsv"] "tsv" => TagSet::new(&[255, 262]), // ["text", "tsx"] "tsx" => TagSet::new(&[255, 263]), // ["binary", "ttf"] "ttf" => TagSet::new(&[21, 264]), // ["text", "twig"] "twig" => TagSet::new(&[255, 265]), // ["ini", "text", "txsprofile"] "txsprofile" => TagSet::new(&[119, 255, 267]), // ["plain-text", "text"] "txt" => TagSet::new(&[194, 255]), // ["text", "textproto"] "txtpb" => TagSet::new(&[255, 256]), // ["text", "urdf", "xml"] "urdf" => TagSet::new(&[255, 268, 297]), // ["text", "verilog"] "v" => TagSet::new(&[255, 273]), // ["text", "vb"] "vb" => TagSet::new(&[255, 269]), // ["msbuild", "text", "vbproj", "xml"] "vbproj" => TagSet::new(&[168, 255, 270, 297]), // ["msbuild", "text", "vcxproj", "xml"] "vcxproj" => TagSet::new(&[168, 255, 271, 297]), // ["text", "vdx"] "vdx" => TagSet::new(&[255, 272]), // ["text", "verilog"] "vh" => TagSet::new(&[255, 273]), // ["text", "vhdl"] "vhd" => TagSet::new(&[255, 274]), // ["text", "vim"] "vim" => TagSet::new(&[255, 275]), // ["text", "vtl"] "vtl" => TagSet::new(&[255, 276]), // ["text", "vue"] "vue" => TagSet::new(&[255, 277]), // ["binary", "jar", "zip"] "war" => TagSet::new(&[21, 126, 308]), // ["audio", "binary", "wav"] "wav" => TagSet::new(&[9, 21, 278]), // ["binary", "image", "webp"] "webp" => TagSet::new(&[21, 117, 279]), // ["binary", "wheel", "zip"] "whl" => TagSet::new(&[21, 280, 308]), // ["text", "wkt"] "wkt" => TagSet::new(&[255, 281]), // ["binary", "woff"] "woff" => TagSet::new(&[21, 282]), // ["binary", "woff2"] "woff2" => TagSet::new(&[21, 283]), // ["text", "wsdl", "xml"] "wsdl" => TagSet::new(&[255, 284, 297]), // ["python", "text", "wsgi"] "wsgi" => TagSet::new(&[213, 255, 285]), // ["text", "urdf", "xacro", "xml"] "xacro" => TagSet::new(&[255, 268, 286, 297]), // ["text", "xaml", "xml"] "xaml" => TagSet::new(&[255, 287, 297]), // ["text", "xcconfig", "xcodebuild"] "xcconfig" => TagSet::new(&[255, 288, 289]), // ["plist", "xcodebuild", "xcprivacy"] "xcprivacy" => TagSet::new(&[196, 289, 290]), // ["text", "xcodebuild", "xcscheme", "xml"] "xcscheme" => TagSet::new(&[255, 289, 291, 297]), // ["plist", "xcodebuild", "xcsettings"] "xcsettings" => TagSet::new(&[196, 289, 292]), // ["json", "text", "xcodebuild", "xctestplan"] "xctestplan" => TagSet::new(&[135, 255, 289, 293]), // ["text", "xcodebuild", "xcworkspacedata", "xml"] "xcworkspacedata" => TagSet::new(&[255, 289, 294, 297]), // ["html", "text", "xhtml", "xml"] "xhtml" => TagSet::new(&[111, 255, 295, 297]), // ["text", "xliff", "xml"] "xlf" => TagSet::new(&[255, 296, 297]), // ["text", "xliff", "xml"] "xliff" => TagSet::new(&[255, 296, 297]), // ["text", "xml"] "xml" => TagSet::new(&[255, 297]), // ["text", "xquery"] "xq" => TagSet::new(&[255, 298]), // ["text", "xquery"] "xql" => TagSet::new(&[255, 298]), // ["text", "xquery"] "xqm" => TagSet::new(&[255, 298]), // ["text", "xquery"] "xqu" => TagSet::new(&[255, 298]), // ["text", "xquery"] "xquery" => TagSet::new(&[255, 298]), // ["text", "xquery"] "xqy" => TagSet::new(&[255, 298]), // ["text", "xml", "xsd"] "xsd" => TagSet::new(&[255, 297, 299]), // ["text", "xml", "xsl"] "xsl" => TagSet::new(&[255, 297, 300]), // ["text", "xml", "xsl"] "xslt" => TagSet::new(&[255, 297, 300]), // ["text", "yaml"] "yaml" => TagSet::new(&[255, 301]), // ["text", "yaml", "yamlld"] "yamlld" => TagSet::new(&[255, 301, 302]), // ["text", "yang"] "yang" => TagSet::new(&[255, 304]), // ["text", "xml", "yin"] "yin" => TagSet::new(&[255, 297, 305]), // ["text", "yaml"] "yml" => TagSet::new(&[255, 301]), // ["text", "xml", "zcml"] "zcml" => TagSet::new(&[255, 297, 306]), // ["text", "zig"] "zig" => TagSet::new(&[255, 307]), // ["binary", "zip"] "zip" => TagSet::new(&[21, 308]), // ["text", "zpt"] "zpt" => TagSet::new(&[255, 309]), // ["shell", "text", "zsh"] "zsh" => TagSet::new(&[235, 255, 310]), }; pub const NAMES: phf::Map<&str, TagSet> = phf::phf_map! { // ["text", "yaml"] ".ansible-lint" => TagSet::new(&[255, 301]), // ["babelrc", "json", "text"] ".babelrc" => TagSet::new(&[13, 135, 255]), // ["bash", "shell", "text"] ".bash_aliases" => TagSet::new(&[14, 235, 255]), // ["bash", "shell", "text"] ".bash_profile" => TagSet::new(&[14, 235, 255]), // ["bash", "shell", "text"] ".bashrc" => TagSet::new(&[14, 235, 255]), // ["bazelrc", "text"] ".bazelrc" => TagSet::new(&[18, 255]), // ["bowerrc", "json", "text"] ".bowerrc" => TagSet::new(&[24, 135, 255]), // ["browserslistrc", "text"] ".browserslistrc" => TagSet::new(&[25, 255]), // ["text", "yaml"] ".clang-format" => TagSet::new(&[255, 301]), // ["text", "yaml"] ".clang-tidy" => TagSet::new(&[255, 301]), // ["codespellrc", "ini", "text"] ".codespellrc" => TagSet::new(&[39, 119, 255]), // ["coveragerc", "ini", "text"] ".coveragerc" => TagSet::new(&[41, 119, 255]), // ["csh", "shell", "text"] ".cshrc" => TagSet::new(&[43, 235, 255]), // ["csslintrc", "json", "text"] ".csslintrc" => TagSet::new(&[47, 135, 255]), // ["dockerignore", "text"] ".dockerignore" => TagSet::new(&[60, 255]), // ["editorconfig", "text"] ".editorconfig" => TagSet::new(&[63, 255]), // ["bash", "shell", "text"] ".envrc" => TagSet::new(&[14, 235, 255]), // ["flake8", "ini", "text"] ".flake8" => TagSet::new(&[81, 119, 255]), // ["gitattributes", "text"] ".gitattributes" => TagSet::new(&[89, 255]), // ["gitconfig", "ini", "text"] ".gitconfig" => TagSet::new(&[90, 119, 255]), // ["gitignore", "text"] ".gitignore" => TagSet::new(&[91, 255]), // ["gitlint", "ini", "text"] ".gitlint" => TagSet::new(&[92, 119, 255]), // ["gitmodules", "text"] ".gitmodules" => TagSet::new(&[93, 255]), // ["hgrc", "ini", "text"] ".hgrc" => TagSet::new(&[109, 119, 255]), // ["ini", "isort", "text"] ".isort.cfg" => TagSet::new(&[119, 124, 255]), // ["jshintrc", "json", "text"] ".jshintrc" => TagSet::new(&[134, 135, 255]), // ["mailmap", "text"] ".mailmap" => TagSet::new(&[156, 255]), // ["json", "mention-bot", "text"] ".mention-bot" => TagSet::new(&[135, 162, 255]), // ["npmignore", "text"] ".npmignore" => TagSet::new(&[177, 255]), // ["pdbrc", "python", "text"] ".pdbrc" => TagSet::new(&[185, 213, 255]), // ["gitignore", "prettierignore", "text"] ".prettierignore" => TagSet::new(&[91, 202, 255]), // ["ini", "pypirc", "text"] ".pypirc" => TagSet::new(&[119, 210, 255]), // ["ini", "text"] ".rstcheck.cfg" => TagSet::new(&[119, 255]), // ["salt-lint", "text", "yaml"] ".salt-lint" => TagSet::new(&[226, 255, 301]), // ["ini", "text"] ".sqlfluff" => TagSet::new(&[119, 255]), // ["text", "yaml", "yamllint"] ".yamllint" => TagSet::new(&[255, 301, 303]), // ["shell", "text", "zsh"] ".zlogin" => TagSet::new(&[235, 255, 310]), // ["shell", "text", "zsh"] ".zlogout" => TagSet::new(&[235, 255, 310]), // ["shell", "text", "zsh"] ".zprofile" => TagSet::new(&[235, 255, 310]), // ["shell", "text", "zsh"] ".zshenv" => TagSet::new(&[235, 255, 310]), // ["shell", "text", "zsh"] ".zshrc" => TagSet::new(&[235, 255, 310]), // ["plain-text", "text"] "AUTHORS" => TagSet::new(&[194, 255]), // ["bazel", "text"] "BUILD" => TagSet::new(&[17, 255]), // ["ruby", "text"] "Brewfile" => TagSet::new(&[223, 255]), // ["plain-text", "text"] "CHANGELOG" => TagSet::new(&[194, 255]), // ["cmake", "text"] "CMakeLists.txt" => TagSet::new(&[38, 255]), // ["plain-text", "text"] "CONTRIBUTING" => TagSet::new(&[194, 255]), // ["plain-text", "text"] "COPYING" => TagSet::new(&[194, 255]), // ["cargo-lock", "text", "toml"] "Cargo.lock" => TagSet::new(&[34, 255, 260]), // ["cargo", "text", "toml"] "Cargo.toml" => TagSet::new(&[33, 255, 260]), // ["dockerfile", "text"] "Containerfile" => TagSet::new(&[59, 255]), // ["dockerfile", "text"] "Dockerfile" => TagSet::new(&[59, 255]), // ["ruby", "text"] "Fastfile" => TagSet::new(&[223, 255]), // ["makefile", "text"] "GNUmakefile" => TagSet::new(&[157, 255]), // ["ruby", "text"] "Gemfile" => TagSet::new(&[223, 255]), // ["text"] "Gemfile.lock" => TagSet::new(&[255]), // ["groovy", "jenkins", "text"] "Jenkinsfile" => TagSet::new(&[101, 131, 255]), // ["plain-text", "text"] "LICENSE" => TagSet::new(&[194, 255]), // ["plain-text", "text"] "MAINTAINERS" => TagSet::new(&[194, 255]), // ["makefile", "text"] "Makefile" => TagSet::new(&[157, 255]), // ["plain-text", "text"] "NEWS" => TagSet::new(&[194, 255]), // ["plain-text", "text"] "NOTICE" => TagSet::new(&[194, 255]), // ["plain-text", "text"] "PATENTS" => TagSet::new(&[194, 255]), // ["alpm", "bash", "pkgbuild", "shell", "text"] "PKGBUILD" => TagSet::new(&[1, 14, 193, 235, 255]), // ["text", "toml"] "Pipfile" => TagSet::new(&[255, 260]), // ["json", "text"] "Pipfile.lock" => TagSet::new(&[135, 255]), // ["plain-text", "text"] "README" => TagSet::new(&[194, 255]), // ["ruby", "text"] "Rakefile" => TagSet::new(&[223, 255]), // ["scons", "text"] "SConscript" => TagSet::new(&[232, 255]), // ["scons", "text"] "SConstruct" => TagSet::new(&[232, 255]), // ["scons", "text"] "SCsub" => TagSet::new(&[232, 255]), // ["text", "tiltfile"] "Tiltfile" => TagSet::new(&[255, 259]), // ["ruby", "text"] "Vagrantfile" => TagSet::new(&[223, 255]), // ["bazel", "text"] "WORKSPACE" => TagSet::new(&[17, 255]), // ["bitbake", "text"] "bblayers.conf" => TagSet::new(&[22, 255]), // ["bitbake", "text"] "bitbake.conf" => TagSet::new(&[22, 255]), // ["ruby", "text"] "config.ru" => TagSet::new(&[223, 255]), // ["bazel", "text"] "copy.bara.sky" => TagSet::new(&[17, 255]), // ["bash", "shell", "text"] "direnvrc" => TagSet::new(&[14, 235, 255]), // ["go-mod", "text"] "go.mod" => TagSet::new(&[96, 255]), // ["go-sum", "text"] "go.sum" => TagSet::new(&[97, 255]), // ["makefile", "text"] "makefile" => TagSet::new(&[157, 255]), // ["meson", "text"] "meson.build" => TagSet::new(&[163, 255]), // ["meson", "meson-options", "text"] "meson.options" => TagSet::new(&[163, 164, 255]), // ["meson", "meson-options", "text"] "meson_options.txt" => TagSet::new(&[163, 164, 255]), // ["text", "toml"] "poetry.lock" => TagSet::new(&[255, 260]), // ["pom", "text", "xml"] "pom.xml" => TagSet::new(&[199, 255, 297]), // ["ini", "pylintrc", "text"] "pylintrc" => TagSet::new(&[119, 209, 255]), // ["pyproject", "text", "toml"] "pyproject.toml" => TagSet::new(&[212, 255, 260]), // ["erlang", "text"] "rebar.config" => TagSet::new(&[73, 255]), // ["ini", "text"] "setup.cfg" => TagSet::new(&[119, 255]), // ["erlang", "text"] "sys.config" => TagSet::new(&[73, 255]), // ["erlang", "text"] "sys.config.src" => TagSet::new(&[73, 255]), // ["text", "toml"] "uv.lock" => TagSet::new(&[255, 260]), // ["python", "text"] "wscript" => TagSet::new(&[213, 255]), }; ================================================ FILE: crates/prek-pty/Cargo.toml ================================================ [package] name = "prek-pty" description = "pty utilities for prek" version = { workspace = true } edition = { workspace = true } rust-version = { workspace = true } repository = { workspace = true } license = { workspace = true } [dependencies] rustix = { workspace = true } tokio = { workspace = true } [lints] workspace = true ================================================ FILE: crates/prek-pty/LICENSE ================================================ This software is Copyright (c) 2021 by Jesse Luehrs. This is free software, licensed under: The MIT (X11) License The MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: crates/prek-pty/src/error.rs ================================================ /// Error type for errors from this crate #[derive(Debug)] pub enum Error { /// error came from `std::io::Error` Io(std::io::Error), /// error came from `nix::Error` Rustix(rustix::io::Errno), /// unsplit was called on halves of two different ptys Unsplit(crate::pty::OwnedReadPty, crate::pty::OwnedWritePty), } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(e) => write!(f, "{e}"), Self::Rustix(e) => write!(f, "{e}"), Self::Unsplit(..) => { write!(f, "unsplit called on halves of two different ptys") } } } } impl From for Error { fn from(e: std::io::Error) -> Self { Self::Io(e) } } impl From for Error { fn from(e: rustix::io::Errno) -> Self { Self::Rustix(e) } } impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(e) => Some(e), Self::Rustix(e) => Some(e), Self::Unsplit(..) => None, } } } /// Convenience wrapper for `Result`s using [`Error`](Error) pub type Result = std::result::Result; ================================================ FILE: crates/prek-pty/src/lib.rs ================================================ // Vendored crate from: https://crates.io/crates/pty-process #![cfg(unix)] mod error; #[allow(clippy::module_inception)] mod pty; mod sys; mod types; pub use error::{Error, Result}; pub use pty::{OwnedReadPty, OwnedWritePty, Pts, Pty, ReadPty, WritePty, open}; pub use types::Size; ================================================ FILE: crates/prek-pty/src/pty.rs ================================================ #![allow(clippy::module_name_repetitions)] use std::io::Write as _; type AsyncPty = tokio::io::unix::AsyncFd; /// Allocate and return a new pty and pts. /// /// # Errors /// Returns an error if the pty failed to be allocated, or if we were /// unable to put it into non-blocking mode. pub fn open() -> crate::Result<(Pty, Pts)> { let pty = crate::sys::Pty::open()?; let pts = pty.pts()?; pty.set_nonblocking()?; let pty = tokio::io::unix::AsyncFd::new(pty)?; Ok((Pty(pty), Pts(pts))) } /// An allocated pty pub struct Pty(AsyncPty); impl Pty { /// Use the provided file descriptor as a pty. /// /// # Safety /// The provided file descriptor must be valid, open, belong to a pty, /// and put into nonblocking mode. /// /// # Errors /// Returns an error if it fails to be registered with the async runtime. pub unsafe fn from_fd(fd: std::os::fd::OwnedFd) -> crate::Result { Ok(Self(tokio::io::unix::AsyncFd::new(unsafe { crate::sys::Pty::from_fd(fd) })?)) } /// Change the terminal size associated with the pty. /// /// # Errors /// Returns an error if we were unable to set the terminal size. pub fn resize(&self, size: crate::Size) -> crate::Result<()> { self.0.get_ref().set_term_size(size) } /// Splits a `Pty` into a read half and a write half, which can be used to /// read from and write to the pty concurrently. Does not allocate, but /// the returned halves cannot be moved to independent tasks. pub fn split(&self) -> (ReadPty<'_>, WritePty<'_>) { (ReadPty(&self.0), WritePty(&self.0)) } /// Splits a `Pty` into a read half and a write half, which can be used to /// read from and write to the pty concurrently. This method requires an /// allocation, but the returned halves can be moved to independent tasks. /// The original `Pty` instance can be recovered via the /// [`OwnedReadPty::unsplit`] method. #[must_use] pub fn into_split(self) -> (OwnedReadPty, OwnedWritePty) { let Self(pt) = self; let read_pt = std::sync::Arc::new(pt); let write_pt = std::sync::Arc::clone(&read_pt); (OwnedReadPty(read_pt), OwnedWritePty(write_pt)) } } impl From for std::os::fd::OwnedFd { fn from(pty: Pty) -> Self { pty.0.into_inner().into() } } impl std::os::fd::AsFd for Pty { fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { self.0.get_ref().as_fd() } } impl std::os::fd::AsRawFd for Pty { fn as_raw_fd(&self) -> std::os::fd::RawFd { self.0.get_ref().as_raw_fd() } } impl tokio::io::AsyncRead for Pty { fn poll_read( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut tokio::io::ReadBuf, ) -> std::task::Poll> { poll_read(&self.0, cx, buf) } } impl tokio::io::AsyncWrite for Pty { fn poll_write( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &[u8], ) -> std::task::Poll> { poll_write(&self.0, cx, buf) } fn poll_flush( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { poll_flush(&self.0, cx) } fn poll_shutdown( self: std::pin::Pin<&mut Self>, _cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { std::task::Poll::Ready(Ok(())) } } /// The child end of the pty /// /// See [`open`] and [`Command::spawn`](crate::Command::spawn) pub struct Pts(pub(crate) crate::sys::Pts); impl Pts { /// Use the provided file descriptor as a pts. /// /// # Safety /// The provided file descriptor must be valid, open, and belong to the /// child end of a pty. #[must_use] pub unsafe fn from_fd(fd: std::os::fd::OwnedFd) -> Self { Self(unsafe { crate::sys::Pts::from_fd(fd) }) } pub fn setup_subprocess( &self, ) -> std::io::Result<( std::process::Stdio, std::process::Stdio, std::process::Stdio, )> { self.0.setup_subprocess() } pub fn session_leader(&self) -> impl FnMut() -> std::io::Result<()> + use<> { self.0.session_leader() } } impl std::os::fd::AsFd for Pts { fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { self.0.as_fd() } } impl std::os::fd::AsRawFd for Pts { fn as_raw_fd(&self) -> std::os::fd::RawFd { self.0.as_raw_fd() } } /// Borrowed read half of a [`Pty`] pub struct ReadPty<'a>(&'a AsyncPty); impl tokio::io::AsyncRead for ReadPty<'_> { fn poll_read( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut tokio::io::ReadBuf, ) -> std::task::Poll> { poll_read(self.0, cx, buf) } } /// Borrowed write half of a [`Pty`] pub struct WritePty<'a>(&'a AsyncPty); impl WritePty<'_> { /// Change the terminal size associated with the pty. /// /// # Errors /// Returns an error if we were unable to set the terminal size. pub fn resize(&self, size: crate::Size) -> crate::Result<()> { self.0.get_ref().set_term_size(size) } } impl tokio::io::AsyncWrite for WritePty<'_> { fn poll_write( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &[u8], ) -> std::task::Poll> { poll_write(self.0, cx, buf) } fn poll_flush( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { poll_flush(self.0, cx) } fn poll_shutdown( self: std::pin::Pin<&mut Self>, _cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { std::task::Poll::Ready(Ok(())) } } /// Owned read half of a [`Pty`] #[derive(Debug)] pub struct OwnedReadPty(std::sync::Arc); impl OwnedReadPty { /// Attempt to join the two halves of a `Pty` back into a single instance. /// The two halves must have originated from calling /// [`into_split`](Pty::into_split) on a single instance. /// /// # Errors /// Returns an error if the two halves came from different [`Pty`] /// instances. The mismatched halves are returned as part of the error. pub fn unsplit(self, write_half: OwnedWritePty) -> crate::Result { let Self(read_pt) = self; let OwnedWritePty(write_pt) = write_half; if std::sync::Arc::ptr_eq(&read_pt, &write_pt) { drop(write_pt); Ok(Pty(std::sync::Arc::try_unwrap(read_pt) // it shouldn't be possible for more than two references to // the same pty to exist .unwrap_or_else(|_| unreachable!()))) } else { Err(crate::Error::Unsplit( Self(read_pt), OwnedWritePty(write_pt), )) } } } impl tokio::io::AsyncRead for OwnedReadPty { fn poll_read( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut tokio::io::ReadBuf, ) -> std::task::Poll> { poll_read(&self.0, cx, buf) } } /// Owned write half of a [`Pty`] #[derive(Debug)] pub struct OwnedWritePty(std::sync::Arc); impl OwnedWritePty { /// Change the terminal size associated with the pty. /// /// # Errors /// Returns an error if we were unable to set the terminal size. pub fn resize(&self, size: crate::Size) -> crate::Result<()> { self.0.get_ref().set_term_size(size) } } impl tokio::io::AsyncWrite for OwnedWritePty { fn poll_write( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &[u8], ) -> std::task::Poll> { poll_write(&self.0, cx, buf) } fn poll_flush( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { poll_flush(&self.0, cx) } fn poll_shutdown( self: std::pin::Pin<&mut Self>, _cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { std::task::Poll::Ready(Ok(())) } } fn poll_read( pty: &AsyncPty, cx: &mut std::task::Context<'_>, buf: &mut tokio::io::ReadBuf, ) -> std::task::Poll> { loop { let mut guard = match pty.poll_read_ready(cx) { std::task::Poll::Ready(guard) => guard, std::task::Poll::Pending => return std::task::Poll::Pending, }?; let prev_filled = buf.filled().len(); // SAFETY: we only pass b to read_buf, which never uninitializes any // part of the buffer it is given let b = unsafe { buf.unfilled_mut() }; match guard.try_io(|inner| inner.get_ref().read_buf(b)) { Ok(Ok((filled, _unfilled))) => { let bytes = filled.len(); // SAFETY: read_buf is given a buffer that starts at the end // of the filled section, and then both initializes and fills // some amount of the buffer after that (and never // deinitializes anything). we know that at least this many // bytes have been initialized (they either were filled and // initialized previously, or the call to read_buf did), and // assume_init will ignore any attempts to shrink the // initialized space, so this call is always safe. unsafe { buf.assume_init(prev_filled + bytes) }; buf.advance(bytes); return std::task::Poll::Ready(Ok(())); } Ok(Err(e)) => return std::task::Poll::Ready(Err(e)), Err(_would_block) => {} } } } fn poll_write( pty: &AsyncPty, cx: &mut std::task::Context<'_>, buf: &[u8], ) -> std::task::Poll> { loop { let mut guard = match pty.poll_write_ready(cx) { std::task::Poll::Ready(guard) => guard, std::task::Poll::Pending => return std::task::Poll::Pending, }?; match guard.try_io(|inner| inner.get_ref().write(buf)) { Ok(result) => return std::task::Poll::Ready(result), Err(_would_block) => {} } } } fn poll_flush( pty: &AsyncPty, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { loop { let mut guard = match pty.poll_write_ready(cx) { std::task::Poll::Ready(guard) => guard, std::task::Poll::Pending => return std::task::Poll::Pending, }?; match guard.try_io(|inner| inner.get_ref().flush()) { Ok(_) => return std::task::Poll::Ready(Ok(())), Err(_would_block) => {} } } } ================================================ FILE: crates/prek-pty/src/sys.rs ================================================ use std::os::{ fd::{AsRawFd as _, FromRawFd as _}, unix::prelude::{OpenOptionsExt as _, OsStrExt as _}, }; #[derive(Debug)] pub struct Pty(std::os::fd::OwnedFd); impl Pty { pub fn open() -> crate::Result { let pt = rustix::pty::openpt( // can't use CLOEXEC here because it's linux-specific rustix::pty::OpenptFlags::RDWR | rustix::pty::OpenptFlags::NOCTTY, )?; rustix::pty::grantpt(&pt)?; rustix::pty::unlockpt(&pt)?; let mut flags = rustix::io::fcntl_getfd(&pt)?; flags |= rustix::io::FdFlags::CLOEXEC; rustix::io::fcntl_setfd(&pt, flags)?; Ok(Self(pt)) } pub unsafe fn from_fd(fd: std::os::fd::OwnedFd) -> Self { Self(fd) } pub fn set_term_size(&self, size: crate::Size) -> crate::Result<()> { Ok(rustix::termios::tcsetwinsize( &self.0, rustix::termios::Winsize::from(size), )?) } pub fn pts(&self) -> crate::Result { Ok(Pts(std::fs::OpenOptions::new() .read(true) .write(true) .custom_flags(rustix::fs::OFlags::NOCTTY.bits().try_into().unwrap()) .open(std::ffi::OsStr::from_bytes( rustix::pty::ptsname(&self.0, vec![])?.as_bytes(), ))? .into())) } pub fn set_nonblocking(&self) -> rustix::io::Result<()> { let mut opts = rustix::fs::fcntl_getfl(&self.0)?; opts |= rustix::fs::OFlags::NONBLOCK; rustix::fs::fcntl_setfl(&self.0, opts)?; Ok(()) } pub fn read_buf<'a>( &self, buf: &'a mut [std::mem::MaybeUninit], ) -> std::io::Result<(&'a mut [u8], &'a mut [std::mem::MaybeUninit])> { rustix::io::read(&self.0, buf).map_err(std::io::Error::from) } } impl From for std::os::fd::OwnedFd { fn from(pty: Pty) -> Self { let Pty(nix_ptymaster) = pty; let raw_fd = nix_ptymaster.as_raw_fd(); std::mem::forget(nix_ptymaster); // Safety: nix::pty::PtyMaster is required to contain a valid file // descriptor, and we ensured that the file descriptor will remain // valid by skipping the drop implementation for nix::pty::PtyMaster unsafe { Self::from_raw_fd(raw_fd) } } } impl std::os::fd::AsFd for Pty { fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { let raw_fd = self.0.as_raw_fd(); // Safety: nix::pty::PtyMaster is required to contain a valid file // descriptor, and it is owned by self unsafe { std::os::fd::BorrowedFd::borrow_raw(raw_fd) } } } impl std::os::fd::AsRawFd for Pty { fn as_raw_fd(&self) -> std::os::fd::RawFd { self.0.as_raw_fd() } } impl std::io::Read for Pty { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { rustix::io::read(&self.0, buf).map_err(std::io::Error::from) } } impl std::io::Write for Pty { fn write(&mut self, buf: &[u8]) -> std::io::Result { rustix::io::write(&self.0, buf).map_err(std::io::Error::from) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } impl std::io::Read for &Pty { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { rustix::io::read(&self.0, buf).map_err(std::io::Error::from) } } impl std::io::Write for &Pty { fn write(&mut self, buf: &[u8]) -> std::io::Result { rustix::io::write(&self.0, buf).map_err(std::io::Error::from) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } pub struct Pts(std::os::fd::OwnedFd); impl Pts { pub unsafe fn from_fd(fd: std::os::fd::OwnedFd) -> Self { Self(fd) } pub fn setup_subprocess( &self, ) -> std::io::Result<( std::process::Stdio, std::process::Stdio, std::process::Stdio, )> { Ok(( self.0.try_clone()?.into(), self.0.try_clone()?.into(), self.0.try_clone()?.into(), )) } pub fn session_leader(&self) -> impl FnMut() -> std::io::Result<()> + use<> { let pts_fd = self.0.as_raw_fd(); move || { rustix::process::setsid()?; rustix::process::ioctl_tiocsctty(unsafe { std::os::fd::BorrowedFd::borrow_raw(pts_fd) })?; Ok(()) } } } impl From for std::os::fd::OwnedFd { fn from(pts: Pts) -> Self { pts.0 } } impl std::os::fd::AsFd for Pts { fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { self.0.as_fd() } } impl std::os::fd::AsRawFd for Pts { fn as_raw_fd(&self) -> std::os::fd::RawFd { self.0.as_raw_fd() } } ================================================ FILE: crates/prek-pty/src/types.rs ================================================ /// Represents the size of the pty. #[derive(Debug, Clone, Copy)] pub struct Size { row: u16, col: u16, xpixel: u16, ypixel: u16, } impl Size { /// Returns a [`Size`](Size) instance with the given number of rows and /// columns. #[must_use] pub fn new(row: u16, col: u16) -> Self { Self { row, col, xpixel: 0, ypixel: 0, } } /// Returns a [`Size`](Size) instance with the given number of rows and /// columns, as well as the given pixel dimensions. #[must_use] pub fn new_with_pixel(row: u16, col: u16, xpixel: u16, ypixel: u16) -> Self { Self { row, col, xpixel, ypixel, } } } impl From for rustix::termios::Winsize { fn from(size: Size) -> Self { Self { ws_row: size.row, ws_col: size.col, ws_xpixel: size.xpixel, ws_ypixel: size.ypixel, } } } ================================================ FILE: dist-workspace.toml ================================================ [workspace] members = ["cargo:."] # Config for 'dist' [dist] # The preferred dist version to use in CI (Cargo.toml SemVer syntax) cargo-dist-version = "0.31.0" # The archive format to use for non-windows builds (defaults .tar.xz) unix-archive = ".tar.gz" # CI backends to support ci = "github" # Whether CI should include auto-generated code to build local artifacts build-local-artifacts = false # Whether CI should trigger releases with dispatches instead of tag pushes dispatch-releases = true # Which actions to run on pull requests pr-run-mode = "skip" # Which phase dist should use to create the GitHub release github-release = "announce" # Whether to enable GitHub Attestations github-attestations = true # When to generate GitHub Attestations github-attestations-phase = "announce" # Whether to publish prereleases to package managers publish-prereleases = true # The installers to generate for each app installers = ["shell", "powershell", "npm", "homebrew"] # A namespace to use when publishing this package to the npm registry npm-scope = "@j178" # Whether to produce an npm lockfile npm-shrinkwrap = false # Target platforms to build apps for (Rust target-triple syntax) targets = [ "aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "aarch64-pc-windows-msvc", "arm-unknown-linux-musleabihf", "armv7-unknown-linux-gnueabihf", "armv7-unknown-linux-musleabihf", "x86_64-apple-darwin", "riscv64gc-unknown-linux-gnu", "s390x-unknown-linux-gnu", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc", "i686-unknown-linux-gnu", "i686-unknown-linux-musl", "i686-pc-windows-msvc", ] # Local artifacts jobs to run in CI local-artifacts-jobs = ["./build-binaries", "./build-docker"] # Publish jobs to run in CI publish-jobs = ["./publish-crates", "./publish-pypi", "./publish-npm"] # Post-announce jobs to run in CI post-announce-jobs = [ "./publish-docs", "./publish-homebrew", "./publish-prek-action", "./publish-winget", ] github-custom-job-permissions = { "publish-docs" = { contents = "read", pages = "write", id-token = "write" }, "build-docker" = { packages = "write", contents = "read", attestations = "write", id-token = "write" }, "publish-winget" = {}, "publish-crates" = { id-token = "write" }, "publish-pypi" = { id-token = "write" }, "publish-npm" = { id-token = "write" }, "publish-homebrew" = {} } # Whether to install an updater program install-updater = false # Path that installers should place binaries in install-path = ["$XDG_BIN_HOME/", "$XDG_DATA_HOME/../bin", "~/.local/bin"] [dist.github-custom-runners] global = "ubuntu-latest" [dist.github-action-commits] "actions/checkout" = "de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6.0.2 "actions/upload-artifact" = "bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" # v7.0.0 "actions/download-artifact" = "70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3" # v8.0.0 "actions/attest-build-provenance" = "a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32" # v4.1.0 ================================================ FILE: docs/assets/badge-v0.json ================================================ { "label": "prek", "message": "enabled", "logoSvg": "", "logoWidth": 10, "labelColor": "grey", "color": "#ff600a" } ================================================ FILE: docs/authoring-hooks.md ================================================ # Authoring Hooks This page is for hook authors who publish a repository consumed by end users. If you only need to configure hooks in your own project, see [Configuration](configuration.md). ## Manifest file: `.pre-commit-hooks.yaml` Hook repositories must include a `.pre-commit-hooks.yaml` file at the repo root. The manifest is a YAML list of hook definitions. Each hook entry must include: - `id`: stable identifier used in end-user configs - `name`: human-friendly label shown in output - `entry`: command to execute - `language`: execution environment (for example `python`, `node`, `system`) Hooks should exit non-zero on failure (or modify files and exit non-zero for fixers). Common optional fields include `args`, `files`, `exclude`, `types`, `types_or`, `stages`, `pass_filenames`, `description`, `additional_dependencies`, and `require_serial`. `prek` follows the upstream pre-commit manifest format. For the full field list and semantics, see: [https://pre-commit.com/#new-hooks](https://pre-commit.com/#new-hooks). Example: ```yaml - id: format-json name: format json entry: python3 -m tools.format_json language: python files: "\\.json$" - id: lint-shell name: shellcheck entry: shellcheck language: system types: [shell] ``` ## Choosing hook stages Hook authors can declare which Git hook stages they support with `stages` in `.pre-commit-hooks.yaml`. End users can override that list in their configuration. If neither is set, `prek` falls back to the top-level `default_stages` (which defaults to all stages). The `manual` stage is special: it never runs automatically and is only executed when a user explicitly runs `prek run --hook-stage manual `. Example: ```yaml - id: lint name: lint entry: my-lint language: python stages: [pre-commit, pre-merge-commit, pre-push, manual] ``` ## Passing arguments to hooks When users configure a hook with `args`, `prek` passes those arguments before the list of file paths. If `args` is empty or omitted, only file paths are provided. Example end-user config: ```yaml repos: - repo: https://github.com/example/hook-repo rev: v1.0.0 hooks: - id: my-hook args: [--max-line-length=120] ``` Invocation shape: ```text my-hook --max-line-length=120 path/to/file1 path/to/file2 ``` ## Versioning for `prek auto-update` End users pin your repository using the `rev` field in their config. To make [`prek auto-update`](cli.md#prek-auto-update) work as expected, publish git tags for releases: - Prefer semantic version tags like `v1.2.3` or `1.2.3`. - Push tags to the remote (annotated or lightweight tags both work). - Avoid moving tags; treat them as immutable release references. `prek auto-update` selects the newest tag by default. With `--bleeding-edge`, it uses the default branch tip instead of tags. With `--freeze`, it writes commit SHAs into `rev` instead of tag names. ## Develop locally with `prek try-repo` [`prek try-repo`](cli.md#prek-try-repo) runs hooks from a repository without publishing a release. This is handy while iterating on a hook. ```bash # In another repository where you want to test the hook prek try-repo ../path/to/hook-repo my-hook-id --verbose ``` Notes: - `prek try-repo` accepts any path or git URL `git clone` understands. - For `prepare-commit-msg` or `commit-msg` hooks, pass the appropriate `--commit-msg-filename` argument when testing. ## Validation and CI Validate your manifest locally with [`prek validate-manifest`](cli.md#prek-validate-manifest): ```bash prek validate-manifest .pre-commit-hooks.yaml ``` This ensures the manifest is well-formed before publishing a release tag. ================================================ FILE: docs/benchmark.md ================================================ # Benchmarks This page presents benchmarks comparing prek vs pre-commit. Caveats: - Benchmark performance may vary based on hardware, OS, network conditions, and other factors. - Benchmarks are not exhaustive; results may vary with different repositories and configurations. - prek is under active development; performance may improve over time. Environment: pre-commit version: 4.3.0 prek version: 0.2.0 OS: macOS 15.5 CPU: Apple M3 Pro RAM: 18GB ## Cold installation Here is a benchmark of installing hooks from [Apache Airflow](https://github.com/apache/airflow), which has a large and complex pre-commit configuration. Steps: ```console uv tool install prek@0.2.0 uv tool install pre-commit@4.3.0 git clone https://github.com/apache/airflow cd airflow git checkout 3.0.6 hyperfine \ --prepare 'prek clean && pre-commit clean && uv cache clean' \ --setup 'prek --version && pre-commit --version' \ --runs 1 \ 'prek prepare-hooks' \ 'pre-commit install-hooks' ``` Results: ``` Benchmark 1: prek prepare-hooks Time (abs ≡): 18.395 s [User: 11.234 s, System: 9.979 s] Benchmark 2: pre-commit install-hooks Time (abs ≡): 186.990 s [User: 68.774 s, System: 39.379 s] Summary prek prepare-hooks ran 10.17 times faster than pre-commit install-hooks ``` Disk usage after installation: ```console $ du -sh ~/.cache/prek ~/.cache/pre-commit 810M /Users/Jo/.cache/prek 1.6G /Users/Jo/.cache/pre-commit ``` ## Runtime benchmarks Since some hooks might be slow to run (e.g., `cargo clippy`), which can take minutes, making any other overhead negligible, we choose to only run `check-toml` hook in `cpython` codebase. ### With prek fast path Steps: ```console git clone https://github.com/python/cpython cd cpython git checkout v3.14.0rc2 hyperfine \ --warmup 3 \ --setup 'prek --version && pre-commit --version' \ --runs 5 \ 'prek run -a check-toml' \ 'pre-commit run -a check-toml' ``` Results: ```console Benchmark 1: prek run -a check-toml Time (mean ± σ): 77.1 ms ± 2.5 ms [User: 44.1 ms, System: 128.5 ms] Range (min … max): 75.1 ms … 81.3 ms 5 runs Benchmark 2: pre-commit run -a check-toml Time (mean ± σ): 351.6 ms ± 25.0 ms [User: 214.5 ms, System: 195.5 ms] Range (min … max): 332.8 ms … 393.2 ms 5 runs Summary prek run -a check-toml ran 4.56 ± 0.36 times faster than pre-commit run -a check-toml ``` ### Without prek fast path Steps: ```console hyperfine \ --warmup 3 \ --setup 'prek --version && pre-commit --version' \ --runs 5 \ 'PREK_NO_FAST_PATH=1 prek run -a check-toml' \ 'pre-commit run -a check-toml' ``` Results: ``` Benchmark 1: PREK_NO_FAST_PATH=1 prek run -a check-toml Time (mean ± σ): 137.3 ms ± 5.1 ms [User: 111.0 ms, System: 147.5 ms] Range (min … max): 131.9 ms … 144.0 ms 5 runs Benchmark 2: pre-commit run -a check-toml Time (mean ± σ): 397.6 ms ± 49.2 ms [User: 217.6 ms, System: 197.7 ms] Range (min … max): 332.6 ms … 440.7 ms 5 runs Summary PREK_NO_FAST_PATH=1 prek run -a check-toml ran 2.90 ± 0.37 times faster than pre-commit run -a check-toml ``` ## Benchmark from the community - [Ready Prek Go!](https://hugovk.dev/blog/2025/ready-prek-go/) from Hugo van Kemenade. ================================================ FILE: docs/builtin.md ================================================ # Built-in Fast Hooks prek includes fast, Rust-native implementations of popular hooks for speed and low overhead. These hooks are bundled directly into the `prek` binary, eliminating the need for external interpreters like Python for these specific checks. Built-in hooks come into play in two ways: 1. **Automatic Fast Path**: Automatically replacing execution for known remote repositories. 2. **Explicit Builtin Repository**: Using `repo: builtin` for offline, zero-setup hooks. ## 1. Automatic Fast Path When you use a standard configuration pointing to a supported repository (like `https://github.com/pre-commit/pre-commit-hooks`), `prek` automatically detects this and runs its internal Rust implementation instead of the Python version defined in the repository. The fast path is activated when the `repo` URL matches `https://github.com/pre-commit/pre-commit-hooks`. No need to change anything in your configuration. Note that the `rev` field is ignored for detection purposes. This provides a speed boost while keeping your configuration compatible with the original `pre-commit` tool. ```yaml repos: - repo: https://github.com/pre-commit/pre-commit-hooks # Enables fast path rev: v4.5.0 # This is ignored for fast path detection hooks: - id: trailing-whitespace ``` !!! note In this mode, `prek` will still clone the repository and create the environment (e.g., a Python venv) to ensure full compatibility and fallback capabilities. However, the actual hook execution bypasses the environment and runs the native Rust code. ### Supported Hooks Currently, only part of hooks from `https://github.com/pre-commit/pre-commit-hooks` is supported. More popular repositories may be added over time. ### - [`trailing-whitespace`](https://github.com/pre-commit/pre-commit-hooks#trailing-whitespace) (Trim trailing whitespace) - [`check-added-large-files`](https://github.com/pre-commit/pre-commit-hooks#check-added-large-files) (Prevent committing large files) - [`check-case-conflict`](https://github.com/pre-commit/pre-commit-hooks#check-case-conflict) (Check for files that would conflict in case-insensitive filesystems) - [`end-of-file-fixer`](https://github.com/pre-commit/pre-commit-hooks#end-of-file-fixer) (Ensure newline at EOF) - [`fix-byte-order-marker`](https://github.com/pre-commit/pre-commit-hooks#fix-byte-order-marker) (Remove UTF-8 byte order marker) - [`check-json`](https://github.com/pre-commit/pre-commit-hooks#check-json) (Validate JSON files) - [`check-toml`](https://github.com/pre-commit/pre-commit-hooks#check-toml) (Validate TOML files) - [`check-yaml`](https://github.com/pre-commit/pre-commit-hooks#check-yaml) (Validate YAML files) - [`check-xml`](https://github.com/pre-commit/pre-commit-hooks#check-xml) (Validate XML files) - [`mixed-line-ending`](https://github.com/pre-commit/pre-commit-hooks#mixed-line-ending) (Normalize or check line endings) - [`check-symlinks`](https://github.com/pre-commit/pre-commit-hooks#check-symlinks) (Check for broken symlinks) - [`check-merge-conflict`](https://github.com/pre-commit/pre-commit-hooks#check-merge-conflict) (Check for merge conflicts) - [`detect-private-key`](https://github.com/pre-commit/pre-commit-hooks#detect-private-key) (Detect private keys) - [`no-commit-to-branch`](https://github.com/pre-commit/pre-commit-hooks#no-commit-to-branch) (Prevent committing to protected branches) - [`check-executables-have-shebangs`](https://github.com/pre-commit/pre-commit-hooks#check-executables-have-shebangs) (Ensures that (non-binary) executables have a shebang) #### Notes - `check-yaml` fast path does not yet support the `--unsafe` flag; for those cases, the automatic fast path is skipped. - Other hooks from the repository which have no fast path implementation will run via the standard method. ### Disabling the fast path If you need to compare with the original behavior or encounter differences: ```bash PREK_NO_FAST_PATH=1 prek run ``` This forces prek to fall back to the standard execution path. ## 2. Explicit Builtin Repository You can explicitly tell `prek` to use its internal hooks by setting `repo: builtin`. This mode has significant benefits: - **No network required**: Does not clone any repository. - **No environment setup**: Does not create Python environments or install dependencies. - **Maximum speed**: Instant startup and execution. **Note**: Configurations using `repo: builtin` are **not compatible** with the standard `pre-commit` tool. ```yaml repos: - repo: builtin hooks: - id: trailing-whitespace - id: check-added-large-files ``` ### Supported Hooks For `repo: builtin`, the following hooks are supported: - [`trailing-whitespace`](#trailing-whitespace) (Trim trailing whitespace) - [`check-added-large-files`](#check-added-large-files) (Prevent committing large files) - [`check-case-conflict`](#check-case-conflict) (Check for files that would conflict in case-insensitive filesystems) - [`end-of-file-fixer`](#end-of-file-fixer) (Ensure newline at EOF) - [`fix-byte-order-marker`](#fix-byte-order-marker) (Remove UTF-8 byte order marker) - [`check-json`](#check-json) (Validate JSON files) - [`check-json5`](#check-json5) (Validate JSON5 files) - [`check-toml`](#check-toml) (Validate TOML files) - [`check-yaml`](#check-yaml) (Validate YAML files) - [`check-xml`](#check-xml) (Validate XML files) - [`mixed-line-ending`](#mixed-line-ending) (Normalize or check line endings) - [`check-symlinks`](#check-symlinks) (Check for broken symlinks) - [`check-merge-conflict`](#check-merge-conflict) (Check for merge conflicts) - [`detect-private-key`](#detect-private-key) (Detect private keys) - [`no-commit-to-branch`](#no-commit-to-branch) (Prevent committing to protected branches) - [`check-executables-have-shebangs`](#check-executables-have-shebangs) (Ensures that (non-binary) executables have a shebang) ### Hook Reference This section documents the built-in (Rust) implementations used by `repo: builtin`. #### Configuration notes - Configure arguments via `args: [...]` just like `pre-commit`. - For `repo: builtin`, `entry` is not allowed and `language` must be `system` (it is fine to omit `language`). - Some hooks are **fixers** (they modify files). Like `pre-commit-hooks`, they typically exit non-zero after making changes so you can re-run the commit. Example: ```yaml repos: - repo: builtin hooks: - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - id: check-added-large-files args: [--maxkb=1024] ``` --- #### `trailing-whitespace` Trims trailing whitespace from each line. **Supported arguments** (compatible with `pre-commit-hooks`): - `--markdown-linebreak-ext=` (repeatable / comma-separated) - Preserves Markdown hard line breaks (two trailing spaces) for files with the given extension(s). - Use `--markdown-linebreak-ext=*` to treat **all** files as Markdown. - `--chars=` - Trim only the specified set of characters instead of “all trailing whitespace”. - Example: `args: [--chars, " \t"]` (space + tab). **Caveats** - `--markdown-linebreak-ext` values must be extensions only (no path separators). --- #### `check-added-large-files` Prevents giant files from being committed. **Supported arguments** (compatible with `pre-commit-hooks`): - `--maxkb=` (default: `500`) - Maximum allowed file size, in kibibytes. - `--enforce-all` - Check all matched files, not just those staged for addition. **Caveats** - By default, only files staged for **addition** are checked. - Files configured with `filter=lfs` (via git attributes) are skipped. --- #### `check-case-conflict` Checks for paths that would conflict on a case-insensitive filesystem (for example macOS / Windows). **Supported arguments** - None. **Caveats** - The check includes parent directories as well as file paths, to catch directory-level case conflicts. --- #### `end-of-file-fixer` Ensures files end in a newline and only a newline. **Supported arguments** - None. **Behavior / caveats** - Empty files are left unchanged. - Files containing only newlines are truncated to empty. - If a file has no trailing newline, a single `\n` is appended (even if the file otherwise uses CRLF). - If a file has trailing newlines, they are reduced to exactly one trailing line ending. --- #### `fix-byte-order-marker` Removes a UTF-8 byte order marker (BOM) from the beginning of a file. **Supported arguments** - None. **Caveats** - Only removes the UTF-8 BOM (`EF BB BF`). --- #### `check-json` Attempts to load all JSON files to verify syntax. **Supported arguments** - None. **Caveats / differences** - This implementation rejects **duplicate object keys** (errors with `duplicate key ...`). - The parser disables the default recursion limit and uses a stack-friendly drop strategy for deeply nested JSON. --- #### `check-json5` Attempts to load all JSON5 files to verify syntax. **Supported arguments** - None. **Caveats / differences** - This implementation rejects **duplicate object keys** (errors with `duplicate key ...`). --- #### `check-toml` Attempts to load all TOML files to verify syntax. **Supported arguments** - None. **Caveats** - Files must be valid UTF-8; invalid UTF-8 is reported as an error. - May report multiple parse errors for a single file. --- #### `check-yaml` Attempts to load all YAML files to verify syntax. **Supported arguments** (partially compatible with `pre-commit-hooks`): - `-m`, `--allow-multiple-documents` (alias: `--multi`) - Allow YAML multi-document syntax (`---`). **Caveats / differences** - `--unsafe` is not supported. - With `repo: builtin`, passing `--unsafe` is treated as an unknown argument. --- #### `check-xml` Attempts to load all XML files to verify syntax. **Supported arguments** - None. **Caveats** - Empty files are treated as invalid XML. - Fails if there is “junk after the document element” (multiple top-level roots). --- #### `mixed-line-ending` Replaces or checks mixed line endings. **Supported arguments** (compatible with `pre-commit-hooks`, plus one extra mode): - `--fix=` (default: `auto`) - `auto`: replace with the most frequent line ending in the file. - `no`: check only (do not modify files). - `lf`: convert to LF (`\n`). - `crlf`: convert to CRLF (`\r\n`). - `cr`: convert to CR (`\r`) (extra mode in `prek`). **Caveats** - Empty and binary files (containing NUL) are skipped. - Upstream note: forcing `lf` / `crlf` may not behave as expected with git CRLF conversion settings (for example `core.autocrlf`). --- #### `check-symlinks` Checks for symlinks which do not point to anything. **Supported arguments** - None. **Caveats** - Relies on filesystem symlink support. On Windows, symlink creation and detection can be permission-dependent. --- #### `check-merge-conflict` Checks for merge conflict strings. **Supported arguments** (compatible with `pre-commit-hooks`): - `--assume-in-merge` - Allow running the hook even when there is no merge/rebase state detected. **Caveats** - By default, this hook exits successfully when not in a merge/rebase state. - Detects common conflict markers only when they appear at the start of a line. --- #### `detect-private-key` Detects the presence of private keys. **Supported arguments** - None. **Caveats** - This is a heuristic substring scan for common PEM/key headers (e.g. `BEGIN RSA PRIVATE KEY`, `BEGIN OPENSSH PRIVATE KEY`, `BEGIN PGP PRIVATE KEY BLOCK`, etc.). It can produce false positives/negatives. --- #### `no-commit-to-branch` Protects specific branches from direct commits. **Supported arguments** (compatible with `pre-commit-hooks`): - `-b`, `--branch ` (repeatable, default: `main`, `master`) - `-p`, `--pattern ` (repeatable) **Caveats** - This hook is configured as `always_run: true` by default, and does not take filenames. As a result, `files`, `exclude`, `types`, etc. are ignored unless you explicitly set `always_run: false`. - If HEAD is detached (no current branch), the hook does nothing. --- #### `check-executables-have-shebangs` Checks that non-binary executables have a proper shebang. **Supported arguments** - None. **Caveats** - The check is intentionally lightweight: it only verifies that the file starts with `#!`. - On systems where the executable bit is not tracked by the filesystem, `prek` consults git’s staged mode bits. ================================================ FILE: docs/changelog.md ================================================ --8<-- "CHANGELOG.md" ================================================ FILE: docs/cli.md ================================================ # CLI Reference ## prek Better pre-commit, re-engineered in Rust

Usage

``` prek [OPTIONS] [HOOK|PROJECT]... [COMMAND] ```

Commands

prek install

Install prek Git shims under the .git/hooks/ directory

prek prepare-hooks

Prepare environments for all hooks used in the config file

prek run

Run hooks

prek list

List hooks configured in the current workspace

prek uninstall

Uninstall prek Git shims

prek validate-config

Validate configuration files (prek.toml or .pre-commit-config.yaml)

prek validate-manifest

Validate .pre-commit-hooks.yaml files

prek sample-config

Produce a sample configuration file (prek.toml or .pre-commit-config.yaml)

prek auto-update

Auto-update the rev field of repositories in the config file to the latest version

prek cache

Manage the prek cache

prek try-repo

Try the pre-commit hooks in the current repo

prek util

Utility commands

prek self

prek self management

## prek install Install prek Git shims under the `.git/hooks/` directory. The Git shims installed by this command are determined by `--hook-type` or `default_install_hook_types` in the config file, falling back to `pre-commit` when neither is set. A hook's `stages` field does not affect which Git shims this command installs.

Usage

``` prek install [OPTIONS] [HOOK|PROJECT]... ```

Arguments

HOOK|PROJECT

Include the specified hooks or projects.

Supports flexible selector syntax:

  • hook-id: Run all hooks with the specified ID across all projects

  • project-path/: Run all hooks from the specified project

  • project-path:hook-id: Run only the specified hook from the specified project

Can be specified multiple times to select multiple hooks/projects.

Options

--allow-missing-config

Allow a missing configuration file

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--git-dir git-dir

Install Git shims into the hooks subdirectory of the given git directory (<GIT_DIR>/hooks/).

When this flag is used, prek install bypasses the safety check that normally refuses to install shims while core.hooksPath is set. Git itself will still ignore .git/hooks while core.hooksPath is configured, so ensure your Git configuration points to the directory where the shim is installed if you want it to be executed.

--help, -h

Display the concise help for this command

--hook-type, -t hook-type

Which Git shim(s) to install.

Specifies which Git hook type(s) you want to install shims for. Can be specified multiple times to install shims for multiple hook types.

If not specified, uses default_install_hook_types from the config file, or defaults to pre-commit if that is also not set.

Note: This is different from a hook's stages parameter in the config file, which declares which stages a hook can run in.

Possible values:

  • commit-msg
  • post-checkout
  • post-commit
  • post-merge
  • post-rewrite
  • pre-commit
  • pre-merge-commit
  • pre-push
  • pre-rebase
  • prepare-commit-msg
--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--overwrite, -f

Overwrite existing Git shims

--prepare-hooks, --install-hooks

Also prepare environments for all hooks used in the config file

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--skip hook|project

Skip the specified hooks or projects.

Supports flexible selector syntax:

  • hook-id: Skip all hooks with the specified ID across all projects

  • project-path/: Skip all hooks from the specified project

  • project-path:hook-id: Skip only the specified hook from the specified project

Can be specified multiple times. Also accepts PREK_SKIP or SKIP environment variables (comma-delimited).

--verbose, -v

Use verbose output

--version, -V

Display the prek version

## prek prepare-hooks Prepare environments for all hooks used in the config file. This command does not install Git shims. To install the Git shims along with the hook environments in one command, use `prek install --prepare-hooks`.

Usage

``` prek prepare-hooks [OPTIONS] [HOOK|PROJECT]... ```

Arguments

HOOK|PROJECT

Include the specified hooks or projects.

Supports flexible selector syntax:

  • hook-id: Run all hooks with the specified ID across all projects

  • project-path/: Run all hooks from the specified project

  • project-path:hook-id: Run only the specified hook from the specified project

Can be specified multiple times to select multiple hooks/projects.

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--help, -h

Display the concise help for this command

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--skip hook|project

Skip the specified hooks or projects.

Supports flexible selector syntax:

  • hook-id: Skip all hooks with the specified ID across all projects

  • project-path/: Skip all hooks from the specified project

  • project-path:hook-id: Skip only the specified hook from the specified project

Can be specified multiple times. Also accepts PREK_SKIP or SKIP environment variables (comma-delimited).

--verbose, -v

Use verbose output

--version, -V

Display the prek version

## prek run Run hooks

Usage

``` prek run [OPTIONS] [HOOK|PROJECT]... ```

Arguments

HOOK|PROJECT

Include the specified hooks or projects.

Supports flexible selector syntax:

  • hook-id: Run all hooks with the specified ID across all projects

  • project-path/: Run all hooks from the specified project

  • project-path:hook-id: Run only the specified hook from the specified project

Can be specified multiple times to select multiple hooks/projects.

Options

--all-files, -a

Run on all files in the repo

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--directory, -d dir

Run hooks on all files in the specified directories.

You can specify multiple directories. It can be used in conjunction with --files.

--dry-run

Do not run the hooks, but print the hooks that would have been run

--fail-fast

Stop running hooks after the first failure

--files files

Specific filenames to run hooks on

--from-ref, --source, -s from-ref

The original ref in a <from_ref>...<to_ref> diff expression. Files changed in this diff will be run through the hooks

--help, -h

Display the concise help for this command

--last-commit

Run hooks against the last commit. Equivalent to --from-ref HEAD~1 --to-ref HEAD

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--show-diff-on-failure

When hooks fail, run git diff directly afterward

--skip hook|project

Skip the specified hooks or projects.

Supports flexible selector syntax:

  • hook-id: Skip all hooks with the specified ID across all projects

  • project-path/: Skip all hooks from the specified project

  • project-path:hook-id: Skip only the specified hook from the specified project

Can be specified multiple times. Also accepts PREK_SKIP or SKIP environment variables (comma-delimited).

--stage, --hook-stage stage

The stage during which the hook is fired.

When specified, only hooks configured for that stage (for example manual, pre-commit, or pre-push) will run. Defaults to pre-commit if not specified. For hooks specified directly in the command line, fallback to manual stage if no hooks found for pre-commit stage.

Possible values:

  • manual
  • commit-msg
  • post-checkout
  • post-commit
  • post-merge
  • post-rewrite
  • pre-commit
  • pre-merge-commit
  • pre-push
  • pre-rebase
  • prepare-commit-msg
--to-ref, --origin, -o to-ref

The destination ref in a from_ref...to_ref diff expression. Defaults to HEAD if from_ref is specified

--verbose, -v

Use verbose output

--version, -V

Display the prek version

## prek list List hooks configured in the current workspace

Usage

``` prek list [OPTIONS] [HOOK|PROJECT]... ```

Arguments

HOOK|PROJECT

Include the specified hooks or projects.

Supports flexible selector syntax:

  • hook-id: Run all hooks with the specified ID across all projects

  • project-path/: Run all hooks from the specified project

  • project-path:hook-id: Run only the specified hook from the specified project

Can be specified multiple times to select multiple hooks/projects.

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--help, -h

Display the concise help for this command

--hook-stage hook-stage

Show only hooks that has the specified stage

Possible values:

  • manual
  • commit-msg
  • post-checkout
  • post-commit
  • post-merge
  • post-rewrite
  • pre-commit
  • pre-merge-commit
  • pre-push
  • pre-rebase
  • prepare-commit-msg
--language language

Show only hooks that are implemented in the specified language

Possible values:

  • bun
  • conda
  • coursier
  • dart
  • deno
  • docker
  • docker-image
  • dotnet
  • fail
  • golang
  • haskell
  • julia
  • lua
  • node
  • perl
  • pygrep
  • python
  • r
  • ruby
  • rust
  • script
  • swift
  • system
--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--output-format output-format

The output format

[default: text]

Possible values:

  • text
  • json
--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--skip hook|project

Skip the specified hooks or projects.

Supports flexible selector syntax:

  • hook-id: Skip all hooks with the specified ID across all projects

  • project-path/: Skip all hooks from the specified project

  • project-path:hook-id: Skip only the specified hook from the specified project

Can be specified multiple times. Also accepts PREK_SKIP or SKIP environment variables (comma-delimited).

--verbose, -v

Use verbose output

--version, -V

Display the prek version

## prek uninstall Uninstall prek Git shims

Usage

``` prek uninstall [OPTIONS] ```

Options

--all

Uninstall all prek-managed Git shims.

Scans the hooks directory and removes every hook managed by prek, regardless of hook type.

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--help, -h

Display the concise help for this command

--hook-type, -t hook-type

Which Git shim(s) to uninstall.

Specifies which Git hook type(s) you want to uninstall shims for. Can be specified multiple times to uninstall shims for multiple hook types.

If not specified, uses default_install_hook_types from the config file, or defaults to pre-commit if that is also not set. Use --all to remove all prek-managed hooks.

Possible values:

  • commit-msg
  • post-checkout
  • post-commit
  • post-merge
  • post-rewrite
  • pre-commit
  • pre-merge-commit
  • pre-push
  • pre-rebase
  • prepare-commit-msg
--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--verbose, -v

Use verbose output

--version, -V

Display the prek version

## prek validate-config Validate configuration files (prek.toml or .pre-commit-config.yaml)

Usage

``` prek validate-config [OPTIONS] [CONFIG]... ```

Arguments

CONFIG

The path to the configuration file

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--help, -h

Display the concise help for this command

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--verbose, -v

Use verbose output

--version, -V

Display the prek version

## prek validate-manifest Validate `.pre-commit-hooks.yaml` files

Usage

``` prek validate-manifest [OPTIONS] [MANIFEST]... ```

Arguments

MANIFEST

The path to the manifest file

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--help, -h

Display the concise help for this command

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--verbose, -v

Use verbose output

--version, -V

Display the prek version

## prek sample-config Produce a sample configuration file (prek.toml or .pre-commit-config.yaml)

Usage

``` prek sample-config [OPTIONS] ```

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--file, -f file

Write the sample config to a file.

Defaults to .pre-commit-config.yaml unless --format toml is set, which uses prek.toml. If a path is provided without --format, the format is inferred from the file extension (.toml uses TOML).

--format format

Select the sample configuration format

Possible values:

  • yaml
  • toml
--help, -h

Display the concise help for this command

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--verbose, -v

Use verbose output

--version, -V

Display the prek version

## prek auto-update Auto-update the `rev` field of repositories in the config file to the latest version

Usage

``` prek auto-update [OPTIONS] ```

Options

--bleeding-edge

Update to the bleeding edge of the default branch instead of the latest tagged version

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--cooldown-days days

Minimum release age (in days) required for a version to be eligible.

The age is computed from the tag creation timestamp for annotated tags, or from the tagged commit timestamp for lightweight tags. A value of 0 disables this check.

[default: 0]

--dry-run

Do not write changes to the config file, only display what would be changed

--freeze

Store "frozen" hashes in rev instead of tag names

--help, -h

Display the concise help for this command

--jobs, -j jobs

Number of threads to use

[default: 0]

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--repo repo

Only update this repository. This option may be specified multiple times

--verbose, -v

Use verbose output

--version, -V

Display the prek version

## prek cache Manage the prek cache

Usage

``` prek cache [OPTIONS] ```

Commands

prek cache dir

Show the location of the prek cache

prek cache gc

Remove unused cached repositories, hook environments, and other data

prek cache clean

Remove all prek cached data

prek cache size

Show the size of the prek cache

### prek cache dir Show the location of the prek cache

Usage

``` prek cache dir [OPTIONS] ```

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--help, -h

Display the concise help for this command

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--verbose, -v

Use verbose output

--version, -V

Display the prek version

### prek cache gc Remove unused cached repositories, hook environments, and other data

Usage

``` prek cache gc [OPTIONS] ```

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--dry-run

Print what would be removed, but do not delete anything

--help, -h

Display the concise help for this command

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--verbose, -v

Use verbose output

--version, -V

Display the prek version

### prek cache clean Remove all prek cached data

Usage

``` prek cache clean [OPTIONS] ```

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--help, -h

Display the concise help for this command

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--verbose, -v

Use verbose output

--version, -V

Display the prek version

### prek cache size Show the size of the prek cache

Usage

``` prek cache size [OPTIONS] ```

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--help, -h

Display the concise help for this command

--human, --human-readable, -H

Display the cache size in human-readable format (e.g., 1.2 GiB instead of raw bytes)

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--verbose, -v

Use verbose output

--version, -V

Display the prek version

## prek try-repo Try the pre-commit hooks in the current repo

Usage

``` prek try-repo [OPTIONS] [HOOK|PROJECT]... ```

Arguments

REPO

Repository to source hooks from

HOOK|PROJECT

Include the specified hooks or projects.

Supports flexible selector syntax:

  • hook-id: Run all hooks with the specified ID across all projects

  • project-path/: Run all hooks from the specified project

  • project-path:hook-id: Run only the specified hook from the specified project

Can be specified multiple times to select multiple hooks/projects.

Options

--all-files, -a

Run on all files in the repo

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--directory, -d dir

Run hooks on all files in the specified directories.

You can specify multiple directories. It can be used in conjunction with --files.

--dry-run

Do not run the hooks, but print the hooks that would have been run

--fail-fast

Stop running hooks after the first failure

--files files

Specific filenames to run hooks on

--from-ref, --source, -s from-ref

The original ref in a <from_ref>...<to_ref> diff expression. Files changed in this diff will be run through the hooks

--help, -h

Display the concise help for this command

--last-commit

Run hooks against the last commit. Equivalent to --from-ref HEAD~1 --to-ref HEAD

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--rev, --ref rev

Manually select a rev to run against, otherwise the HEAD revision will be used

--show-diff-on-failure

When hooks fail, run git diff directly afterward

--skip hook|project

Skip the specified hooks or projects.

Supports flexible selector syntax:

  • hook-id: Skip all hooks with the specified ID across all projects

  • project-path/: Skip all hooks from the specified project

  • project-path:hook-id: Skip only the specified hook from the specified project

Can be specified multiple times. Also accepts PREK_SKIP or SKIP environment variables (comma-delimited).

--stage, --hook-stage stage

The stage during which the hook is fired.

When specified, only hooks configured for that stage (for example manual, pre-commit, or pre-push) will run. Defaults to pre-commit if not specified. For hooks specified directly in the command line, fallback to manual stage if no hooks found for pre-commit stage.

Possible values:

  • manual
  • commit-msg
  • post-checkout
  • post-commit
  • post-merge
  • post-rewrite
  • pre-commit
  • pre-merge-commit
  • pre-push
  • pre-rebase
  • prepare-commit-msg
--to-ref, --origin, -o to-ref

The destination ref in a from_ref...to_ref diff expression. Defaults to HEAD if from_ref is specified

--verbose, -v

Use verbose output

--version, -V

Display the prek version

## prek util Utility commands

Usage

``` prek util [OPTIONS] ```

Commands

prek util identify

Show file identification tags

prek util list-builtins

List all built-in hooks bundled with prek

prek util init-template-dir

Install Git shims in a directory intended for use with git config init.templateDir

prek util yaml-to-toml

Convert a YAML configuration file to prek.toml

### prek util identify Show file identification tags

Usage

``` prek util identify [OPTIONS] [PATH]... ```

Arguments

PATH

The path(s) to the file(s) to identify

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--help, -h

Display the concise help for this command

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--output-format output-format

The output format

[default: text]

Possible values:

  • text
  • json
--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--verbose, -v

Use verbose output

--version, -V

Display the prek version

### prek util list-builtins List all built-in hooks bundled with prek

Usage

``` prek util list-builtins [OPTIONS] ```

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--help, -h

Display the concise help for this command

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--output-format output-format

The output format

[default: text]

Possible values:

  • text
  • json
--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--verbose, -v

Use verbose output

--version, -V

Display the prek version

### prek util init-template-dir Install Git shims in a directory intended for use with `git config init.templateDir`

Usage

``` prek util init-template-dir [OPTIONS] ```

Arguments

DIRECTORY

The directory in which to write the Git shim

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--help, -h

Display the concise help for this command

--hook-type, -t hook-type

Which Git shim(s) to install.

Specifies which Git hook type(s) you want to install shims for. Can be specified multiple times to install shims for multiple hook types.

If not specified, uses default_install_hook_types from the config file, or defaults to pre-commit if that is also not set.

Possible values:

  • commit-msg
  • post-checkout
  • post-commit
  • post-merge
  • post-rewrite
  • pre-commit
  • pre-merge-commit
  • pre-push
  • pre-rebase
  • prepare-commit-msg
--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-allow-missing-config

Assume cloned repos should have a pre-commit config

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--verbose, -v

Use verbose output

--version, -V

Display the prek version

### prek util yaml-to-toml Convert a YAML configuration file to prek.toml

Usage

``` prek util yaml-to-toml [OPTIONS] [CONFIG] ```

Arguments

CONFIG

The YAML configuration file to convert. If omitted, discovers .pre-commit-config.yaml or .pre-commit-config.yml in the current directory

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--force

Overwrite the output file if it already exists

--help, -h

Display the concise help for this command

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--output, -o output

Path to write the generated prek.toml file. Defaults to prek.toml in the same directory as the input file

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--verbose, -v

Use verbose output

--version, -V

Display the prek version

## prek self `prek` self management

Usage

``` prek self [OPTIONS] ```

Commands

prek self update

Update prek

### prek self update Update prek

Usage

``` prek self update [OPTIONS] [TARGET_VERSION] ```

Arguments

TARGET_VERSION

Update to the specified version. If not provided, prek will update to the latest version

Options

--cd, -C dir

Change to directory before running

--color color

Whether to use color in output

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
--config, -c config

Path to alternate config file

--help, -h

Display the concise help for this command

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--token token

A GitHub token for authentication. A token is not required but can be used to reduce the chance of encountering rate limits

May also be set with the GITHUB_TOKEN environment variable.

--verbose, -v

Use verbose output

--version, -V

Display the prek version

================================================ FILE: docs/compatibility.md ================================================ # Compatibility with pre-commit `prek` is designed to be a practical drop-in replacement for `pre-commit`. - Existing `.pre-commit-config.yaml` and `.pre-commit-config.yml` files work unchanged. See [Configuration](configuration.md). - Most day-to-day `pre-commit` commands work unchanged in `prek`. ## Command and flag differences Only the commands and flags below differ from the preferred `prek` spelling. The compatibility forms are still accepted so existing scripts do not break. - `prek install-hooks` still works, but `prek prepare-hooks` is the preferred spelling. - `prek install --install-hooks` still works, but `prek install --prepare-hooks` is the preferred flag spelling. - `prek autoupdate` still works, but `prek auto-update` is the preferred spelling. - `prek gc` still works as a hidden compatibility command, but `prek cache gc` is preferred. - `prek clean` still works as a hidden compatibility command, but `prek cache clean` is preferred. - `prek init-templatedir` and `prek init-template-dir` still work as hidden compatibility commands, but `prek util init-template-dir` is preferred. - `pre-commit hazmat` is not implemented in `prek`. - `pre-commit migrate-config` is not provided as a direct command. Use `prek util yaml-to-toml` if you want to migrate from YAML to `prek.toml`. If you need strict upstream portability, stay with the YAML config format and avoid `prek`-only features such as `prek.toml`, `repo: builtin`, glob mappings for `files` and `exclude`, and workspace mode. See [Configuration](configuration.md) and [Differences](diff.md). ================================================ FILE: docs/configuration.md ================================================ # Configuration `prek` reads **one configuration file per project**. You only need to choose **one** format: - **prek.toml** (TOML) — recommended for new users - **.pre-commit-config.yaml** (YAML) — best if you already use pre-commit or rely on tool/editor support Both formats are first-class and will be supported long-term. They describe the **same** configuration model: you list repositories under `repos`, then enable and configure hooks from those repositories. === "prek.toml" ```toml [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" hooks = [{ id = "trailing-whitespace" }] ``` === ".pre-commit-config.yaml" ```yaml repos: - repo: https://github.com/pre-commit/pre-commit-hooks hooks: - id: trailing-whitespace ``` ## Pre-commit compatibility `prek` is **fully compatible** with [`pre-commit`](https://pre-commit.com/) YAML configs, so your existing `.pre-commit-config.yaml` files work unchanged. If you use **`prek.toml`**, there’s nothing to worry about from a `pre-commit` perspective: upstream `pre-commit` does not read TOML. If you use the same `.pre-commit-config.yaml` with both tools, keep in mind: - `prek` supports several extensions beyond upstream `pre-commit`. - Upstream `pre-commit` may warn about unknown keys or error out on unsupported features. - To stay maximally portable, avoid the extensions listed below (or keep separate configs). Notable differences (when using YAML): - **Workspace mode** is a `prek` feature that can discover multiple projects; upstream `pre-commit` is single-project. - `files` / `exclude` can be written as **glob mappings** in `prek` (in addition to regex), which is not supported by upstream `pre-commit`. - `repo: builtin` adds fast built-in hooks in `prek`. - Upstream `pre-commit` uses `minimum_pre_commit_version`, while `prek` uses `minimum_prek_version` and intentionally ignores `minimum_pre_commit_version`. ### Prek-only extensions These entries are implemented by `prek` and are not part of the documented upstream `pre-commit` configuration surface. They work in both YAML and TOML, but they only matter for compatibility if you share a YAML config with upstream `pre-commit`. - Top-level: - [`minimum_prek_version`](#prek-only-minimum-prek-version-config) - [`orphan`](#prek-only-orphan) - Repo type: - [`repo: builtin`](#prek-only-repo-builtin) - Hook-level: - [`env`](#prek-only-env) - [`priority`](#prek-only-priority) - [`minimum_prek_version`](#prek-only-minimum-prek-version-hook) ## Configuration file ### Location (discovery) By default, `prek` looks for a configuration file starting from your current working directory and moving upward. It stops when it finds a config file, or when it hits the git repository boundary. If you run **without** `--config`, `prek` then enables **workspace mode**: - The first config found while traversing upward becomes the workspace root. - From that root, `prek` searches for additional config files in subdirectories (nested projects). Workspace discovery respects `.gitignore`, and also supports `.prekignore` for excluding directories from discovery. For the full behavior and examples, see [Workspace Mode](workspace.md). !!! tip After updating `.prekignore`, run with `--refresh` to force a fresh project discovery so the changes are picked up. If you pass `--config` / `-c`, workspace discovery is disabled and only that single config file is used. ### File name `prek` recognizes the following configuration filenames: - `prek.toml` (TOML) - `.pre-commit-config.yaml` (YAML, preferred for pre-commit compatibility) - `.pre-commit-config.yml` (YAML, alternate) In workspace mode, each project uses one of these filenames in its own directory. !!! note "One format per repo" We recommend using a **single format** across the whole repository to avoid confusion. If multiple configuration files exist in the same directory, `prek` uses only one and ignores the rest. The precedence order is: 1. `prek.toml` 2. `.pre-commit-config.yaml` 3. `.pre-commit-config.yml` ### File format Both `prek.toml` and `.pre-commit-config.yaml` map to the same configuration model (repositories under `repos`, then `hooks` under each repo). This section focuses on format-specific authoring notes and examples. #### TOML (`prek.toml`) Practical notes: - Structure is explicit and less indentation-sensitive. - Inline tables are common for hooks (e.g. `{ id = "ruff" }`). TOML supports both **inline tables** and **array-of-tables**, so you can choose between a compact or expanded hook style. Inline tables (best for small/simple hook configs): ```toml [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v6.0.0" hooks = [ { id = "end-of-file-fixer", args = ["--fix"] }, ] ``` Array-of-tables (more readable for larger hook configs): ```toml [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v6.0.0" [[repos.hooks]] id = "trailing-whitespace" [[repos.hooks]] id = "check-json" ``` Example: === "prek.toml" ```toml default_language_version.python = "3.12" [[repos]] repo = "local" hooks = [ { id = "ruff", name = "ruff", language = "system", entry = "python3 -m ruff check", files = "\\.py$", }, ] ``` The previous example uses multiline inline tables, a feature that was introduced in [TOML 1.1](https://toml.io/en/v1.1.0), not all parsers have support for it yet. You may want to use the longer form if your editor/IDE complains about it. === "prek.toml" ```toml default_language_version.python = "3.12" [[repos]] repo = "local" [[repos.hooks]] id = "ruff" name = "ruff" language = "system" entry = "python3 -m ruff check" files = "\\.py$" ``` #### YAML (`.pre-commit-config.yaml` / `.yml`) Practical notes: - Regular expressions are provided as YAML strings. If your regex contains backslashes, quote it (e.g. `files: '\\.rs$'`). - YAML anchors/aliases and merge keys are supported, so you can de-duplicate repeated blocks. Example: === ".pre-commit-config.yaml" ```yaml default_language_version: python: "3.12" repos: - repo: local hooks: - id: ruff name: ruff language: system entry: python3 -m ruff check files: "\\.py$" ``` #### Choosing a format **`prek.toml`** - Clearer structure and less error-prone syntax. - Recommended for new users or new projects. **`.pre-commit-config.yaml`** - Long-established in the ecosystem with broad tool/editor support. - Fully compatible with upstream `pre-commit`. **Recommendation** - If you already use `.pre-commit-config.yaml`, keep it. - If you want a cleaner, more robust authoring experience, prefer `prek.toml`. !!! tip If you want to switch, you can use [`prek util yaml-to-toml`](cli.md#prek-util-yaml-to-toml) to convert YAML configs to `prek.toml`. YAML comments are not preserved during conversion. ### Scope (per-project) Each configuration file (`prek.toml`, `.pre-commit-config.yaml`, or `.pre-commit-config.yml`) is scoped to the **project directory it lives in**. In workspace mode, `prek` treats every discovered configuration file as a **distinct project**: - A project’s config only controls hook selection and filtering (for example `files` / `exclude`) for that project. - A project may contain nested subprojects (subdirectories with their own config). Those subprojects run using *their own* configs. Practical implication: filters in the parent project do not “turn off” a subproject. Example layout (monorepo with a nested project): - `foo/.pre-commit-config.yaml` (project `foo`) - `foo/bar/.pre-commit-config.yaml` (project `foo/bar`, nested subproject) If project `foo` config contains an `exclude` that matches `bar/**`, then hooks for project `foo` will not run on files under `foo/bar`: === "prek.toml" ```toml # foo/prek.toml exclude = { glob = "bar/**" } ``` === ".pre-commit-config.yaml" ```yaml # foo/.pre-commit-config.yaml exclude: glob: "bar/**" ``` But if `foo/bar` is itself a project (has its own config), files under `foo/bar` are still eligible for hooks when running **in the context of project `foo/bar`**. !!! note "Excluding a nested project" If `foo/bar/.pre-commit-config.yaml` exists but you *don’t* want it to be recognized as a project in workspace mode, exclude it from discovery using [`.prekignore`](workspace.md#discovery). Like `.gitignore`, `.prekignore` files can be placed anywhere in the workspace and apply to their directory and all subdirectories. !!! tip After updating `.prekignore`, run with `--refresh` to force a fresh project discovery so the changes are picked up. ### Validation Use [`prek validate-config`](cli.md#prek-validate-config) to validate one or more config files. If you want IDE completion / validation, prek provides a JSON Schema at [https://prek.j178.dev/docs/prek.schema.json](https://prek.j178.dev/docs/prek.schema.json). And the schema is also submitted to the [JSON Schema Store](https://www.schemastore.org/prek.json), so some editors may pick it up automatically. That schema tracks what `prek` accepts today, but `prek` also intentionally tolerates unknown keys for forward compatibility. ## Configuration reference This section documents the configuration keys that `prek` understands. ### Top-level keys #### `repos` (required) A list of hook repositories. Each entry is one of: - a remote repository (typically a git URL) - `repo: local` for hooks defined directly in your repository - `repo: meta` for built-in meta hooks - `repo: builtin` for `prek`'s built-in fast hooks See [Repo entries](#repo-entries). #### `files` Global *include* regex applied before hook-level filtering. - Type: regex string (default, pre-commit compatible) **or** a prek-only glob pattern mapping - Default: no global include filter This is usually used to narrow down the universe of files in large repositories. !!! note "What path is matched? (workspace + nested projects)" `files` (and `exclude`) are matched against the file path **relative to the project root** — i.e. the directory containing the configuration file. - For the root project, this is the workspace root. - For a nested project, this is the nested project directory. Example (workspace mode): - Root project config: `./.pre-commit-config.yaml` - Nested project config: `./nested/.pre-commit-config.yaml` For a file at `nested/excluded_by_project`: - Root project sees the path as `nested/excluded_by_project` - Nested project sees the path as `excluded_by_project` This matters most for anchored patterns like `^...$`. !!! tip "Regex matching" When `files` / `exclude` are regex strings, they are matched with *search* semantics (the pattern can match anywhere in the path). Use `^` to anchor at the beginning and `$` at the end. `prek` uses the Rust [`fancy-regex`](https://github.com/fancy-regex/fancy-regex) engine. Most typical patterns are portable to upstream `pre-commit`, but very advanced regex features may differ from Python’s `re`. !!! note "prek-only globs" In addition to regex strings, `prek` supports glob patterns via: - `files: { glob: "..." }` (single glob) - `files: { glob: ["...", "..."] }` (glob list) This is a `prek` extension. Upstream `pre-commit` expects regex strings here. For more information on the glob syntax, refer to the [globset documentation](https://docs.rs/globset/latest/globset/#syntax). Examples: === "prek.toml" ```toml # Regex (portable to pre-commit) files = "\\.rs$" # Glob (prek-only) files = { glob = "src/**/*.rs" } # Glob list (prek-only; matches if any glob matches) files = { glob = ["src/**/*.rs", "crates/**/src/**/*.rs"] } ``` === ".pre-commit-config.yaml" ```yaml # Regex (portable to pre-commit) files: "\\.rs$" # Glob (prek-only) files: glob: "src/**/*.rs" # Glob list (prek-only; matches if any glob matches) files: glob: - "src/**/*.rs" - "crates/**/src/**/*.rs" ``` #### `exclude` Global *exclude* regex applied before hook-level filtering. - Type: regex string (default, pre-commit compatible) **or** a prek-only glob pattern mapping - Default: no global exclude filter `exclude` is useful for generated folders, vendored code, or build outputs. !!! note "What path is matched?" Same as [`files`](#top-level-files): the pattern is evaluated against the file path **relative to the project root** (the directory containing the config). !!! note "prek-only globs" Like `files`, `exclude` supports `glob` (single glob or glob list) as a `prek` extension. For glob syntax details, see the [globset documentation](https://docs.rs/globset/latest/globset/#syntax). Examples: === "prek.toml" ```toml # Regex (portable to pre-commit) exclude = "^target/" # Glob (prek-only) exclude = { glob = "target/**" } # Glob list (prek-only) exclude = { glob = ["target/**", "dist/**"] } ``` === ".pre-commit-config.yaml" ```yaml # Regex (portable to pre-commit) exclude: "^target/" # Glob (prek-only) exclude: glob: "target/**" # Glob list (prek-only) exclude: glob: - "target/**" - "dist/**" ``` Verbose regex example (useful for long allow/deny lists): === "prek.toml" ```toml # `(?x)` enables "verbose" regex mode (whitespace and newlines are ignored). exclude = """(?x)^( docs/| vendor/| target/ )""" ``` === ".pre-commit-config.yaml" ```yaml # `(?x)` enables "verbose" regex mode (whitespace and newlines are ignored). exclude: | (?x)^( docs/| vendor/| target/ ) ``` #### `fail_fast` Stop the run after the first failing hook. - Type: boolean - Default: `false` This is a global default; individual hooks can also set `fail_fast`. #### `default_language_version` Map a language name to the default `language_version` used by hooks of that language. - Type: map - Default: none (hooks fall back to `language_version: default`) Example: === "prek.toml" ```toml default_language_version.python = "3.12" default_language_version.node = "20" ``` === ".pre-commit-config.yaml" ```yaml default_language_version: python: "3.12" node: "20" ``` `prek` treats `language_version` as a version request (often a semver-like selector) and may install toolchains automatically. See [Difference from pre-commit](diff.md). #### `default_stages` Default `stages` used when a hook does not specify its own. - Type: list of stage names - Default: all stages Allowed values: - `manual` - `commit-msg` - `post-checkout` - `post-commit` - `post-merge` - `post-rewrite` - `pre-commit` - `pre-merge-commit` - `pre-push` - `pre-rebase` - `prepare-commit-msg` #### `default_install_hook_types` Default Git shim name(s) installed by `prek install` when you don’t pass `--hook-type`. - Type: list of `--hook-type` values - Default: `[pre-commit]` This controls which Git shims are installed (for example `pre-commit` vs `pre-push`). It is separate from a hook’s `stages`, which controls when a particular hook is eligible to run. Allowed values: - `pre-commit` - `pre-push` - `commit-msg` - `prepare-commit-msg` - `post-checkout` - `post-commit` - `post-merge` - `post-rewrite` - `pre-merge-commit` - `pre-rebase` #### `minimum_prek_version` !!! note "prek-only" This key is a `prek` extension. Upstream `pre-commit` uses `minimum_pre_commit_version`, which `prek` intentionally ignores. Require a minimum `prek` version for this config. - Type: string (version) - Default: unset If the installed `prek` is older than the configured minimum, `prek` exits with an error. Example: === "prek.toml" ```toml minimum_prek_version = "0.2.0" ``` === ".pre-commit-config.yaml" ```yaml minimum_prek_version: "0.2.0" ``` #### `orphan` !!! note "prek-only" `orphan` is a `prek` workspace-mode feature and is not recognized by upstream `pre-commit`. Workspace-mode setting to isolate a nested project from parent configs. - Type: boolean - Default: `false` When `orphan: true`, files under this project directory are handled only by this project’s config and are not “seen” by parent projects. Example: === "prek.toml" ```toml orphan = true [[repos]] repo = "https://github.com/astral-sh/ruff-pre-commit" rev = "v0.8.4" hooks = [{ id = "ruff" }] ``` === ".pre-commit-config.yaml" ```yaml orphan: true repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.8.4 hooks: - id: ruff ``` See [Workspace Mode - File Processing Behavior](workspace.md#file-processing-behavior) for details. ### Repo entries Each item under `repos:` is a mapping that always contains a `repo:` key. #### Remote repository Use this for hooks distributed in a separate repository. Required keys: - `repo`: repository location (commonly an https git URL) - `rev`: version to use (tag, branch, or commit SHA) - `hooks`: list of hook selections Remote hook definitions live inside the hook repository itself in the `.pre-commit-hooks.yaml` manifest (at the repo root). Your config only selects hooks by `id` and optionally overrides options. See [Authoring Hooks](authoring-hooks.md) if you maintain a hook repository. ##### `repo` Where to fetch hooks from. In most configs this is a git URL. `prek` also recognizes special values documented separately: `local`, `meta`, and `builtin`. ##### `rev` The revision to use for the remote repository. Use a tag or commit SHA for repeatable results. If you use a moving target (like a branch name), runs may change over time. ##### `hooks` The list of hooks to enable from that repository. Each item must at least specify `id`. You can also add hook-level options (filters, args, stages, etc.) to customize behavior. Example: === "prek.toml" ```toml [[repos]] repo = "https://github.com/astral-sh/ruff-pre-commit" rev = "v0.8.4" hooks = [{ id = "ruff", args = ["--fix"] }] ``` === ".pre-commit-config.yaml" ```yaml repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.8.4 hooks: - id: ruff args: [--fix] ``` Notes: - For reproducibility, prefer immutable pins (tags or commit SHAs). - `prek auto-update` can help update `rev` values. #### `repo: local` Define hooks inline inside your repository. Keys: - `repo`: must be `local` - `hooks`: list of **local hook definitions** (see [Local hook definition](#local-hook-definition)) Example: === "prek.toml" ```toml [[repos]] repo = "local" hooks = [ { id = "cargo-fmt", name = "cargo fmt", language = "system", entry = "cargo fmt", files = "\\.rs$", }, ] ``` === ".pre-commit-config.yaml" ```yaml repos: - repo: local hooks: - id: cargo-fmt name: cargo fmt language: system entry: cargo fmt files: "\\.rs$" ``` #### `repo: meta` Use `pre-commit`-style meta hooks that validate and debug your configuration. `prek` supports the following meta hook ids: - `check-hooks-apply` - `check-useless-excludes` - `identity` Restrictions: - `id` is required. - `entry` is not allowed. - `language` (if set) must be `system`. You may still configure normal hook options such as `files`, `exclude`, `stages`, etc. Example: === "prek.toml" ```toml [[repos]] repo = "meta" hooks = [{ id = "check-useless-excludes" }] ``` === ".pre-commit-config.yaml" ```yaml repos: - repo: meta hooks: - id: check-useless-excludes ``` #### `repo: builtin` !!! note "prek-only" `repo: builtin` is specific to `prek` and is not compatible with upstream `pre-commit`. Use `prek`’s built-in fast hooks (offline, zero setup). Restrictions: - `id` is required. - `entry` is not allowed. - `language` (if set) must be `system`. Example: === "prek.toml" ```toml [[repos]] repo = "builtin" hooks = [ { id = "trailing-whitespace" }, { id = "check-yaml" }, ] ``` === ".pre-commit-config.yaml" ```yaml repos: - repo: builtin hooks: - id: trailing-whitespace - id: check-yaml ``` For the list of available built-in hooks and the “automatic fast path” behavior, see [Built-in Fast Hooks](builtin.md). ### Hook entries Hook items under `repos[*].hooks` have slightly different shapes depending on the repo type. #### Remote hook selection For a remote repo, the hook entry must include: - `id` (required): selects the hook from the repository All other hook keys are optional overrides (for example `args`, `files`, `exclude`, `stages`, …). !!! note "Advanced overrides" `prek` also supports overriding `name`, `entry`, and `language` for remote hooks. This can be useful for experimentation, but it may reduce portability to the original `pre-commit`. #### Local hook definition For `repo: local`, the hook entry is a full definition and must include: - `id` (required): stable identifier used by `prek run ` and selectors - `name` (required): label shown in output - `entry` (required): command to execute - `language` (required): how `prek` sets up and runs the hook #### Builtin/meta hook selection For `repo: builtin` and `repo: meta`, the hook entry must include `id`. You can optionally provide `name` and normal hook options (filters, stages, etc), but not `entry`. ### Common hook options These keys can appear on hooks (remote/local/builtin/meta), subject to the restrictions above. #### `id` The stable identifier of the hook. - For remote hooks, this must match a hook id defined by the remote repository. - For local hooks, you choose it. `id` is also used for CLI selection (for example `prek run ` and `PREK_SKIP`). !!! note "Hook ids containing `:`" If your hook id contains `:` (for example `id: lint:ruff`), `prek run lint:ruff` will not select that hook. `prek` interprets `lint:ruff` as the selector `:`, with project `lint` and hook `ruff`. To select the hook id `lint:ruff`, add a leading `:` and run `prek run :lint:ruff`. #### `name` Human-friendly label shown in output. - Required for `repo: local` hooks. - Optional as an override for remote/meta/builtin hooks. #### `entry` The command line to execute for the hook. - Required for `repo: local` hooks. - Optional override for remote hooks. - Not allowed for `repo: meta` and `repo: builtin`. If `pass_filenames: true`, `prek` appends matching filenames to this command when running. #### `language` How `prek` should run the hook (and whether it should create a managed environment). - Required for `repo: local` hooks. - Optional override for remote hooks. - Not allowed (except as `system`) for `repo: meta` and `repo: builtin`. Common values include `system`, `python`, `node`, `rust`, `golang`, `ruby`, and `docker`. See [Language Support](languages.md) for per-language behavior, supported values, and `language_version` details. !!! note "Language name aliases" For compatibility with upstream `pre-commit`, the following legacy language names are also accepted: - `unsupported` is treated as `system` - `unsupported_script` is treated as `script` #### `alias` An alternate identifier for selecting the hook from the CLI. If set, you can run the hook via either `prek run ` or `prek run `. #### `args` Extra arguments appended to the hook’s `entry`. - Type: list of strings Example: === "prek.toml" ```toml hooks = [{ id = "ruff", args = ["--fix"] }] ``` === ".pre-commit-config.yaml" ```yaml hooks: - id: ruff args: [--fix] ``` #### `env` !!! note "prek-only" `env` is a `prek` extension and may not be recognized by upstream `pre-commit`. Extra environment variables for the hook process. - Type: map of string to string Values override the existing process environment (including variables such as `PATH`). For `docker` / `docker_image` hooks, these variables are passed into the container rather than being applied to the container runtime command. Example: === "prek.toml" ```toml [[repos]] repo = "local" hooks = [ { id = "cargo-doc", name = "cargo doc", language = "system", entry = "cargo doc --all-features --workspace --no-deps", env = { RUSTDOCFLAGS = "-Dwarnings" }, pass_filenames = false, }, ] ``` === ".pre-commit-config.yaml" ```yaml repos: - repo: local hooks: - id: cargo-doc name: cargo doc language: system entry: cargo doc --all-features --workspace --no-deps env: RUSTDOCFLAGS: -Dwarnings pass_filenames: false ``` #### `files` / `exclude` Filters applied to candidate filenames. - `files` selects which files are eligible for the hook. - `exclude` removes files matched by `files`. If you use both global and hook-level filters, the effective behavior is “global filter first, then hook filter”. By default (and for compatibility with upstream `pre-commit`), these are regex strings. As a `prek` extension, you can also specify globs using `glob` or a glob list. See [Top-level `files`](#top-level-files) and [Top-level `exclude`](#top-level-exclude) for syntax notes and examples. #### `types` / `types_or` / `exclude_types` File-type filters based on [`identify`](https://pre-commit.com/#filtering-files-with-types) tags. !!! tip Use [`prek util identify `](cli.md#prek-util-identify) to see how prek tags a file when you’re troubleshooting `types` filters. Compared to regex-only filtering (`files` / `exclude`), tag-based filtering is often easier and more robust: - tags can match by **file extension** *and* by **shebang** (for extensionless scripts) - you can easily exclude things like **symlinks** or **binary files** Common tags include: - `file`, `text`, `binary`, `symlink`, `executable` - language-ish tags such as `python`, `rust`, `javascript`, `yaml`, `toml`, ... - `types`: all listed tags must match (logical AND) - `types_or`: at least one listed tag must match (logical OR) - `exclude_types`: tags that disqualify a file How these combine: - `files` / `exclude`, `types`, and `types_or` are combined with **AND**. - Tags within `types` are combined with **AND**. - Tags within `types_or` are combined with **OR**. Defaults: - `types`: `[file]` (matches all files) - `types_or`: `[]` - `exclude_types`: `[]` These filters are applied in addition to regex filtering. Examples: === "prek.toml" ```toml [[repos]] repo = "local" hooks = [ # AND: must be under `src/` AND have the `python` tag { id = "lint-py", name = "Lint (py)", language = "system", entry = "python -m ruff check", files = "^src/", types = ["python"], exclude_types = ["symlink"] }, # OR: match any of the listed tags under `web/` { id = "lint-web", name = "Lint (web)", language = "system", entry = "npm run lint", files = "^web/", types_or = ["javascript", "jsx", "ts", "tsx"] }, ] ``` === ".pre-commit-config.yaml" ```yaml repos: - repo: local hooks: - id: lint-py name: Lint (py) language: system entry: python -m ruff check files: ^src/ types: [python] exclude_types: [symlink] - id: lint-web name: Lint (web) language: system entry: npm run lint files: ^web/ types_or: [javascript, jsx, ts, tsx] ``` If you need to match a path pattern that doesn’t align with a hook’s default `types` (common when reusing an existing hook in a nonstandard way), override it back to “all files” and use `files`: === "prek.toml" ```toml [[repos]] repo = "meta" hooks = [ { id = "check-hooks-apply", types = ["file"], files = "\\.(yaml|yml|myext)$" }, ] ``` === ".pre-commit-config.yaml" ```yaml repos: - repo: meta hooks: - id: check-hooks-apply types: [file] files: \.(yaml|yml|myext)$ ``` #### `always_run` Run the hook even when no files match. - Type: boolean - Default: `false` This is commonly used for hooks that check repository-wide state (for example, running a test suite) rather than operating on specific files. #### `pass_filenames` Controls whether `prek` appends the matching filenames to the command line. - Type: boolean or positive integer - Default: `true` which passes all matching filenames Set `pass_filenames: false` for hooks that don’t accept file arguments (or that discover files themselves). Set `pass_filenames: n` (a positive integer) to limit each invocation to at most `n` filenames. When there are more matching files than `n`, `prek` spawns multiple invocations and runs them in parallel. This is useful for tools that can only process a limited number of files at once. Prek will automatically limit the number of filenames to ensure command lines don’t exceed the OS limit, even when `pass_filenames: true`. #### `stages` Declare which stages a hook is eligible to run in. - Type: list of stage names - Default: all stages Allowed values: - `manual` - `commit-msg` - `post-checkout` - `post-commit` - `post-merge` - `post-rewrite` - `pre-commit` - `pre-merge-commit` - `pre-push` - `pre-rebase` - `prepare-commit-msg` When you run `prek run --hook-stage `, only hooks configured for that stage are considered. #### `require_serial` Force a hook to run without parallel invocations (one in-flight process for that hook at a time). - Type: boolean - Default: `false` This is useful for tools that use global caches/locks or otherwise can’t handle concurrent execution. #### `priority` !!! note "prek-only" `priority` controls `prek`'s scheduler and does not exist in upstream `pre-commit`. Each hook can set an explicit `priority` (a non-negative integer) that controls when it runs and with which hooks it may execute in parallel. Scope: - `priority` is evaluated **within a single configuration file** and is compared across **all hooks in that file**, even if they appear under different `repos:` entries. - `priority` does **not** coordinate across different config files. In workspace mode, each project’s config file is scheduled independently. Hooks run in ascending priority order: **lower `priority` values run earlier**. Hooks that share the same `priority` value run concurrently, subject to the global concurrency limit. When `priority` is omitted, `prek` assigns an implicit value based on hook order to preserve sequential behavior. Example: === "prek.toml" ```toml [[repos]] repo = "local" hooks = [ { id = "format", name = "Format", language = "system", entry = "python3 -m ruff format", always_run = true, priority = 0, }, { id = "lint", name = "Lint", language = "system", entry = "python3 -m ruff check", always_run = true, priority = 10, }, { id = "tests", name = "Tests", language = "system", entry = "just test", always_run = true, priority = 20, }, ] ``` === ".pre-commit-config.yaml" ```yaml repos: - repo: local hooks: - id: format name: Format language: system entry: python3 -m ruff format always_run: true priority: 0 - id: lint name: Lint language: system entry: python3 -m ruff check always_run: true priority: 10 - id: tests name: Tests language: system entry: just test always_run: true priority: 20 ``` !!! danger "Parallel hooks modifying files" If two hooks run in the same priority group and both mutate the same files (or depend on shared state), results are undefined. Use separate priorities to avoid overlap. !!! note "`require_serial` is different" `require_serial: true` prevents concurrent invocations of the *same hook*. It does not prevent other hooks from running alongside it; use a unique `priority` if you need exclusivity. #### `fail_fast` Hook-level fail-fast behavior. - Type: boolean - Default: `false` If `true`, a failure in this hook stops the run immediately. #### `verbose` Print hook output even when the hook succeeds. - Type: boolean - Default: `false` #### `log_file` Write hook output to a file when the hook fails (and also when `verbose: true`). - Type: string path #### `description` Free-form description shown in listings / metadata. - Type: string #### `language_version` Choose the language/toolchain version request for this hook. - Type: string - Default: `default` If not set, `prek` may use `default_language_version` for the hook’s language. !!! note "prek-only" `language_version` is treated as a **version request**, not a single pinned value. For languages that use semver requests, you can specify ranges (for example `^1.2`, `>=1.5, <2.0`). Special values: - `default`: use the language’s default resolution logic. - `system`: require a system-installed toolchain (no downloads). Language-specific behavior: - Python: passed to the Python resolver (for example `python3`, `python3.12`, or a specific interpreter name). May trigger toolchain download. - Node: passed to the Node resolver (for example `20`, `18.19.0`). May trigger toolchain download. - Go: uses Go version strings such as `1.22.1` (downloaded if missing). - Rust: supports rustup toolchains such as `stable`, `beta`, `nightly`, or versioned toolchains. - Other languages: parsed as a semver request and matched against the installed toolchain version. Examples: === "prek.toml" ```toml hooks = [ { id = "ruff", language = "python", language_version = "3.12" }, { id = "eslint", language = "node", language_version = "20" }, { id = "cargo-fmt", language = "rust", language_version = "stable" }, { id = "my-tool", language = "system", language_version = "system" }, ] ``` === ".pre-commit-config.yaml" ```yaml hooks: - id: ruff language: python language_version: "3.12" - id: eslint language: node language_version: "20" - id: cargo-fmt language: rust language_version: stable - id: my-tool language: system language_version: system ``` #### `additional_dependencies` Extra dependencies for hooks that run inside a managed environment (for example Python or Node hooks). - Type: list of strings If you set this for a language that doesn’t support dependency installation, `prek` fails with a configuration error. #### `minimum_prek_version` !!! note "prek-only" This is a `prek`-specific requirement gate. Upstream `pre-commit` does not have a hook-level minimum version key. Require a minimum `prek` version for this specific hook. - Type: string (version) - Default: unset ## Environment variables prek supports the following environment variables: - `PREK_HOME` — Override the prek data directory (caches, toolchains, hook envs). If beginning with `~`, it is expanded to the user’s home directory. Defaults to `~/.cache/prek` on macOS and Linux, and `%LOCALAPPDATA%\prek` on Windows. - `PREK_COLOR` — Control colored output: auto (default), always, or never. - `PREK_QUIET` — Control quiet output mode. Set to `1` for quiet mode (equivalent to `-q`, only shows failed hooks), or `2` for silent mode (equivalent to `-qq`, no output to stdout). - `PREK_SKIP` — Comma-separated list of hook IDs to skip (e.g. black,ruff). See [Skipping Projects or Hooks](workspace.md#skipping-projects-or-hooks) for details. - `PREK_ALLOW_NO_CONFIG` — Allow running without a configuration file (useful for ad‑hoc runs). - `PREK_NO_CONCURRENCY` — Disable parallelism for installs and runs (If set, force concurrency to 1). - `PREK_MAX_CONCURRENCY` — Set the maximum number of concurrent hooks (minimum 1). Defaults to the number of CPU cores when unset. Ignored when `PREK_NO_CONCURRENCY` is set. If you encounter "Too many open files" errors, lowering this value or raising the file descriptor limit with `ulimit -n` can help. - `PREK_NO_FAST_PATH` — Disable Rust-native built-in hooks; always use the original hook implementation. See [Built-in Fast Hooks](builtin.md) for details. - `PREK_UV_SOURCE` — Control how uv (Python package installer) is installed. Options: - `github` (download from GitHub releases) - `pypi` (install from PyPI) - `tuna` (use Tsinghua University mirror) - `aliyun` (use Alibaba Cloud mirror) - `tencent` (use Tencent Cloud mirror) - `pip` (install via pip) - a custom PyPI mirror URL If not set, prek automatically selects the best available source. - `PREK_NATIVE_TLS` — Use the system trusted store instead of the bundled `webpki-roots` crate. - `PREK_CONTAINER_RUNTIME` — Specify the container runtime to use for container-based hooks (e.g., `docker`, `docker_image`). Options: - `auto` (default, auto-detect available runtime) - `docker` - `podman` - `container` (Apple's Container runtime on macOS, see [container](https://github.com/apple/container)) - `PREK_LOG_TRUNCATE_LIMIT` — Control the truncation limit for command lines shown in trace logs (`Executing ...`). Defaults to `120` characters of arguments; set a larger value to reduce truncation. - `PREK_RUBY_MIRROR` — Override the Ruby installer base URL used for downloaded Ruby toolchains (for example, when using mirrors or air-gapped CI environments). See [Ruby language support](languages.md#ruby) for details. Compatibility fallbacks: - `PRE_COMMIT_ALLOW_NO_CONFIG` — Fallback for `PREK_ALLOW_NO_CONFIG`. - `PRE_COMMIT_NO_CONCURRENCY` — Fallback for `PREK_NO_CONCURRENCY`. - `SKIP` — Fallback for `PREK_SKIP`. ================================================ FILE: docs/debugging.md ================================================ # Debugging To enable verbose tracing output, use the `-vvv` flag when running prek: ```bash prek run -vvv ``` Additionally, on every run prek writes a log file to `~/.cache/prek/prek.log` by default. If you encounter issues, please include this log file when reporting bugs. ================================================ FILE: docs/diff.md ================================================ # Differences from pre-commit ## General differences - `prek` supports both `.pre-commit-config.yaml` and `.pre-commit-config.yml` configuration files. - `prek` implements some common hooks from `pre-commit-hooks` in Rust for better performance. - `prek` supports `repo: builtin` for offline, zero-setup hooks. - `prek` uses `~/.cache/prek` as the default cache directory for repos, environments and toolchains. - `prek` decoupled hook environment from their repositories, allowing shared toolchains and environments across hooks. - `prek` supports `language_version` as a semver specifier and automatically installs the required toolchains. - `prek` supports `files` and `exclude` as glob lists (in addition to regex) via `glob` mappings. See [Configuration](configuration.md#top-level-files). ## Workspace mode `prek` supports workspace mode, allowing you to run hooks for multiple projects in a single command. Each subproject can have its own `.pre-commit-config.yaml` file. See [Workspace Mode](./workspace.md) for more information. ## Language support See the dedicated [Language Support](languages.md) page for a complete list of supported languages, prek-specific behavior, and unsupported languages. ## Command line interface For a compatibility-focused command mapping, see [Compatibility with pre-commit](compatibility.md). ### `prek run` - `prek run [HOOK|PROJECT]...` supports selecting or skipping multiple projects or hooks in workspace mode. See [Running Specific Hooks or Projects](workspace.md#running-specific-hooks-or-projects) for details. - `prek run` can execute hooks in parallel by priority (hooks with the same [`priority`](./configuration.md#priority) may run concurrently), instead of strictly serial execution. - `prek` provides dynamic completions of hook id. - `prek run --last-commit` to run hooks on files changed by the last commit. - `prek run --directory ` to run hooks on a specified directory. ### `prek list` `prek list` command lists all available hooks, their ids, and descriptions. This provides a better overview of the configured hooks. ### `prek auto-update` - `prek auto-update` updates all projects in the workspace to their latest revisions. - `prek auto-update` checks updates for the same repository only once, speeding up the process in workspace mode. - `prek auto-update` supports `--dry-run` option to preview the updates without applying them. - `prek auto-update` supports the `--cooldown-days` option to skip releases newer than the specified number of days (based on the tag creation timestamp for annotated tags, or the tagged commit timestamp for lightweight tags). ### `prek sample-config` - `prek sample-config` command has a `--file` option to write the sample configuration to a specific file. ### `prek cache` - `prek cache clean` to remove all cached data. - `prek cache gc` to remove unused cached repositories, environments and toolchains. - `prek cache dir` to show the cache directory. - `prek cache size` to show the total size of the cache. ## Not implemented The `pre-commit hazmat` subcommand introduced in pre-commit [v4.5.0](https://github.com/pre-commit/pre-commit/releases/tag/v4.5.0) is not implemented. This command is niche and unlikely to be widely used. ================================================ FILE: docs/faq.md ================================================ # FAQ ## How is `prek` pronounced? Like "wreck", but with a "p" sound instead of the "w" at the beginning. ## I updated `.prekignore`, why didn't discovery change? Workspace discovery is cached. If you edited `.prekignore`, run the command with `--refresh` to force a fresh project discovery so the changes are picked up. For example: ```bash prek run --refresh ``` ## What does `prek install --prepare-hooks` do? In short, it installs the Git shims **and** prepares the environments for the hooks managed by prek. It is inherited from the original Python-based `pre-commit` tool (I'll abbreviate it as **ppc** in this document) to maintain compatibility with existing workflows. It's a little confusing because it refers to two different kinds of hooks: 1. **Git shims** – Scripts placed inside `.git/hooks/`, such as `.git/hooks/pre-commit`, that Git executes during lifecycle events. Both prek and ppc drop a small shim here so Git automatically runs them on `git commit`. 2. **prek-managed hooks** – The tools listed in `.pre-commit-config.yaml`. When prek runs, it executes these hooks and prepares whatever runtime they need (for example, creating a Python virtual environment and installing the hook's dependencies before execution). Running `prek install` installs the first type: it writes the Git shim so that Git knows to call prek. Which Git shims get installed is determined by `--hook-type` or `default_install_hook_types` in the config file, and defaults to `pre-commit` if neither is set. This is not affected by a hook's `stages` field in the config: `stages` controls when a configured hook may run, not which Git shims `prek install` writes. Adding `--prepare-hooks` tells prek to do that **and** proactively create the environments and caches required by the hooks that prek manages. That way, the next time Git invokes prek through the shim, the managed hooks are ready to run without additional setup. The older `--install-hooks` spelling remains as an alias. ## How do I use hooks from private repositories? prek supports cloning hooks from private repositories that require authentication. prek first clones with interactive terminal prompts disabled so non-interactive runs do not hang. If a clone fails with an authentication error and prek is not running in CI, it retries with terminal prompts enabled so Git can ask for credentials. In CI, interactive prompts remain disabled, so you still need to configure credentials via credential helpers, environment variables, or SSH. ### Option 1: Credential helpers (recommended) If you use GitHub CLI, Git Credential Manager, macOS Keychain, or similar tools, authentication often works automatically with no extra configuration: ```shell # GitHub CLI users: configure git to use gh for credentials gh auth setup-git # Now HTTPS URLs work automatically prek install ``` Other credential helpers that work out of the box: - **macOS**: Keychain (`credential.helper=osxkeychain`) - **Windows**: Git Credential Manager (`credential.helper=manager`) - **Linux**: GNOME Keyring, KWallet, or `credential.helper=store` You can also use `GIT_ASKPASS` to point to a custom credential program: ```shell export GIT_ASKPASS=/path/to/credential-script ``` ### Option 2: SSH URLs Use SSH URLs in your `.pre-commit-config.yaml` instead of HTTPS: ```yaml repos: - repo: git@github.com:myorg/private-hooks.git rev: v1.0.0 hooks: - id: my-hook ``` This works automatically if you have SSH keys configured with an agent. ### Option 3: URL rewriting with tokens (for CI) In CI environments without credential helpers, use environment variables to rewrite HTTPS URLs to include credentials: ```shell # GitHub Actions example export GIT_CONFIG_COUNT=1 export GIT_CONFIG_KEY_0="url.https://oauth2:${GITHUB_TOKEN}@github.com/.insteadOf" export GIT_CONFIG_VALUE_0="https://github.com/" # Or using GIT_CONFIG_PARAMETERS (more compact) export GIT_CONFIG_PARAMETERS="'url.https://oauth2:${GITHUB_TOKEN}@github.com/.insteadOf=https://github.com/'" ``` > **Security note:** Be careful with tokens in environment variables. Ensure your > CI system masks secrets in logs. ================================================ FILE: docs/index.md ================================================ # prek
prek
--8<-- "README.md:description" !!! note Although prek is pretty new, it's already powering real‑world projects—see [Who is using prek?](#who-is-using-prek). If you're looking for an alternative to `pre-commit`, please give it a try—we'd love your feedback! Please note that some languages are not yet supported for full drop‑in parity with `pre-commit`. See [Language Support](https://prek.j178.dev/languages/) for current status. --8<-- "README.md:features" --8<-- "README.md:why" ## Badges Show that your project uses prek with a badge in your README: [![prek](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json)](https://github.com/j178/prek) === "Markdown" ```markdown [![prek](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json)](https://github.com/j178/prek) ``` === "HTML" ```html prek ``` === "reStructuredText (RST)" ```rst .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/j178/prek/master/docs/assets/badge-v0.json :target: https://github.com/j178/prek :alt: prek ``` ================================================ FILE: docs/installation.md ================================================ # Installation prek provides multiple installation methods to suit different needs and environments. ## Standalone Installer The standalone installer automatically downloads and installs the correct binary for your platform: === "macOS and Linux" Use `curl` to download the script and execute it with `sh`: --8<-- "README.md:linux-standalone-install" === "Windows" Use `irm` to download the script and execute it with `iex`: --8<-- "README.md:windows-standalone-install" Changing the [execution policy](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies) allows running a script from the internet. !!! tip The installation script may be inspected before use. Alternatively, binaries can be downloaded directly from [GitHub Releases](#github-releases). ## Package Managers ### PyPI --8<-- "README.md:pypi-install" ### Homebrew (macOS/Linux) --8<-- "README.md:homebrew-install" ### mise --8<-- "README.md:mise-install" ### npm prek is published as a [Node.js package](https://www.npmjs.com/package/@j178/prek) and can be installed with any npm-compatible package manager: ```bash # npm npm install -g @j178/prek # pnpm pnpm add -g @j178/prek # bun bun install -g @j178/prek ``` Or as a project dependency: ```bash npm add -D @j178/prek ``` ### Nix --8<-- "README.md:nix-install" ### Conda --8<-- "README.md:conda-forge-install" ### Scoop (Windows) --8<-- "README.md:scoop-install" ### Winget (Windows) --8<-- "README.md:winget-install" ### MacPorts --8<-- "README.md:macports-install" ### cargo-binstall --8<-- "README.md:cargo-binstall" ## Docker prek provides a Docker image at [`ghcr.io/j178/prek`](https://github.com/j178/prek/pkgs/container/prek). See the guide on [using prek in Docker](integrations.md#docker) for more details. ## GitHub Releases --8<-- "README.md:pre-built-binaries" ## Build from Source --8<-- "README.md:cargo-install" ## Updating --8<-- "README.md:self-update" For other installation methods, follow the same installation steps again. ## Shell Completion !!! tip Run `echo $SHELL` to determine your shell. To enable shell autocompletion for prek commands, run one of the following: === "Bash" ```bash echo 'eval "$(COMPLETE=bash prek)"' >> ~/.bashrc ``` === "Zsh" ```bash echo 'eval "$(COMPLETE=zsh prek)"' >> ~/.zshrc ``` === "Fish" ```bash echo 'COMPLETE=fish prek | source' >> ~/.config/fish/config.fish ``` === "PowerShell" ```powershell Add-Content -Path $PROFILE -Value '$env:COMPLETE = "powershell"; prek | Out-String | Invoke-Expression; Remove-Item Env:\COMPLETE' ``` Then restart your shell or source the config file. ## Artifact Verification Release artifacts are signed with [GitHub Attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations) to provide cryptographic proof of their origin. Verify downloads using the [GitHub CLI](https://cli.github.com/): ```console $ gh attestation verify prek-x86_64-unknown-linux-gnu.tar.gz --repo j178/prek Loaded digest sha256:xxxx... for file://prek-x86_64-unknown-linux-gnu.tar.gz Loaded 1 attestation from GitHub API ✓ Verification succeeded! - Attestation #1 - Build repo:..... j178/prek - Build workflow:. .github/workflows/release.yml@refs/tags/vX.Y.Z ``` This confirms the artifact was built by the official release workflow. ================================================ FILE: docs/integrations.md ================================================ # Integrations This page documents common ways to integrate prek into CI and container workflows. ## Docker prek is published as a distroless container image at: - `ghcr.io/j178/prek` The image is based on `scratch` (no shell, no package manager). It contains the prek binary at `/prek`. A common pattern is to copy the binary into your own image: ```dockerfile FROM debian:bookworm-slim COPY --from=ghcr.io/j178/prek:v0.3.6 /prek /usr/local/bin/prek ``` If you prefer, you can also run the distroless image directly: ```bash docker run --rm ghcr.io/j178/prek:v0.3.6 --version ``` ### Verifying Images Docker images are signed with [GitHub Attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations) to verify they were built by official prek workflows. Verify using the [GitHub CLI](https://cli.github.com/): ```console $ gh attestation verify --owner j178 oci://ghcr.io/j178/prek:latest Loaded digest sha256:xxxx... for oci://ghcr.io/j178/prek:latest Loaded 1 attestation from GitHub API ✓ Verification succeeded! - Attestation #1 - Build repo:..... j178/prek - Build workflow:. .github/workflows/build-docker.yml@refs/tags/vX.Y.Z ``` !!! tip Use a specific version tag (e.g., `ghcr.io/j178/prek:v0.3.6`) or image digest rather than `latest` for verification. ## GitHub Actions --8<-- "README.md:github-actions" ================================================ FILE: docs/languages.md ================================================ # Language support ## What “language” means in prek Each hook has a `language` that tells prek how to install and run it. The language determines: - Whether prek creates a managed environment for the hook - How dependencies are installed (`additional_dependencies`) - How toolchain versions are selected (`language_version`) - How `entry` is executed For `repo: local` hooks, `language` is required. For remote hooks, it is read from `.pre-commit-hooks.yaml`, but you can override it in your config. ## Toolchain management and `language_version` prek resolves toolchains in two steps: 1. **Discover system toolchains** (PATH and common version manager locations). 2. **Download a toolchain** when the language supports it and the request cannot be satisfied locally. If `language_version` is `system`, prek skips downloads and requires a system-installed toolchain. If `language_version` is `default`, prek uses the language’s default resolution logic (often preferring system installs, then downloading if supported). !!! note "prek-only" `language_version` is parsed as a version request. For languages that use semver requests, you can specify ranges (for example `^1.2`, `>=1.5, <2.0`). See [Configuration](configuration.md#language_version) for details. Languages with managed toolchain downloads in prek today: - [Python](#python) - [Node](#node) - [Bun](#bun) - [Deno](#deno) - [Golang](#golang) - [Rust](#rust) - [Ruby](#ruby) Other supported languages rely on system installations and will fail if a matching toolchain is not available. ## Language details Below is how prek handles each language (with notes when it differs from pre-commit). ### bun **Status in prek:** ✅ Supported. prek installs Bun hooks via `bun install` and runs the configured entry. The repository should contain a `package.json`. `entry` should match a provided bin name or be a Bun command. `additional_dependencies` are supported. Bun hooks run without needing a pre-installed Bun runtime when toolchain download is available. #### `language_version` Supported formats: - `default` or `system` - `bun`, `bun@latest` - `bun@1`, `1` - `bun@1.1`, `1.1` - `bun@1.1.0`, `1.1.0` - Semver ranges like `>=1.0, <2.0` - Absolute path to a Bun executable !!! note "prek-only" Bun language support is a prek extension. pre-commit does not have native `bun` support. ### conda **Status in prek:** Not supported yet. Tracking: [#52](https://github.com/j178/prek/issues/52) ### coursier **Status in prek:** Not supported yet. Tracking: [#53](https://github.com/j178/prek/issues/53) ### dart **Status in prek:** Not supported yet. Tracking: [#51](https://github.com/j178/prek/issues/51) ### docker **Status in prek:** ✅ Supported. prek expects the hook repository to ship a Dockerfile and builds the image from the repo root with `docker build .`. Hooks run inside the container, and the first token of `entry` is used as the container entrypoint (arguments are passed after it). Runtime behavior: - Requires a working container engine on the host (Docker, Podman, or Container). - The repository is bind-mounted into the container at `/src` and the working directory is set to `/src`. - The container is run with `--entrypoint` set to the hook `entry`, so the image’s default command is not used when filenames are passed. - Environment variables configured via `env` are passed using `-e`. - On Linux, prek tries to run as a non-root user and handles rootless Podman with `--userns=keep-id`. Use `docker` when you need a language runtime that isn’t otherwise supported; the container provides the execution environment. !!! note "prek-only" prek auto-detects the container runtime (Docker, Podman, or [Container](https://github.com/apple/container)) and can be overridden with `PREK_CONTAINER_RUNTIME`. See [Configuration](configuration.md#environment-variables) for details. ### docker_image **Status in prek:** ✅ Supported. prek runs hooks from an existing image. The `entry` value is passed to `docker run` directly, so it should include the image reference and can optionally include `--entrypoint` overrides. Runtime behavior: - Uses the same bind-mount and `/src` working directory as `docker` hooks. - Environment variables configured via `env` are passed using `-e`. If the image already defines an `ENTRYPOINT`, you can omit `--entrypoint` in `entry`. Otherwise, specify it explicitly in `entry`. !!! note "prek-only" prek uses the same runtime auto-detection as `docker` hooks. ### dotnet **Status in prek:** Not supported yet. Tracking: [#48](https://github.com/j178/prek/issues/48) ### fail **Status in prek:** ✅ Supported. `fail` is a lightweight “forbid files” hook. The `entry` text is printed when the hook fails, followed by the list of matching files, and the hook exits non-zero. ### golang **Status in prek:** ✅ Supported. prek installs with `go install ./...` in an isolated `GOPATH`. The repository should build at least one binary whose name matches the hook `entry`. `additional_dependencies` can be appended and `language_version` selects the Go toolchain. #### `language_version` Supported formats: - `default` or `system` - `go1.22`, `1.22` - `go1.22.1`, `1.22.1` - Semver ranges like `>=1.20, <1.23` - Absolute path to a `go` executable Pre-release strings (for example `go1.22rc1`) are not supported yet. ### haskell **Status in prek:** ✅ Supported. prek installs Haskell hooks via Cabal and runs the configured entry. Please ensure the repository contains a `.cabal` file or configured `additional_dependencies` for proper dependency management. #### `language_version` `language_version` is not supported for Haskell hooks yet. It uses the system `cabal` and `ghc` installations. The hook `entry` should point at an executable installed by `cabal`. ### julia **Status in prek:** ✅ Supported. prek installs Julia hooks into an isolated environment using Julia's built-in package manager (`Pkg`). The hook repository can include a `Project.toml` (or `JuliaProject.toml`) and optionally a `Manifest.toml` (or `JuliaManifest.toml`). If these files are present, prek will use them to instantiate the environment. If no project file is found, an empty one is created to ensure the environment is correctly initialized. `additional_dependencies` are supported and will be added to the environment via `Pkg.add`. #### `language_version` `language_version` is not supported for Julia hooks yet. It uses the system `julia` installation. The hook `entry` should be a path to a julia source file relative to the hook repository (optionally with arguments). It is executed using `julia --project= --startup-file=no`. ### lua **Status in prek:** ✅ Supported. prek installs Lua hooks via LuaRocks and runs the configured entry. If the repository includes a rockspec, it is installed into the hook environment before running. #### `language_version` Lua does not support `language_version` today. It uses the system `lua` / `luarocks` installation. The hook entry should point at an executable installed by LuaRocks. ### node **Status in prek:** ✅ Supported. prek expects a `package.json` and installs via `npm install .`, exposing executables from the package `bin`. `entry` should match a provided bin name. `additional_dependencies` are supported. Node hooks run without needing a pre-installed Node runtime when toolchain download is available. #### `language_version` Supported formats: - `default` or `system` - `node18`, `18`, `18.19`, `18.19.1` - Semver ranges like `^18.12` or `>=18, <20` - LTS selectors: `lts` or `lts/` - Absolute path to a Node executable ### perl **Status in prek:** Not supported yet. Tracking: [#1447](https://github.com/j178/prek/issues/1447) ### python **Status in prek:** ✅ Supported. prek installs hook repositories with `uv pip install` and uses the installed console scripts. The repository should be installable via `pip` (for example via `pyproject.toml` or `setup.py`). `additional_dependencies` are appended to the install step. Python hooks run without requiring a system Python when toolchain download is available. #### `language_version` Supported formats: - `default` or `system` - `python`, `python3`, `python3.12`, `python3.12.1` - `3`, `3.12`, `3.12.1` - Wheel-style short forms like `312` or `python312` - Semver ranges like `>=3.9, <3.13` - Absolute path to a Python executable !!! note "prek-only" prek uses `uv` for virtual environments and dependency installs, and can auto-install Python toolchains based on `language_version`. #### Dependency management with `uv` prek uses `uv` for creating virtual environments and installing dependencies: - First tries to find `uv` in the system PATH - If not found, automatically installs `uv` from the best available source (GitHub releases, PyPI, or mirrors) - Automatically installs the required Python version if it's not already available !!! warning "Environment variables" Since prek calls `uv` under the hood to create Python virtual environments and install dependencies, most `uv` environment variables will affect prek's behavior. For example, setting `UV_RESOLUTION=lowest-direct` in your environment will cause hook dependencies to be resolved to their lowest compatible versions, which may lead to installation failures with old packages on modern Python versions. If you encounter unexpected behavior when installing Python hooks, check whether you have any `UV_*` environment variables set that might be affecting dependency resolution or installation. #### PEP 723 inline script metadata support For Python hooks **without** `additional_dependencies`, prek can read PEP 723 inline metadata from the script specified in the `entry` field. **Example:** `.pre-commit-config.yaml`: ```yaml repos: - repo: local hooks: - id: echo name: echo language: python entry: ./echo.py ``` `echo.py`: ```python # /// script # requires-python = ">=3.13" # dependencies = [ # "pyecho-cli", # ] # /// from pyecho import main main() ``` **Important notes:** - The first part of the `entry` field must be a path to a local Python script - If `additional_dependencies` is specified in `.pre-commit-config.yaml`, script metadata will be ignored - When both `language_version` (in config) and `requires-python` (in script) are set, `language_version` takes precedence - Only `dependencies` and `requires-python` fields are supported; other metadata like `tool.uv` is ignored ### r **Status in prek:** Not supported yet. Tracking: [#42](https://github.com/j178/prek/issues/42) ### ruby **Status in prek:** ✅ Supported. prek installs gems from a `*.gemspec` and runs executables declared in the gemspec. `additional_dependencies` are installed into the same isolated gemset. #### `language_version` Supported formats: - `default` or `system` - `3`, `3.3`, `3.3.6` - `ruby-3`, `ruby-3.3`, `ruby-3.3.6` - Semver ranges like `>=3.2, <4.0` - Absolute path to a Ruby executable !!! note "prek-only" prek can use system-installed Rubies, including a variety of common version managers. On some platforms, if the system search fails to find a suitable version matching `language_version`, it can then attempt to download one. Ruby interpreters are downloaded from those built by the `rv` project, and as such are limited in supported platform versions (currently limited to MacOS and Linux on x86_64 and ARM64). Older versions are also not available, with the oldest being 3.2.1. Unsupported platforms or versions will require a compatible system Ruby installation. The `PREK_RUBY_MIRROR` environment variable can be used to point to a different source for installers, for example to support mirrors or air-gapped CI environments. Mirrors need to follow the GitHub URL patterns, but note that although the GitHub hostname changes between `api.github.com` and `github.com` as needed, any non-GitHub mirror server will not be remapped in this manner. Where Ruby is being downloaded from GitHub (either from the upstream `rv` or a mirror), this remapping does occur, and any `GITHUB_TOKEN` will be sent with the requests. This both limits impact of rate limiting, and also allows a private GitHub repository to be used (e.g. for a vetted subset of `rv` rubies to be mirrored). Note that GitHub tokens will only be sent to mirrors which are hosted on GitHub. Gems specified in hook gemspec files and `additional_dependencies` are installed into an isolated gemset shared across hooks with the same Ruby version and dependencies. ### rust **Status in prek:** ✅ Supported. prek installs binaries via `cargo install --bins --locked` and runs the specified executable. The repository should contain a `Cargo.toml` that produces the binary referenced by `entry`. `additional_dependencies` and `language_version` are supported. !!! note "Using `--locked` flag" prek uses the `--locked` flag when installing Rust packages to ensure exact dependency versions from `Cargo.lock` are used. This prevents breaking changes from new dependency releases. #### `language_version` Supported formats: - `default` or `system` - Channels: `stable`, `beta`, `nightly` - `1`, `1.70`, `1.70.0` - Semver ranges like `>=1.70, <1.72` !!! note "prek-only" - prek supports installing packages from virtual workspaces. See [#1180](https://github.com/j178/prek/pull/1180). - `additional_dependencies` supports: - Library dependencies using `name` or `name:version` (applied via `cargo add`). - CLI dependencies using `cli:`. - There are two forms: - crates.io: `cli:[:]` - git: `cli:[:[:]]` - For git dependencies: - `` is the git repository URL. - `` is optional and selects a specific git tag. - `` is optional and selects which Cargo package to install binaries from. - Use `` when the git repository is a workspace or multi-crate repository and Cargo needs you to choose one package. - This matches the package argument in `cargo install --git `. - Examples: - crates.io package: `cli:rg` - crates.io package with version: `cli:rg:13.0.0` - git repository default ref: `cli:https://github.com/fish-shell/fish-shell` - git repository with tag: `cli:https://github.com/fish-shell/fish-shell:v4.5.0` - git repository with package but no tag: `cli:https://github.com/fish-shell/fish-shell::fish` - git repository with tag and package: `cli:https://github.com/fish-shell/fish-shell:v4.5.0:fish` - Invalid forms: - empty package is invalid, for example `...:v4.5.0:` or `...::`. ### swift **Status in prek:** ✅ Supported. prek detects the system Swift installation and runs hooks using the configured `entry`. If the hook repository contains a `Package.swift`, prek builds it in release mode and adds the resulting binaries to PATH. Runtime behavior: - Uses the system Swift installation (no automatic toolchain management) - Builds Swift packages with `swift build -c release` - Build artifacts are stored in the hook environment's `.build/release/` directory - The `entry` command runs with built binaries available on PATH #### `language_version` Swift does not support `language_version` today. It uses the system `swift` installation. ### pygrep **Status in prek:** ✅ Supported. prek provides a Python-based grep implementation for file content matching. The `entry` is a Python regex. Supported args: - `-i` / `--ignore-case` - `--multiline` - `--negate` (require all files to match) Regex matching uses Python’s `re` semantics for compatibility with pre-commit. ### system **Status in prek:** ✅ Supported. `system` runs a system executable without a managed environment. The command is taken from `entry`, and filenames are appended unless `pass_filenames: false` is set. Dependencies must be installed by the user. Use `system` for tools with special environment requirements that cannot run in isolated environments. !!! note `unsupported` is accepted as an alias for `system`. ### script **Status in prek:** ✅ Supported. `script` runs repository-local scripts without a managed environment. For remote hooks, `entry` is resolved relative to the hook repository root; for local hooks, it is resolved relative to the current working directory. Use `script` for simple repository scripts that only need file paths and no managed environment. !!! note `unsupported_script` is accepted as an alias for `script`. ### deno **Status in prek:** ✅ Supported. prek installs each `additional_dependencies` item with `deno install --global` into the hook environment. The hook runs from the work repository with an isolated `DENO_DIR` for cache separation. Deno hooks run without needing a pre-installed Deno runtime when toolchain download is available. #### Rules - `additional_dependencies` are treated as executable installs. Each item should be something `deno install --global` can install, such as an `npm:` or `jsr:` specifier. - `additional_dependencies` may also point at a local file to install as an executable, using `./path/to/tool.ts:name`. Relative paths resolve from the hook repository for remote hooks and from the work repository for local hooks. - To override the executable name for an additional dependency, append `:name` to the dependency string. For example: `npm:semver@7:semver-tool`. For remote hooks, if the repo wants to provide its own executable, declare it explicitly in the hook's `additional_dependencies`, for example `./cli.ts:repo-tool`, and then use `repo-tool` in `entry`. #### `language_version` Supported formats: - `default` or `system` - `deno`, `deno@latest` - `deno@x`, `x` (major version) - `deno@x.y`, `x.y` (major.minor version) - `deno@x.y.z`, `x.y.z` (exact version) - Semver ranges like `>=x.y, ` to change the working directory before execution. **Discovery exclusions** - Directories beginning with a dot (e.g. `.hidden`) are ignored during project discovery. - Cookiecutter template directories (names like `{{cookiecutter.project_slug}}`) are ignored during project discovery. **Ignore rules** - By default, `prek` respects `.gitignore` files during workspace discovery. This means any directories or files excluded by `.gitignore`, `.git/info/exclude`, or your global gitignore configuration will automatically be excluded from project discovery. This prevents `prek` from discovering workspaces in ignored directories like `node_modules`, `target`, or `.venv`. - For additional control, `prek` also supports reading `.prekignore` files (following the same syntax rules as `.gitignore`) to exclude specific directories from workspace discovery beyond what's in `.gitignore`. Like `.gitignore`, `.prekignore` files can be placed anywhere in the workspace and apply to their directory and all subdirectories. This works similarly to the `--skip` option but is configured via files. !!! tip After updating `.prekignore`, run with `--refresh` to force a fresh project discovery so the changes are picked up. ## Project Organization ### Example Structure ```text my-monorepo/ ├── .pre-commit-config.yaml # Workspace root config ├── .git/ ├── docs/ │ └── .pre-commit-config.yaml # Nested project ├── src/ │ ├── .pre-commit-config.yaml # Nested project │ └── backend/ │ └── .pre-commit-config.yaml # Deeply nested project └── frontend/ └── .pre-commit-config.yaml # Nested project ``` In this example: - `my-monorepo/` is the workspace root - `docs/`, `src/`, `src/backend/`, and `frontend/` are individual projects - Each project has its own `.pre-commit-config.yaml` file ## Execution Model ### File Collection When running in workspace mode: 1. **Collect all files**: `prek` collects all files within the workspace root directory 2. **Apply global filters**: Files are filtered based on include/exclude patterns from the workspace root config 3. **Distribute to projects**: Each project receives a subset of files based on its location #### File Visibility Constraints **Important**: Each project can only see and process files within its own directory tree. This is a fundamental design principle of workspace mode that ensures proper isolation between projects. A hook defined in `frontend/.pre-commit-config.yaml` can only match files under the `frontend/` directory—it cannot reference files from sibling directories like `backend/`. If hooks need to reference files across multiple projects, move the hook configuration to a common ancestor directory (e.g., the workspace root). ### Hook Execution For each project: 1. **Scope to project directory**: Hooks run within their project's root directory 2. **Filter files**: Only files within the project's directory tree are passed to its hooks 3. **Independent execution**: Each project's hooks run independently with their own environment ### Execution Order Projects are executed from **deepest to shallowest**: 1. `src/backend/` (deepest) 2. `src/` 3. `docs/` 4. `frontend/` 5. `./` (root, last) This ensures that more specific configurations (deeper projects) take precedence over general ones. ### File Processing Behavior **By default**, files in subprojects will be processed multiple times - once for each project in the hierarchy that contains them. For example, a file in `src/backend/` will be checked by hooks in `src/backend/`, then `src/`, then the workspace root. **To isolate a project**, you can set `orphan: true` in its configuration. When enabled, files in this project are "consumed" by it and will not be processed by parent projects: ```yaml # src/backend/.pre-commit-config.yaml orphan: true repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.8.4 hooks: - id: ruff ``` With this option: - Files in `src/backend/` are processed **only** by hooks in `src/backend/` - Files in `src/` (but not in `src/backend/`) are processed by hooks in `src/` and the workspace root - Files in the root (but not in subdirectories with configs) are processed by hooks in the root This can be useful to avoid redundant processing in monorepos with nested project structures or to completely isolate a subproject from parent configurations. ### Example Output When running `prek run` on the example structure above, you might see output like this: ```console $ prek run Running hooks for `src/backend`: check python ast.........................................................Passed check for merge conflicts................................................Passed black....................................................................Passed isort....................................................................Passed Running hooks for `docs`: Markdownlint.........................................(unimplemented yet)Skipped Running hooks for `frontend`: prettier.................................................................Passed Running hooks for `src`: isort....................................................................Passed mypy.....................................................................Passed check python ast.........................................................Passed check docstring is first.................................................Passed Running hooks for `.`: fix end of files.........................................................Passed check yaml...............................................................Passed check for added large files..............................................Passed trim trailing whitespace.................................................Passed check for merge conflicts................................................Passed ``` Notice how: - Files in `src/backend/` are processed by both the `src/backend/` project and the `src/` project - Each project runs in its own working directory - The workspace root processes all files in the entire workspace - Projects are executed from deepest to shallowest as described in the execution order #### Orphan Projects and Selectors When you combine `orphan: true` with selectors such as `--skip`, remember that orphans keep the files they cover. Even if you skip an orphan project (for example via `--skip src/backend/`), that project still claims ownership of the files under its directory. Those files will not fall back to parent projects, so you can disable or precisely target orphaned projects without reintroducing duplicate processing upstream. ## Command Line Usage ```bash # Run from current directory, auto-discover workspace prek run # Run specific hook across all projects prek run black # Run from specific directory cd src/backend && prek run # Use -C option to change directory automatically prek run -C src/backend ``` The `-C ` or `--cd ` option automatically changes to the specified directory before running, allowing you to target specific projects from any location in the workspace. !!! note When using `prek install`, only the workspace root configuration's `default_install_hook_types` will be honored. Nested project configurations are not considered during installation. ## Project and Hook Selection In workspace mode, you can selectively run hooks from specific projects or skip certain projects/hooks using flexible selector syntax. ### Selector Syntax The selector syntax has three different forms: 1. **``**: Matches all hooks with the given ID across all projects. 2. **`/`**: Matches all hooks from the specified project and its subprojects. 3. **`:`**: Matches only the specified hook from the specified project. Selectors can be used to select specific hooks or projects, and combined with `--skip` to exclude certain hooks or projects. !!! note `` can be a relative path, which is then resolved relative to the current working directory. The trailing slash `/` in a `` is important: if a selector does not contain a slash, it is interpreted as a hook ID. !!! note "Hook ids containing `:`" If your hook id contains `:` (for example `id: lint:ruff`), `prek run lint:ruff` will not select that hook. `prek` interprets `lint:ruff` as the selector `:`, with project `lint` and hook `ruff`. To select the hook id `lint:ruff`, add a leading `:` and run `prek run :lint:ruff`. ### Running Specific Hooks or Projects ```bash # Run all hooks with a specific ID across all projects prek run # Run only hooks from a specific project prek run / # Run only hooks with a specific ID from a specific project prek run : ``` **Examples:** ```bash # Run all 'black' hooks across all projects prek run black # Run all hooks from the 'frontend' project prek run frontend/ # Run only the 'lint' hook from the 'frontend' project prek run frontend:lint # Run the 'lint' from 'frontend' and 'black' from 'src/backend' prek run frontend:lint src/backend:black ``` ### Skipping Projects or Hooks You can skip specific projects or hooks using the `--skip` option, with the same syntax as for selecting projects or hooks. **Alternative**: You can also create `.prekignore` files (using `.gitignore` syntax) anywhere in the workspace to permanently exclude directories from project discovery during workspace setup. Note that `.gitignore` files are already respected by default, so `.prekignore` is only needed for excluding additional directories beyond what's in `.gitignore`. !!! tip After updating `.prekignore`, run with `--refresh` to force a fresh project discovery so the changes are picked up. ```bash # Skip all hooks from a specific project prek run --skip / # Skip specific hooks within a selected project prek run / --skip / # Skip all hooks with a specific ID across all projects prek run --skip ``` **Examples:** ```bash # Run all hooks except those from the 'frontend' project prek run --skip frontend/ # Run hooks from 'frontend' but skip 'frontend/docs' prek run frontend/ --skip frontend/docs # Run hooks from 'frontend' but skip 'frontend/docs' and 'frontend:lint' prek run frontend/ --skip frontend/docs --skip frontend:lint # Run all hooks except 'black' and 'markdownlint' hooks prek run --skip black --skip markdownlint ``` !!! note Selecting a project includes all its subprojects unless explicitly skipped. Skipping a project also skips all its subprojects. !!! note The `PREK_SKIP` or `SKIP` environment variable can be used as an alternative to `--skip`. Multiple values should be comma-delimited: ```bash # Skip 'frontend' and 'tests' projects PREK_SKIP=frontend/,tests prek run # Skip 'frontend/docs' project and 'src/backend:lint' hook SKIP=frontend/docs,src/backend:lint prek run ``` Precedence rules for `--skip` command line options and environment variables are: `--skip` > `PREK_SKIP` > `SKIP`. ### Advanced Examples ```bash # Run 'lint' hooks from all projects except 'tests' prek run lint --skip tests # Run all hooks from 'src' and 'docs' but skip 'src/legacy' prek run src/ docs/ --skip src/legacy # Run 'format' hooks only from Python projects prek run python:format ``` ## Single Config Mode When you specify a configuration file using the `-c` or `--config` parameter, workspace mode is disabled and only the specified configuration file is used. This mode provides traditional pre-commit behavior similar to the original pre-commit tool. In single config mode: - **No workspace discovery**: Only the explicitly specified configuration file is used - **Single execution context**: All hooks run from the git repository root directory - **Global file scope**: All files in the git repository are passed to all hooks - **No project isolation**: Hooks don't have access to project-specific working directories ### Usage Examples ```bash # Disable workspace mode, use specific config prek run --config .pre-commit-config.yaml # Use config from a subdirectory prek run --config src/.pre-commit-config.yaml # Short form using -c prek run -c docs/.pre-commit-config.yaml ``` ### Key Differences: Workspace vs Single Config | Feature | Workspace Mode | Single Config Mode | | -- | -- | -- | | **Discovery** | Auto-discovers all `.pre-commit-config.yaml` files | Uses single specified config file | | **Working Directory** | Uses workspace root | Uses git repository root | | **File Scope** | All files in workspace | All files in git repo | | **Hook Scope** | Project-specific file filtering | All files pass to all hooks | | **Execution Context** | Each project runs in its own directory | All hooks run from git root | | **Configuration** | Multiple configs | Single config file only | ### Migration from Single Config To migrate an existing single-config setup to workspace mode: 1. **Create workspace root**: Move existing `.pre-commit-config.yaml` to repository root 2. **Add project configs**: Create `.pre-commit-config.yaml` in subdirectories as needed 3. **Update file patterns**: Adjust `files`/`exclude` patterns to be project-relative 4. **Test execution**: Verify hooks run in correct directories with correct file sets ## Workspace Cache To improve performance in large monorepos, `prek` introduces a workspace cache mechanism. The workspace cache stores the results of project discovery, so repeated runs are much faster. - The cache is automatically used by default. You don't need to do anything for it to work. - If you make changes to `.pre-commit-config.yaml` files, remove projects, or otherwise change the workspace structure, `prek` will usually detect this and refresh the cache automatically. - If you add a new `.pre-commit-config.yaml` to your workspace, `prek` may not detect it immediately, try running with `--refresh` to ensure the cache is up to date. ```bash prek run --refresh ``` This will clear and rebuild the workspace cache before running hooks. ## Behavior Changes in Workspace Mode When running in workspace mode, there are a few changes to the output format and behavior compared to single-config mode: 1. Hook output is grouped by project, with a header indicating which project is currently running. 2. Skipped hooks are not shown at all in the output, previously they were listed as "Skipped". The workspace mode provides powerful organization capabilities while maintaining backward compatibility with existing single-config workflows. ================================================ FILE: licenses/LICENSE.identify.txt ================================================ Copyright (c) 2017 Chris Kuehl, Anthony Sottile Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: licenses/LICENSE.pre-commit.txt ================================================ Copyright (c) 2014 pre-commit dev team: Anthony Sottile, Ken Struys Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: mise.toml ================================================ [settings] cargo.binstall = true [tools] # We need cargo-binstall so that Mise would download "cargo:" tools instead of building them. cargo-binstall = "latest" # For snapshot testing cargo-insta = "latest" "cargo:cargo-nextest" = "latest" prek = "latest" uv = "latest" [tasks.lint] description = "Run formatting and linting" run = [ "cargo fmt", "cargo clippy --all-targets --all-features --workspace -- -D warnings", ] [tasks.test-unit] description = "Run unit tests with insta review" run = "cargo insta test --review --bin prek -- {{arg(name='filter')}}" [tasks.test-all-unit] description = "Run all unit tests with insta review" run = "cargo insta test --review --workspace --lib --bins" [tasks.test-integration] description = "Run specific integration test with insta review" run = "cargo insta test --review --test {{arg(name='test')}} -- {{arg(name='filter')}}" [tasks.test-all-integration] description = "Run all integration tests with insta review" run = "cargo insta test --review --test '*'" [tasks.test] description = "Run all tests" run = "cargo test --all-targets --all-features --workspace" [tasks.generate-cli-reference] description = "Generate CLI reference" run = "cargo test --bin prek cli::_gen::generate_cli_reference -- --exact" env = { PREK_GENERATE = "1" } [tasks.generate-json-schema] description = "Generate JSON schema" run = "cargo test --bin prek --features schemars schema::_gen::generate_json_schema -- --exact" env = { PREK_GENERATE = "1" } [tasks.generate] description = "Generate CLI reference and JSON schema" run = [{ task = "generate-cli-reference" }, { task = "generate-json-schema" }] [tasks.preview-docs] description = "Serve documentation locally" run = "uvx --with-requirements docs/requirements.txt zensical serve" [tasks.build-docs] description = "Build documentation" run = [ "uvx --with-requirements docs/requirements.txt zensical build", "uvx --with-requirements docs/requirements.txt llmstxt-standalone build", ] [tasks.compile-docs-deps] # Python version should match PYTHON_VERSION in .github/workflows/publish-docs.yml description = "Compile documentation dependencies" run = "uv pip compile docs/requirements.in --universal -o docs/requirements.txt --python 3.14" [tasks.update-macports] description = "Update MacPorts portfile" run = "uv run scripts/update-macports-portfile.py" [tasks.release] description = "Prepare for a release" run = """ git checkout -b bump uvx --from 'rooster @ git+https://github.com/j178/rooster@747d16f' --python 3.13 -- rooster release """ ================================================ FILE: mkdocs.yml ================================================ site_name: prek site_description: Better `pre-commit` alternative, re-engineered in Rust site_author: j178 site_url: https://prek.j178.dev/ repo_name: j178/prek repo_url: https://github.com/j178/prek copyright: Copyright © 2025 j178 theme: name: material logo: assets/logo.webp favicon: assets/favicon.ico palette: - media: "(prefers-color-scheme)" toggle: icon: material/brightness-auto name: Switch to light mode - media: "(prefers-color-scheme: light)" scheme: default primary: indigo accent: blue toggle: icon: material/brightness-7 name: Switch to dark mode - media: "(prefers-color-scheme: dark)" scheme: slate primary: indigo accent: blue toggle: icon: material/brightness-4 name: Switch to system preference features: - navigation.side - navigation.sections - navigation.expand - navigation.path - navigation.indexes - navigation.instant - navigation.instant.prefetch - navigation.instant.progress - navigation.tracking - navigation.footer - navigation.top - content.code.copy - content.code.annotate - content.tabs.link - search.suggest - search.highlight - search.share plugins: - search - minify: minify_html: true - include-markdown - llmstxt: markdown_description: | prek is a drop-in replacement for pre-commit, fully compatible with existing `.pre-commit-config.yaml` files. It runs the same hooks faster, with better toolchain management. Key differences from pre-commit: - Single binary, no Python runtime required - Parallel hook execution by priority - Built-in workspace/monorepo support - Automatic toolchain installation (Python, Node, Go, Rust, Ruby) - Integration with uv for Python environments When fetching documentation, use explicit `index.md` paths for directories, e.g., `https://prek.j178.dev/configuration/index.md`. This returns clean markdown instead of rendered HTML. full_output: llms-full.txt sections: Getting Started: - index.md - installation.md - quickstart.md Usage: - configuration.md - languages.md - cli.md - builtin.md - workspace.md - integrations.md - authoring-hooks.md Help: - debugging.md - faq.md About: - compatibility.md - diff.md - benchmark.md - changelog.md nav: - Getting Started: - Introduction: index.md - Installation: installation.md - Quickstart: quickstart.md - Usage: - Configuration: configuration.md - Language Support: languages.md - Commands: cli.md - Built-in Hooks: builtin.md - Workspace Mode: workspace.md - Integrations: integrations.md - Authoring Hooks: authoring-hooks.md - Help: - Debugging: debugging.md - FAQ: faq.md - About: - Compatibility: compatibility.md - Differences: diff.md - Benchmark: benchmark.md - Changelog: changelog.md markdown_extensions: - pymdownx.highlight: anchor_linenums: true line_spans: __span pygments_lang_class: true - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences - pymdownx.tabbed: alternate_style: true combine_header_slug: true slugify: !!python/object/apply:pymdownx.slugs.slugify kwds: case: lower - pymdownx.details - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg - admonition - attr_list - footnotes - md_in_html - meta - tables - toc: permalink: true ================================================ FILE: prek.schema.json ================================================ { "$id": "https://www.schemastore.org/prek.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "prek.toml", "description": "The configuration file for prek, a git hook manager written in Rust.", "type": "object", "properties": { "repos": { "type": "array", "items": { "$ref": "#/definitions/Repo" } }, "default_install_hook_types": { "description": "A list of `--hook-types` which will be used by default when running `prek install`.\nDefault is `[pre-commit]`.", "type": "array", "items": { "$ref": "#/definitions/HookType" } }, "default_language_version": { "description": "A mapping from language to the default `language_version`.", "type": "object", "properties": { "bun": { "type": "string" }, "conda": { "type": "string" }, "coursier": { "type": "string" }, "dart": { "type": "string" }, "deno": { "type": "string" }, "docker": { "type": "string" }, "docker_image": { "type": "string" }, "dotnet": { "type": "string" }, "fail": { "type": "string" }, "golang": { "type": "string" }, "haskell": { "type": "string" }, "julia": { "type": "string" }, "lua": { "type": "string" }, "node": { "type": "string" }, "perl": { "type": "string" }, "pygrep": { "type": "string" }, "python": { "type": "string" }, "r": { "type": "string" }, "ruby": { "type": "string" }, "rust": { "type": "string" }, "script": { "type": "string" }, "swift": { "type": "string" }, "system": { "type": "string" } }, "additionalProperties": false }, "default_stages": { "description": "A configuration-wide default for the stages property of hooks.\nDefault to all stages.", "type": "array", "items": { "$ref": "#/definitions/Stage" }, "uniqueItems": true }, "files": { "description": "Global file include pattern.", "$ref": "#/definitions/FilePattern" }, "exclude": { "description": "Global file exclude pattern.", "$ref": "#/definitions/FilePattern" }, "fail_fast": { "description": "Set to true to have prek stop running hooks after the first failure.\nDefault is false.", "type": "boolean" }, "minimum_prek_version": { "description": "The minimum version of prek required to run this configuration.", "type": "string" }, "orphan": { "description": "Set to true to isolate this project from parent configurations in workspace mode.\nWhen true, files in this project are \"consumed\" by this project and will not be processed\nby parent projects.\nWhen false (default), files in subprojects are processed by both the subproject and\nany parent projects that contain them.", "type": "boolean" } }, "required": [ "repos" ], "additionalProperties": true, "x-tombi-toml-version": "v1.1.0", "definitions": { "Repo": { "description": "A repository of hooks, which can be remote, local, meta, or builtin.", "type": "object", "oneOf": [ { "$ref": "#/definitions/RemoteRepo" }, { "$ref": "#/definitions/LocalRepo" }, { "$ref": "#/definitions/MetaRepo" }, { "$ref": "#/definitions/BuiltinRepo" } ], "additionalProperties": true }, "RemoteRepo": { "type": "object", "properties": { "repo": { "description": "Remote repository location. Must not be `local`, `meta`, or `builtin`.", "type": "string", "not": { "enum": [ "local", "meta", "builtin" ] } }, "rev": { "type": "string" }, "hooks": { "type": "array", "items": { "$ref": "#/definitions/RemoteHook" }, "writeOnly": true } }, "required": [ "repo", "rev", "hooks" ], "additionalProperties": true }, "RemoteHook": { "description": "A remote hook in the configuration file.\n\nAll keys in manifest hook dict are valid in a config hook dict, but are optional.", "type": "object", "properties": { "id": { "description": "The id of the hook.", "type": "string" }, "name": { "description": "Override the name of the hook.", "type": "string" }, "entry": { "description": "Override the entrypoint. Not documented in the official docs but works.", "type": "string" }, "language": { "description": "Override the language. Not documented in the official docs but works.", "$ref": "#/definitions/Language" }, "priority": { "description": "Priority used by the scheduler to determine ordering and concurrency.\nHooks with the same priority can run in parallel.\n\nThis is only allowed in project config files (e.g. `.pre-commit-config.yaml`).\nIt is not allowed in manifests (e.g. `.pre-commit-hooks.yaml`).", "type": "integer", "minimum": 0 }, "alias": { "description": "Not documented in the official docs.", "type": "string" }, "files": { "description": "The pattern of files to run on.", "$ref": "#/definitions/FilePattern" }, "exclude": { "description": "Exclude files that were matched by `files`.\nDefault is `$^`, which matches nothing.", "$ref": "#/definitions/FilePattern" }, "types": { "description": "List of file types to run on (AND).\nDefault is `[file]`, which matches all files.", "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "types_or": { "description": "List of file types to run on (OR).\nDefault is `[]`.", "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "exclude_types": { "description": "List of file types to exclude.\nDefault is `[]`.", "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "additional_dependencies": { "description": "Not documented in the official docs.", "type": "array", "items": { "type": "string" } }, "args": { "description": "Additional arguments to pass to the hook.", "type": "array", "items": { "type": "string" } }, "env": { "description": "Environment variables to set for the hook.", "type": "object", "additionalProperties": { "type": "string" } }, "always_run": { "description": "This hook will run even if there are no matching files.\nDefault is false.", "type": "boolean" }, "fail_fast": { "description": "If this hook fails, don't run any more hooks.\nDefault is false.", "type": "boolean" }, "pass_filenames": { "description": "Append filenames that would be checked to the hook entry as arguments.\nDefault is true.", "$ref": "#/definitions/PassFilenames" }, "description": { "description": "A description of the hook. For metadata only.", "type": "string" }, "language_version": { "description": "Run the hook on a specific version of the language.\nDefault is `default`.\nSee .", "type": "string" }, "log_file": { "description": "Write the output of the hook to a file when the hook fails or verbose is enabled.", "type": "string" }, "require_serial": { "description": "This hook will execute using a single process instead of in parallel.\nDefault is false.", "type": "boolean" }, "stages": { "description": "Select which Git hook stages this hook runs for.\nDefault all stages are selected.\nSee .", "type": "array", "items": { "$ref": "#/definitions/Stage" }, "uniqueItems": true }, "verbose": { "description": "Print the output of the hook even if it passes.\nDefault is false.", "type": "boolean" }, "minimum_prek_version": { "description": "The minimum version of prek required to run this hook.", "type": "string" } }, "required": [ "id" ], "additionalProperties": true }, "Language": { "type": "string", "enum": [ "bun", "conda", "coursier", "dart", "deno", "docker", "docker_image", "dotnet", "fail", "golang", "haskell", "julia", "lua", "node", "perl", "pygrep", "python", "r", "ruby", "rust", "script", "swift", "system" ] }, "FilePattern": { "description": "A file pattern, either a regex or glob pattern(s).", "oneOf": [ { "description": "A regular expression pattern.", "type": "string" }, { "type": "object", "properties": { "glob": { "oneOf": [ { "description": "A glob pattern.", "type": "string" }, { "description": "A list of glob patterns.", "type": "array", "items": { "type": "string" } } ] } }, "required": [ "glob" ] } ] }, "PassFilenames": { "description": "Whether to pass filenames to the hook. `true` passes all matching filenames (default), `false` passes none, and a positive integer limits each invocation to at most that many filenames.", "oneOf": [ { "type": "boolean" }, { "type": "integer", "exclusiveMinimum": 0 } ] }, "Stage": { "type": "string", "enum": [ "manual", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg" ] }, "LocalRepo": { "type": "object", "properties": { "repo": { "description": "Must be `local`.", "type": "string", "const": "local" }, "hooks": { "type": "array", "items": { "$ref": "#/definitions/LocalHook" } } }, "required": [ "repo", "hooks" ], "additionalProperties": true }, "LocalHook": { "description": "A local hook in the configuration file.\n\nThis is similar to `ManifestHook`, but includes config-only fields (like `priority`).", "type": "object", "properties": { "id": { "description": "The id of the hook.", "type": "string" }, "name": { "description": "The name of the hook.", "type": "string" }, "entry": { "description": "The command to run. It can contain arguments that will not be overridden.", "type": "string" }, "language": { "description": "The language of the hook. Tells prek how to install and run the hook.", "$ref": "#/definitions/Language" }, "priority": { "description": "Priority used by the scheduler to determine ordering and concurrency.\nHooks with the same priority can run in parallel.", "type": "integer", "minimum": 0 }, "alias": { "description": "Not documented in the official docs.", "type": "string" }, "files": { "description": "The pattern of files to run on.", "$ref": "#/definitions/FilePattern" }, "exclude": { "description": "Exclude files that were matched by `files`.\nDefault is `$^`, which matches nothing.", "$ref": "#/definitions/FilePattern" }, "types": { "description": "List of file types to run on (AND).\nDefault is `[file]`, which matches all files.", "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "types_or": { "description": "List of file types to run on (OR).\nDefault is `[]`.", "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "exclude_types": { "description": "List of file types to exclude.\nDefault is `[]`.", "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "additional_dependencies": { "description": "Not documented in the official docs.", "type": "array", "items": { "type": "string" } }, "args": { "description": "Additional arguments to pass to the hook.", "type": "array", "items": { "type": "string" } }, "env": { "description": "Environment variables to set for the hook.", "type": "object", "additionalProperties": { "type": "string" } }, "always_run": { "description": "This hook will run even if there are no matching files.\nDefault is false.", "type": "boolean" }, "fail_fast": { "description": "If this hook fails, don't run any more hooks.\nDefault is false.", "type": "boolean" }, "pass_filenames": { "description": "Append filenames that would be checked to the hook entry as arguments.\nDefault is true.", "$ref": "#/definitions/PassFilenames" }, "description": { "description": "A description of the hook. For metadata only.", "type": "string" }, "language_version": { "description": "Run the hook on a specific version of the language.\nDefault is `default`.\nSee .", "type": "string" }, "log_file": { "description": "Write the output of the hook to a file when the hook fails or verbose is enabled.", "type": "string" }, "require_serial": { "description": "This hook will execute using a single process instead of in parallel.\nDefault is false.", "type": "boolean" }, "stages": { "description": "Select which Git hook stages this hook runs for.\nDefault all stages are selected.\nSee .", "type": "array", "items": { "$ref": "#/definitions/Stage" }, "uniqueItems": true }, "verbose": { "description": "Print the output of the hook even if it passes.\nDefault is false.", "type": "boolean" }, "minimum_prek_version": { "description": "The minimum version of prek required to run this hook.", "type": "string" } }, "required": [ "id", "name", "entry", "language" ], "additionalProperties": true }, "MetaRepo": { "type": "object", "properties": { "repo": { "description": "Must be `meta`.", "type": "string", "const": "meta" }, "hooks": { "type": "array", "items": { "$ref": "#/definitions/MetaHook" } } }, "required": [ "repo", "hooks" ], "additionalProperties": true }, "MetaHook": { "description": "A meta hook predefined in prek.", "type": "object", "properties": { "id": { "$ref": "#/definitions/MetaHooks" }, "name": { "description": "Override the name of the hook.", "type": "string" }, "entry": { "description": "Entry is not allowed for predefined hooks.", "const": false }, "language": { "description": "Language must be `system` for predefined hooks (or omitted).", "type": "string", "enum": [ "system" ] }, "priority": { "description": "Priority used by the scheduler to determine ordering and concurrency.\nHooks with the same priority can run in parallel.\n\nThis is only allowed in project config files (e.g. `.pre-commit-config.yaml`).\nIt is not allowed in manifests (e.g. `.pre-commit-hooks.yaml`).", "type": "integer", "minimum": 0 }, "alias": { "description": "Not documented in the official docs.", "type": "string" }, "files": { "description": "The pattern of files to run on.", "$ref": "#/definitions/FilePattern" }, "exclude": { "description": "Exclude files that were matched by `files`.\nDefault is `$^`, which matches nothing.", "$ref": "#/definitions/FilePattern" }, "types": { "description": "List of file types to run on (AND).\nDefault is `[file]`, which matches all files.", "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "types_or": { "description": "List of file types to run on (OR).\nDefault is `[]`.", "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "exclude_types": { "description": "List of file types to exclude.\nDefault is `[]`.", "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "additional_dependencies": { "description": "Not documented in the official docs.", "type": "array", "items": { "type": "string" } }, "args": { "description": "Additional arguments to pass to the hook.", "type": "array", "items": { "type": "string" } }, "env": { "description": "Environment variables to set for the hook.", "type": "object", "additionalProperties": { "type": "string" } }, "always_run": { "description": "This hook will run even if there are no matching files.\nDefault is false.", "type": "boolean" }, "fail_fast": { "description": "If this hook fails, don't run any more hooks.\nDefault is false.", "type": "boolean" }, "pass_filenames": { "description": "Append filenames that would be checked to the hook entry as arguments.\nDefault is true.", "$ref": "#/definitions/PassFilenames" }, "description": { "description": "A description of the hook. For metadata only.", "type": "string" }, "language_version": { "description": "Run the hook on a specific version of the language.\nDefault is `default`.\nSee .", "type": "string" }, "log_file": { "description": "Write the output of the hook to a file when the hook fails or verbose is enabled.", "type": "string" }, "require_serial": { "description": "This hook will execute using a single process instead of in parallel.\nDefault is false.", "type": "boolean" }, "stages": { "description": "Select which Git hook stages this hook runs for.\nDefault all stages are selected.\nSee .", "type": "array", "items": { "$ref": "#/definitions/Stage" }, "uniqueItems": true }, "verbose": { "description": "Print the output of the hook even if it passes.\nDefault is false.", "type": "boolean" }, "minimum_prek_version": { "description": "The minimum version of prek required to run this hook.", "type": "string" } }, "required": [ "id" ], "additionalProperties": true }, "MetaHooks": { "type": "string", "enum": [ "check-hooks-apply", "check-useless-excludes", "identity" ] }, "BuiltinRepo": { "type": "object", "properties": { "repo": { "description": "Must be `builtin`.", "type": "string", "const": "builtin" }, "hooks": { "type": "array", "items": { "$ref": "#/definitions/BuiltinHook" } } }, "required": [ "repo", "hooks" ], "additionalProperties": true }, "BuiltinHook": { "description": "A builtin hook predefined in prek.", "type": "object", "properties": { "id": { "$ref": "#/definitions/BuiltinHooks" }, "name": { "description": "Override the name of the hook.", "type": "string" }, "entry": { "description": "Entry is not allowed for predefined hooks.", "const": false }, "language": { "description": "Language must be `system` for predefined hooks (or omitted).", "type": "string", "enum": [ "system" ] }, "priority": { "description": "Priority used by the scheduler to determine ordering and concurrency.\nHooks with the same priority can run in parallel.\n\nThis is only allowed in project config files (e.g. `.pre-commit-config.yaml`).\nIt is not allowed in manifests (e.g. `.pre-commit-hooks.yaml`).", "type": "integer", "minimum": 0 }, "alias": { "description": "Not documented in the official docs.", "type": "string" }, "files": { "description": "The pattern of files to run on.", "$ref": "#/definitions/FilePattern" }, "exclude": { "description": "Exclude files that were matched by `files`.\nDefault is `$^`, which matches nothing.", "$ref": "#/definitions/FilePattern" }, "types": { "description": "List of file types to run on (AND).\nDefault is `[file]`, which matches all files.", "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "types_or": { "description": "List of file types to run on (OR).\nDefault is `[]`.", "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "exclude_types": { "description": "List of file types to exclude.\nDefault is `[]`.", "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "additional_dependencies": { "description": "Not documented in the official docs.", "type": "array", "items": { "type": "string" } }, "args": { "description": "Additional arguments to pass to the hook.", "type": "array", "items": { "type": "string" } }, "env": { "description": "Environment variables to set for the hook.", "type": "object", "additionalProperties": { "type": "string" } }, "always_run": { "description": "This hook will run even if there are no matching files.\nDefault is false.", "type": "boolean" }, "fail_fast": { "description": "If this hook fails, don't run any more hooks.\nDefault is false.", "type": "boolean" }, "pass_filenames": { "description": "Append filenames that would be checked to the hook entry as arguments.\nDefault is true.", "$ref": "#/definitions/PassFilenames" }, "description": { "description": "A description of the hook. For metadata only.", "type": "string" }, "language_version": { "description": "Run the hook on a specific version of the language.\nDefault is `default`.\nSee .", "type": "string" }, "log_file": { "description": "Write the output of the hook to a file when the hook fails or verbose is enabled.", "type": "string" }, "require_serial": { "description": "This hook will execute using a single process instead of in parallel.\nDefault is false.", "type": "boolean" }, "stages": { "description": "Select which Git hook stages this hook runs for.\nDefault all stages are selected.\nSee .", "type": "array", "items": { "$ref": "#/definitions/Stage" }, "uniqueItems": true }, "verbose": { "description": "Print the output of the hook even if it passes.\nDefault is false.", "type": "boolean" }, "minimum_prek_version": { "description": "The minimum version of prek required to run this hook.", "type": "string" } }, "required": [ "id" ], "additionalProperties": true }, "BuiltinHooks": { "type": "string", "enum": [ "check-added-large-files", "check-case-conflict", "check-executables-have-shebangs", "check-json", "check-json5", "check-merge-conflict", "check-symlinks", "check-toml", "check-xml", "check-yaml", "detect-private-key", "end-of-file-fixer", "fix-byte-order-marker", "mixed-line-ending", "no-commit-to-branch", "trailing-whitespace" ] }, "HookType": { "type": "string", "enum": [ "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg" ] } } } ================================================ FILE: pyproject.toml ================================================ [project] name = "prek" version = "0.3.6" description = "Better `pre-commit`, re-engineered in Rust" authors = [{ name = "j178", email = "hi@j178.dev" }] requires-python = ">=3.8" keywords = ["pre-commit", "git", "hooks"] readme = "README.md" license = { file = "LICENSE" } classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "Operating System :: OS Independent", "License :: OSI Approved :: MIT License", "Programming Language :: Rust", "Topic :: Software Development :: Quality Assurance", ] [project.urls] Repository = "https://github.com/j178/prek" Changelog = "https://github.com/j178/prek/blob/master/CHANGELOG.md" Releases = "https://github.com/j178/prek/releases" Homepage = "https://prek.j178.dev/" [build-system] requires = ["maturin>=1.0,<2.0"] build-backend = "maturin" [tool.maturin] bindings = "bin" manifest-path = "crates/prek/Cargo.toml" strip = true python-source = "python" include = [{ path = "licenses/*", format = ["wheel", "sdist"] }] [tool.rooster] version-format = "cargo" version_tag_prefix = "v" major_labels = [] # We do not use the major version number yet minor_labels = ["breaking"] changelog_ignore_labels = ["internal", "ci", "testing"] changelog_sections.breaking = "Breaking changes" changelog_sections.enhancement = "Enhancements" changelog_sections.compatibility = "Enhancements" changelog_sections.performance = "Performance" changelog_sections.bug = "Bug fixes" changelog_sections.documentation = "Documentation" changelog_sections.__unknown__ = "Other changes" changelog_contributors = true version_files = [ "pyproject.toml", # Replace the `workspace.package.version` field in the Cargo.toml { path = "Cargo.toml", format = "cargo", field = "workspace.package.version" }, # Bump versions of dependent crates { target = "Cargo.toml", match = "^prek-", version_format = "cargo" }, "README.md", "docs/installation.md", "docs/integrations.md", ] [tool.uv] managed = false ================================================ FILE: python/prek/__init__.py ================================================ ================================================ FILE: python/prek/__main__.py ================================================ import os import sys from ._find_prek import find_prek_bin def _run() -> None: prek = find_prek_bin() if sys.platform == "win32": import subprocess # Avoid emitting a traceback on interrupt try: completed_process = subprocess.run([prek, *sys.argv[1:]]) except KeyboardInterrupt: sys.exit(2) sys.exit(completed_process.returncode) else: os.execvp(prek, [prek, *sys.argv[1:]]) if __name__ == "__main__": _run() ================================================ FILE: python/prek/_find_prek.py ================================================ # MIT License # Copyright (c) 2025 Astral Software Inc. # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import os import sys import sysconfig class PrekNotFound(FileNotFoundError): ... def find_prek_bin() -> str: """Return the prek binary path.""" prek_exe = "prek" + sysconfig.get_config_var("EXE") targets = [ # The scripts directory for the current Python sysconfig.get_path("scripts"), # The scripts directory for the base prefix sysconfig.get_path("scripts", vars={"base": sys.base_prefix}), # Above the package root, e.g., from `pip install --prefix` or `uv run --with` ( # On Windows, with module path `/Lib/site-packages/prek` _join(_matching_parents(_module_path(), "Lib/site-packages/prek"), "Scripts") if sys.platform == "win32" # On Unix, with module path `/lib/python3.13/site-packages/prek` else _join( _matching_parents(_module_path(), "lib/python*/site-packages/prek"), "bin" ) ), # Adjacent to the package root, e.g., from `pip install --target` # with module path `/prek` _join(_matching_parents(_module_path(), "prek"), "bin"), # The user scheme scripts directory, e.g., `~/.local/bin` sysconfig.get_path("scripts", scheme=_user_scheme()), ] seen = [] for target in targets: if not target: continue if target in seen: continue seen.append(target) path = os.path.join(target, prek_exe) if os.path.isfile(path): return path locations = "\n".join(f" - {target}" for target in seen) raise PrekNotFound( f"Could not find the prek binary in any of the following locations:\n{locations}\n" ) def _module_path() -> str | None: path = os.path.dirname(__file__) return path def _matching_parents(path: str | None, match: str) -> str | None: """ Return the parent directory of `path` after trimming a `match` from the end. The match is expected to contain `/` as a path separator, while the `path` is expected to use the platform's path separator (e.g., `os.sep`). The path components are compared case-insensitively and a `*` wildcard can be used in the `match`. """ from fnmatch import fnmatch if not path: return None parts = path.split(os.sep) match_parts = match.split("/") if len(parts) < len(match_parts): return None if not all( fnmatch(part, match_part) for part, match_part in zip(reversed(parts), reversed(match_parts)) ): return None return os.sep.join(parts[: -len(match_parts)]) def _join(path: str | None, *parts: str) -> str | None: if not path: return None return os.path.join(path, *parts) def _user_scheme() -> str: if sys.version_info >= (3, 10): user_scheme = sysconfig.get_preferred_scheme("user") elif os.name == "nt": user_scheme = "nt_user" elif sys.platform == "darwin" and sys._framework: # ty: ignore[unresolved-attribute] user_scheme = "osx_framework_user" else: user_scheme = "posix_user" return user_scheme ================================================ FILE: rust-toolchain.toml ================================================ [toolchain] channel = "1.94" ================================================ FILE: scripts/hyperfine-run-benchmarks.sh ================================================ #!/usr/bin/env bash set -euo pipefail TARGET_WORKSPACE=${HYPERFINE_BENCHMARK_WORKSPACE:?HYPERFINE_BENCHMARK_WORKSPACE is required} COMMENT=${HYPERFINE_RESULTS_FILE:?HYPERFINE_RESULTS_FILE is required} HEAD_BINARY=${HYPERFINE_HEAD_BINARY:?HYPERFINE_HEAD_BINARY is required} BASE_BINARY=${HYPERFINE_BASE_BINARY:?HYPERFINE_BASE_BINARY is required} OUT_DIR=$(dirname "$COMMENT") META_WORKSPACE="${TARGET_WORKSPACE}-meta" section_open=false regression_count=0 improvement_count=0 mkdir -p "$OUT_DIR" OUT_MD="$OUT_DIR/out.md" OUT_JSON="$OUT_DIR/out.json" REPORT_BODY="$OUT_DIR/report-body.md" : > "$REPORT_BODY" CURRENT_PREK_VERSION=$( "$HEAD_BINARY" --version | sed -n '1p' ) write_line() { printf '%s\n' "$1" >> "$REPORT_BODY" } write_blank_line() { printf '\n' >> "$REPORT_BODY" } finalize_report() { : > "$COMMENT" printf '### ⚡️ Hyperfine Benchmarks\n\n' >> "$COMMENT" printf '**Summary:** %s regressions, %s improvements above the 10%% threshold.\n' "$regression_count" "$improvement_count" >> "$COMMENT" cat "$REPORT_BODY" >> "$COMMENT" } write_section() { local title="$1" local description="${2:-}" close_section write_blank_line write_line "
" write_line "$title" write_blank_line if [ -n "$description" ]; then write_line "$description" write_blank_line fi section_open=true } close_section() { if [ "$section_open" = true ]; then write_blank_line write_line "
" section_open=false fi } # Compare the two commands in out.json (reference vs current). # Hyperfine's JSON has results[0] = reference and results[1] = current. # A ratio > 1 means current is slower (regression), < 1 means faster (improvement). check_variance() { local cmd="$1" local num_results num_results=$(jq '.results | length' "$OUT_JSON") if [ "$num_results" -lt 2 ]; then return fi local ref_mean current_mean ratio pct ref_mean=$(jq '.results[0].mean' "$OUT_JSON") current_mean=$(jq '.results[1].mean' "$OUT_JSON") ratio=$(echo "scale=4; $current_mean / $ref_mean" | bc) pct=$(echo "scale=2; ($ratio - 1) * 100" | bc) if (( $(echo "${pct#-} > 10" | bc -l) )); then if (( $(echo "$ratio < 1" | bc -l) )); then improvement_count=$((improvement_count + 1)) write_line "✅ Performance improvement for \`$cmd\`: ${pct#-}% faster" else regression_count=$((regression_count + 1)) write_line "⚠️ Warning: Performance regression for \`$cmd\`: ${pct}% slower" fi fi } benchmark() { local cmd="$1" local warmup="${2:-3}" local runs="${3:-30}" local setup="${4:-}" local prepare="${5:-}" local check_change="${6:-false}" local label_suffix="${7:-}" local label="prek $cmd" local -a hyperfine_args=(-i -N -w "$warmup" -r "$runs" --export-markdown "$OUT_MD" --export-json "$OUT_JSON") if [ -n "$label_suffix" ]; then label="$label $label_suffix" fi if [ -n "$setup" ]; then hyperfine_args+=(--setup "$setup") fi if [ -n "$prepare" ]; then hyperfine_args+=(--prepare "$prepare") fi write_blank_line write_line "### \`$label\`" if ! hyperfine "${hyperfine_args[@]}" --reference "$BASE_BINARY $cmd" "$HEAD_BINARY $cmd"; then write_line "⚠️ Benchmark failed for: $cmd" return 1 fi cat "$OUT_MD" >> "$REPORT_BODY" write_blank_line if [ "$check_change" = "true" ]; then check_variance "$cmd" fi } create_meta_workspace() { rm -rf "$META_WORKSPACE" mkdir -p "$META_WORKSPACE" cd "$META_WORKSPACE" git init || { echo "Failed to init git for meta hooks"; exit 1; } git config user.name "Benchmark" git config user.email "bench@prek.dev" cp "$TARGET_WORKSPACE"/*.txt "$TARGET_WORKSPACE"/*.json . 2>/dev/null || true cat > .pre-commit-config.yaml << 'EOF' repos: - repo: meta hooks: - id: check-hooks-apply - id: check-useless-excludes - id: identity - repo: builtin hooks: - id: trailing-whitespace - id: end-of-file-fixer EOF git add -A git commit -m "Meta hooks test" || { echo "Failed to commit meta hooks test"; exit 1; } $HEAD_BINARY install-hooks } # Add environment metadata write_line "
" write_line "Environment" write_blank_line write_line "- OS: $(uname -s) $(uname -r)" write_line "- CPU: $(nproc) cores" write_line "- prek version: $CURRENT_PREK_VERSION" write_line "- Rust version: $(rustc --version)" write_line "- Hyperfine version: $(hyperfine --version)" write_blank_line write_line "
" # Benchmark in the main repo write_section "CLI Commands" "Benchmarking basic commands in the main repo:" CMDS=( "--version" "list" "validate-config .pre-commit-config.yaml" "sample-config" ) for cmd in "${CMDS[@]}"; do if [[ "$cmd" == "validate-config"* ]] && [ ! -f ".pre-commit-config.yaml" ]; then write_line "### \`prek $cmd\`" write_line "⏭️ Skipped: .pre-commit-config.yaml not found" continue fi if [[ "$cmd" == "--version" ]] || [[ "$cmd" == "list" ]]; then benchmark "$cmd" 5 100 else benchmark "$cmd" 3 50 fi check_variance "$cmd" done # Benchmark builtin hooks in test directory cd "$TARGET_WORKSPACE" # Cold vs warm benchmarks before polluting cache write_section "Cold vs Warm Runs" "Comparing first run (cold) vs subsequent runs (warm cache):" benchmark "run --all-files" 0 10 "rm -rf ~/.cache/prek" "git checkout -- ." false "(cold - no cache)" benchmark "run --all-files" 3 20 "" "git checkout -- ." false "(warm - with cache)" # Full benchmark suite with cache warmed up write_section "Full Hook Suite" "Running the builtin hook suite on the benchmark workspace:" benchmark "run --all-files" 3 50 "" "git checkout -- ." true "(full builtin hook suite)" # Individual hook performance write_section "Individual Hook Performance" "Benchmarking each hook individually on the test repo:" INDIVIDUAL_HOOKS=( "trailing-whitespace" "end-of-file-fixer" "check-json" "check-yaml" "check-toml" "check-xml" "detect-private-key" "fix-byte-order-marker" ) for hook in "${INDIVIDUAL_HOOKS[@]}"; do benchmark "run $hook --all-files" 3 30 "" "git checkout -- ." done # Installation performance write_section "Installation Performance" "Benchmarking hook installation (fast path hooks skip Python setup):" benchmark "install-hooks" 1 5 "rm -rf ~/.cache/prek/hooks ~/.cache/prek/repos" "" false "(cold - no cache)" benchmark "install-hooks" 1 5 "" "" false "(warm - with cache)" # File filtering/scoping performance write_section "File Filtering/Scoping Performance" "Testing different file selection modes:" git add -A benchmark "run" 3 20 "" "sh -c 'git checkout -- . && git add -A'" false "(staged files only)" benchmark "run --files '*.json'" 3 20 "" "" false "(specific file type)" # Workspace discovery & initialization write_section "Workspace Discovery & Initialization" "Benchmarking hook discovery and initialization overhead:" benchmark "run --dry-run --all-files" 3 20 "" "" false "(measures init overhead)" # Meta hooks performance write_section "Meta Hooks Performance" "Benchmarking meta hooks separately:" create_meta_workspace META_HOOKS=( "check-hooks-apply" "check-useless-excludes" "identity" ) for hook in "${META_HOOKS[@]}"; do benchmark "run $hook --all-files" 3 15 "" "git checkout -- ." done close_section finalize_report ================================================ FILE: scripts/hyperfine-setup-test-env.sh ================================================ #!/usr/bin/env bash set -euo pipefail TARGET_WORKSPACE=${HYPERFINE_BENCHMARK_WORKSPACE:?HYPERFINE_BENCHMARK_WORKSPACE is required} # Create a clean test directory with files to run builtin hooks against rm -rf "$TARGET_WORKSPACE" mkdir -p "$TARGET_WORKSPACE" cd "$TARGET_WORKSPACE" git init || { echo "Failed to init git"; exit 1; } git config user.name "Benchmark" git config user.email "bench@prek.dev" # Files with trailing whitespace and no final newline for i in {1..50}; do printf "line with trailing whitespace \nanother line " > "file$i.txt" done # JSON files for i in {1..30}; do echo '{"key": "value", "number": '$i'}' > "file$i.json" done # YAML files for i in {1..30}; do echo "key: value" > "file$i.yaml" echo "number: $i" >> "file$i.yaml" done # TOML files for i in {1..30}; do echo "[section]" > "file$i.toml" echo "key = \"value$i\"" >> "file$i.toml" done # XML files for i in {1..30}; do echo 'value' > "file$i.xml" done # Files with mixed line endings for i in {1..20}; do printf "line1\r\nline2\nline3\r\n" > "mixed$i.txt" done # Files with UTF-8 BOM for i in {1..20}; do printf '\xef\xbb\xbfContent with BOM' > "bom$i.txt" done # Executable files (for shebang check) for i in {1..10}; do echo "#!/bin/bash" > "script$i.sh" echo "echo hello" >> "script$i.sh" chmod +x "script$i.sh" done # Files that might contain private keys (but don't) for i in {1..10}; do echo "# This is not a private key" > "config$i.txt" echo "api_key = fake_key_$i" >> "config$i.txt" done # Create symlinks for check-symlinks for i in {1..10}; do ln -s "file$i.txt" "link$i.txt" done # Create a config that uses all builtin hooks cat > .pre-commit-config.yaml << 'EOF' repos: - repo: builtin hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-json - id: check-yaml - id: check-toml - id: check-xml - id: mixed-line-ending - id: fix-byte-order-marker - id: check-executables-have-shebangs - id: detect-private-key - id: check-case-conflict - id: check-merge-conflict - id: check-symlinks EOF git add -A git commit -m "Initial commit" || { echo "Failed to commit"; exit 1; } ================================================ FILE: scripts/macports/Portfile ================================================ # -*- coding: utf-8; mode: tcl; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- vim:fenc=utf-8:ft=tcl:et:sw=4:ts=4:sts=4 PortSystem 1.0 PortGroup cargo 1.0 PortGroup github 1.0 github.setup j178 prek 0.3.5 v github.tarball_from archive revision 0 description Better `pre-commit`, re-engineered in Rust long_description {*}${description} categories devel installs_libs no license MIT maintainers {@j178 j178.dev:hi} openmaintainer homepage https://prek.j178.dev checksums ${distname}${extract.suffix} \ rmd160 953c5729f50d8e57a4011fca43978abcc2308849 \ sha256 3d0bf93af3591762b2fce97965fb88f8dc4b750164451162f57f866e26e4bb67 \ size 524673 post-build { # Generate shell completions for supported shells set prek_bin ${worksrcpath}/target/[cargo.rust_platform]/release/${name} foreach shell {zsh bash fish} { system -W ${worksrcpath} "COMPLETE=${shell} ${prek_bin} > ${name}.${shell}" } } destroot { set bindir ${worksrcpath}/target/[cargo.rust_platform]/release xinstall -m 0755 ${bindir}/${name} ${destroot}${prefix}/bin/ set zsh_comp_path ${destroot}${prefix}/share/zsh/site-functions xinstall -d ${zsh_comp_path} xinstall -m 0644 ${worksrcpath}/${name}.zsh ${zsh_comp_path}/_${name} set bash_comp_path ${destroot}${prefix}/share/bash-completion/completions xinstall -d ${bash_comp_path} xinstall -m 0644 ${worksrcpath}/${name}.bash ${bash_comp_path}/${name} set fish_comp_path ${destroot}${prefix}/share/fish/vendor_completions.d xinstall -d ${fish_comp_path} xinstall -m 0644 ${worksrcpath}/${name}.fish ${fish_comp_path} } build.args-append -p prek cargo.crates \ addr2line 0.25.1 1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b \ adler2 2.0.1 320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa \ ahash 0.8.12 5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75 \ aho-corasick 1.1.4 ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301 \ aligned-vec 0.6.4 dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b \ annotate-snippets 0.12.12 c86cd1c51b95d71dde52bca69ed225008f6ff4c8cc825b08042aa1ef823e1980 \ anstream 0.6.21 43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a \ anstyle 1.0.13 5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78 \ anstyle-parse 0.2.7 4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2 \ anstyle-query 1.1.5 40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc \ anstyle-wincon 3.0.11 291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d \ anyhow 1.0.102 7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c \ ar_archive_writer 0.5.1 7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b \ arraydeque 0.5.1 7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236 \ arrayvec 0.7.6 7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50 \ assert_cmd 2.1.2 9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514 \ assert_fs 1.1.3 a652f6cb1f516886fcfee5e7a5c078b9ade62cfcb889524efe5a64d682dd27a9 \ astral-tokio-tar 0.5.6 ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5 \ astral_async_zip 0.0.17 ab72a761e6085828cc8f0e05ed332b2554701368c5dc54de551bfaec466518ba \ async-compression 0.4.41 d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1 \ atomic-waker 1.1.2 1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0 \ autocfg 1.5.0 c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8 \ aws-lc-rs 1.16.1 94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf \ aws-lc-sys 0.38.0 4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e \ axoasset 2.0.1 1be1b9c2739b635e04c7bbcde9e89dd5e874b9e86e28f1b41c44eb830635d83e \ axoprocess 0.2.1 8a4b4798a6c02e91378537c63cd6e91726900b595450daa5d487bc3c11e95e1b \ axotag 0.3.0 dc923121fbc4cc72e9008436b5650b98e56f94b5799df59a1b4f572b5c6a7e6b \ axoupdater 0.10.0 0ab66f118bab79524a27139b7341cdf1c4f839c6274ef89a6d8fb4365cb218cf \ backtrace 0.3.76 bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6 \ base64 0.22.1 72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6 \ bit-set 0.8.0 08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3 \ bit-vec 0.8.0 5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7 \ bitflags 1.3.2 bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a \ bitflags 2.11.0 843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af \ block2 0.6.2 cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5 \ bstr 1.12.1 63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab \ bumpalo 3.20.2 5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb \ bytemuck 1.25.0 c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec \ byteorder-lite 0.1.0 8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495 \ bytes 1.11.1 1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33 \ camino 1.2.2 e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48 \ cargo-platform 0.3.2 87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082 \ cargo_metadata 0.23.1 ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9 \ cc 1.2.56 aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2 \ cesu8 1.1.0 6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c \ cfg-if 1.0.4 9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801 \ cfg_aliases 0.2.1 613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724 \ chacha20 0.10.0 6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601 \ clap 4.5.60 2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a \ clap_builder 4.5.60 24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876 \ clap_complete 4.5.66 c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031 \ clap_derive 4.5.55 a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5 \ clap_lex 1.0.0 3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831 \ cmake 0.1.57 75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d \ colorchoice 1.0.4 b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75 \ combine 4.6.7 ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd \ compression-codecs 0.4.37 eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7 \ compression-core 0.4.31 75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d \ console 0.15.11 054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8 \ console 0.16.2 03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4 \ core-foundation 0.9.4 91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f \ core-foundation 0.10.1 b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6 \ core-foundation-sys 0.8.7 773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b \ cpp_demangle 0.5.1 0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def \ cpufeatures 0.3.0 8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201 \ crc32fast 1.5.0 9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511 \ crossbeam-deque 0.8.6 9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51 \ crossbeam-epoch 0.9.18 5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e \ crossbeam-utils 0.8.21 d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28 \ ctrlc 3.5.2 e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162 \ debugid 0.8.0 bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d \ diff 0.1.13 56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8 \ difflib 0.4.0 6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8 \ dispatch2 0.3.1 1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38 \ displaydoc 0.2.5 97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0 \ doc-comment 0.3.4 780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9 \ dunce 1.0.5 92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813 \ dyn-clone 1.0.20 d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555 \ either 1.15.0 48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719 \ encode_unicode 1.0.0 34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0 \ encoding_rs 0.8.35 75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3 \ encoding_rs_io 0.1.7 1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83 \ env_home 0.1.0 c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe \ equator 0.4.2 4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc \ equator-macro 0.4.2 44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3 \ equivalent 1.0.2 877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f \ errno 0.3.14 39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb \ etcetera 0.11.0 de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96 \ fancy-regex 0.17.0 72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8 \ fastrand 2.3.0 37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be \ filetime 0.2.27 f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db \ find-msvc-tools 0.1.9 5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582 \ findshlibs 0.10.2 40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64 \ flate2 1.1.9 843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c \ float-cmp 0.10.0 b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8 \ fnv 1.0.7 3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1 \ foldhash 0.1.5 d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2 \ foldhash 0.2.0 77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb \ form_urlencoded 1.2.2 cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf \ fs-err 3.3.0 73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0 \ fs_extra 1.3.0 42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c \ futures 0.3.32 8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d \ futures-channel 0.3.32 07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d \ futures-core 0.3.32 7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d \ futures-executor 0.3.32 baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d \ futures-io 0.3.32 cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718 \ futures-lite 2.6.1 f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad \ futures-macro 0.3.32 e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b \ futures-sink 0.3.32 c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893 \ futures-task 0.3.32 037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393 \ futures-util 0.3.32 389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6 \ getrandom 0.2.17 ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0 \ getrandom 0.3.4 899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd \ getrandom 0.4.2 0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555 \ gimli 0.32.3 e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7 \ globset 0.4.18 52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3 \ globwalk 0.9.1 0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757 \ h2 0.4.13 2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54 \ hashbrown 0.15.5 9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1 \ hashbrown 0.16.1 841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100 \ heck 0.5.0 2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea \ hermit-abi 0.5.2 fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c \ hex 0.4.3 7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70 \ homedir 0.3.6 68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527 \ http 1.4.0 e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a \ http-body 1.0.1 1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184 \ http-body-util 0.1.3 b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a \ httparse 1.10.1 6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87 \ hyper 1.8.1 2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11 \ hyper-rustls 0.27.7 e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58 \ hyper-util 0.1.20 96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0 \ icu_collections 2.1.1 4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43 \ icu_locale_core 2.1.1 edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6 \ icu_normalizer 2.1.1 5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599 \ icu_normalizer_data 2.1.1 7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a \ icu_properties 2.1.2 020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec \ icu_properties_data 2.1.2 616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af \ icu_provider 2.1.1 85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614 \ id-arena 2.3.0 3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954 \ idna 1.1.0 3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de \ idna_adapter 1.2.1 3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344 \ ignore 0.4.25 d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a \ image 0.25.9 e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a \ indexmap 2.13.0 7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017 \ indicatif 0.18.4 25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb \ indoc 2.0.7 79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706 \ inferno 0.11.21 232929e1d75fe899576a3d5c7416ad0d88dbfbb3c3d6aa00873a7408a50ddb88 \ insta 1.46.3 e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4 \ insta-cmd 0.6.0 ffeeefa927925cced49ccb01bf3e57c9d4cd132df21e576eb9415baeab2d3de6 \ ipnet 2.12.0 d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2 \ iri-string 0.7.10 c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a \ is-terminal 0.4.17 3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46 \ is_executable 1.0.5 baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4 \ is_terminal_polyfill 1.70.2 a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695 \ itertools 0.14.0 2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285 \ itoa 1.0.17 92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2 \ jni 0.21.1 1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97 \ jni-sys 0.3.0 8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130 \ jobserver 0.1.34 9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33 \ js-sys 0.3.91 b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c \ json5 1.3.1 733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c \ lazy-regex 3.6.0 6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496 \ lazy-regex-proc_macros 3.6.0 4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358 \ lazy_static 1.5.0 bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe \ leb128fmt 0.1.0 09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2 \ levenshtein 1.0.5 db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760 \ libc 0.2.182 6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112 \ liblzma 0.4.6 b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899 \ liblzma-sys 0.4.5 9f2db66f3268487b5033077f266da6777d057949b8f93c8ad82e441df25e6186 \ libredox 0.1.14 1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a \ linux-raw-sys 0.12.1 32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53 \ litemap 0.8.1 6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77 \ lock_api 0.4.14 224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965 \ log 0.4.29 5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897 \ lru-slab 0.1.2 112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154 \ markdown 1.0.0 a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb \ matchers 0.2.0 d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9 \ mea 0.6.3 6747f54621d156e1b47eb6b25f39a941b9fc347f98f67d25d8881ff99e8ed832 \ memchr 2.8.0 f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79 \ memmap2 0.9.10 714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3 \ miette 7.6.0 5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7 \ miette-derive 7.6.0 db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b \ mime 0.3.17 6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a \ miniz_oxide 0.8.9 1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316 \ mio 1.1.1 a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc \ moxcms 0.7.11 ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97 \ nix 0.26.4 598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b \ nix 0.30.1 74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6 \ nix 0.31.2 5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3 \ nohash-hasher 0.2.0 2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451 \ normalize-line-endings 0.3.0 61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be \ nu-ansi-term 0.50.3 7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5 \ num-format 0.4.4 a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3 \ num-traits 0.2.19 071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841 \ objc2 0.6.4 3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f \ objc2-encode 4.1.0 ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33 \ object 0.37.3 ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe \ once_cell 1.21.3 42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d \ once_cell_polyfill 1.70.2 384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe \ openssl-probe 0.2.1 7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe \ owo-colors 4.3.0 d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d \ parking 2.2.1 f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba \ path-clean 1.0.1 17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef \ percent-encoding 2.3.2 9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220 \ phf 0.13.1 c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf \ phf_generator 0.13.1 135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737 \ phf_macros 0.13.1 812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef \ phf_shared 0.13.1 e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266 \ pin-project 1.1.11 f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517 \ pin-project-internal 1.1.11 d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6 \ pin-project-lite 0.2.17 a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd \ pin-utils 0.1.0 8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184 \ pkg-config 0.3.32 7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c \ plain 0.2.3 b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6 \ portable-atomic 1.13.1 c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49 \ potential_utf 0.1.4 b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77 \ pprof 0.15.0 38a01da47675efa7673b032bf8efd8214f1917d89685e07e395ab125ea42b187 \ ppv-lite86 0.2.21 85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9 \ predicates 3.1.4 ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe \ predicates-core 1.0.10 cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144 \ predicates-tree 1.0.13 d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2 \ pretty_assertions 1.4.1 3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d \ prettyplease 0.2.37 479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b \ proc-macro2 1.0.106 8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934 \ psm 0.1.30 3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8 \ pxfm 0.1.28 b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d \ quick-xml 0.26.0 7f50b1c63b38611e7d4d7f68b82d3ad0cc71a2ad2e7f61fc10f1328d917c93cd \ quick-xml 0.39.2 958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d \ quinn 0.11.9 b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20 \ quinn-proto 0.11.13 f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31 \ quinn-udp 0.5.14 addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd \ quote 1.0.45 41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924 \ r-efi 5.3.0 69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f \ r-efi 6.0.0 f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf \ rand 0.9.2 6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1 \ rand 0.10.0 bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8 \ rand_chacha 0.9.0 d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb \ rand_core 0.9.5 76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c \ rand_core 0.10.0 0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba \ rayon 1.11.0 368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f \ rayon-core 1.13.0 22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91 \ redox_syscall 0.7.3 6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16 \ ref-cast 1.0.25 f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d \ ref-cast-impl 1.0.25 b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da \ regex 1.12.3 e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276 \ regex-automata 0.4.14 6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f \ regex-syntax 0.8.10 dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a \ reqwest 0.13.2 ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801 \ rgb 0.8.53 47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4 \ ring 0.17.14 a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7 \ rustc-demangle 0.1.27 b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d \ rustc-hash 2.1.1 357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d \ rustix 1.1.4 b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190 \ rustls 0.23.37 758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4 \ rustls-native-certs 0.8.3 612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63 \ rustls-pki-types 1.14.0 be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd \ rustls-platform-verifier 0.6.2 1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784 \ rustls-platform-verifier-android 0.1.1 f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f \ rustls-webpki 0.103.9 d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53 \ rustversion 1.0.22 b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d \ same-file 1.0.6 93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502 \ saphyr-parser-bw 0.0.608 d55ae5ea09894b6d5382621db78f586df37ef18ab581bf32c754e75076b124b1 \ schannel 0.1.28 891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1 \ schemars 1.2.1 a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc \ schemars_derive 1.2.1 7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f \ scopeguard 1.2.0 94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49 \ security-framework 3.7.0 b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d \ security-framework-sys 2.17.0 6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3 \ self-replace 1.5.0 03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7 \ semver 1.0.27 d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2 \ serde 1.0.228 9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e \ serde-saphyr 0.0.20 bfcaa44cda9e21eaf5fefc86175d544a359d4de9bcd1f3a90be7bbf77dfc3492 \ serde_core 1.0.228 41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad \ serde_derive 1.0.228 d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79 \ serde_derive_internals 0.29.1 18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711 \ serde_json 1.0.149 83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86 \ serde_spanned 1.0.4 f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776 \ serde_stacker 0.1.14 d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a \ sharded-slab 0.1.7 f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6 \ shlex 1.3.0 0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64 \ signal-hook-registry 1.4.8 c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b \ simd-adler32 0.3.8 e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2 \ similar 2.7.0 bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa \ siphasher 1.0.2 b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e \ slab 0.4.12 0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5 \ smallvec 1.15.1 67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03 \ smawk 0.3.2 b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c \ socket2 0.6.2 86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0 \ spin 0.10.0 d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591 \ stable_deref_trait 1.2.1 6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596 \ stacker 0.1.23 08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013 \ str_stack 0.1.0 9091b6114800a5f2141aee1d1b9d6ca3592ac062dc5decb3764ec5895a47b4eb \ strsim 0.11.1 7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f \ strum 0.28.0 9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd \ strum_macros 0.28.0 ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664 \ subtle 2.6.1 13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292 \ symbolic-common 12.17.2 751a2823d606b5d0a7616499e4130a516ebd01a44f39811be2b9600936509c23 \ symbolic-demangle 12.17.2 79b237cfbe320601dd24b4ac817a5b68bb28f5508e33f08d42be0682cadc8ac9 \ syn 2.0.117 e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99 \ sync_wrapper 1.0.2 0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263 \ synstructure 0.13.2 728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2 \ system-configuration 0.7.0 a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b \ system-configuration-sys 0.6.0 8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4 \ target-lexicon 0.13.5 adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca \ tempfile 3.26.0 82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0 \ terminal_size 0.4.3 60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0 \ termtree 0.5.1 8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683 \ textwrap 0.16.2 c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057 \ thiserror 1.0.69 b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52 \ thiserror 2.0.18 4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4 \ thiserror-impl 1.0.69 4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1 \ thiserror-impl 2.0.18 ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5 \ thread_local 1.1.9 f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185 \ tinystr 0.8.2 42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869 \ tinyvec 1.10.0 bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa \ tinyvec_macros 0.1.1 1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20 \ tokio 1.50.0 27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d \ tokio-macros 2.6.1 5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c \ tokio-rustls 0.26.4 1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61 \ tokio-stream 0.1.18 32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70 \ tokio-util 0.7.18 9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098 \ toml 1.0.3+spec-1.1.0 c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c \ toml_datetime 1.0.0+spec-1.1.0 32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e \ toml_edit 0.25.3+spec-1.1.0 a0a07913e63758bc95142d9863a5a45173b71515e68b690cad70cf99c3255ce1 \ toml_parser 1.0.9+spec-1.1.0 702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4 \ toml_writer 1.0.6+spec-1.1.0 ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607 \ tower 0.5.3 ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4 \ tower-http 0.6.8 d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8 \ tower-layer 0.3.3 121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e \ tower-service 0.3.3 8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3 \ tracing 0.1.44 63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100 \ tracing-attributes 0.1.31 7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da \ tracing-core 0.1.36 db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a \ tracing-log 0.2.0 ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3 \ tracing-subscriber 0.3.22 2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e \ try-lock 0.2.5 e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b \ ucd-trie 0.1.7 2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971 \ unicode-id 0.3.6 70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580 \ unicode-ident 1.0.24 e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75 \ unicode-linebreak 0.1.5 3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f \ unicode-width 0.1.14 7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af \ unicode-width 0.2.2 b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254 \ unicode-xid 0.2.6 ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853 \ unit-prefix 0.5.2 81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3 \ untrusted 0.9.0 8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1 \ url 2.5.8 ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed \ utf8_iter 1.0.4 b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be \ utf8parse 0.2.2 06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821 \ uuid 1.21.0 b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb \ valuable 0.1.1 ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65 \ version_check 0.9.5 0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a \ wait-timeout 0.2.1 09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11 \ walkdir 2.5.0 29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b \ want 0.3.1 bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e \ wasi 0.11.1+wasi-snapshot-preview1 ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b \ wasip2 1.0.2+wasi-0.2.9 9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5 \ wasip3 0.4.0+wasi-0.3.0-rc-2026-01-06 5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5 \ wasm-bindgen 0.2.114 6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e \ wasm-bindgen-futures 0.4.64 e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8 \ wasm-bindgen-macro 0.2.114 18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6 \ wasm-bindgen-macro-support 0.2.114 03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3 \ wasm-bindgen-shared 0.2.114 75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16 \ wasm-encoder 0.244.0 990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319 \ wasm-metadata 0.244.0 bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909 \ wasm-streams 0.5.0 9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb \ wasmparser 0.244.0 47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe \ web-sys 0.3.91 854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9 \ web-time 1.1.0 5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb \ webpki-root-certs 1.0.6 804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca \ which 8.0.0 d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d \ widestring 1.2.1 72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471 \ winapi 0.3.9 5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419 \ winapi-i686-pc-windows-gnu 0.4.0 ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6 \ winapi-util 0.1.11 c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22 \ winapi-x86_64-pc-windows-gnu 0.4.0 712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f \ windows 0.61.3 9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893 \ windows-collections 0.2.0 3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8 \ windows-core 0.61.2 c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3 \ windows-future 0.2.1 fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e \ windows-implement 0.60.2 053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf \ windows-interface 0.59.3 3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358 \ windows-link 0.1.3 5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a \ windows-link 0.2.1 f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5 \ windows-numerics 0.2.0 9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1 \ windows-registry 0.6.1 02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720 \ windows-result 0.3.4 56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6 \ windows-result 0.4.1 7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5 \ windows-strings 0.4.2 56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57 \ windows-strings 0.5.1 7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091 \ windows-sys 0.45.0 75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0 \ windows-sys 0.52.0 282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d \ windows-sys 0.59.0 1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b \ windows-sys 0.60.2 f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb \ windows-sys 0.61.2 ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc \ windows-targets 0.42.2 8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071 \ windows-targets 0.52.6 9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973 \ windows-targets 0.53.5 4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3 \ windows-threading 0.1.0 b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6 \ windows_aarch64_gnullvm 0.42.2 597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8 \ windows_aarch64_gnullvm 0.52.6 32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3 \ windows_aarch64_gnullvm 0.53.1 a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53 \ windows_aarch64_msvc 0.42.2 e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43 \ windows_aarch64_msvc 0.52.6 09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469 \ windows_aarch64_msvc 0.53.1 b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006 \ windows_i686_gnu 0.42.2 c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f \ windows_i686_gnu 0.52.6 8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b \ windows_i686_gnu 0.53.1 960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3 \ windows_i686_gnullvm 0.52.6 0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66 \ windows_i686_gnullvm 0.53.1 fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c \ windows_i686_msvc 0.42.2 44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060 \ windows_i686_msvc 0.52.6 240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66 \ windows_i686_msvc 0.53.1 1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2 \ windows_x86_64_gnu 0.42.2 8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36 \ windows_x86_64_gnu 0.52.6 147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78 \ windows_x86_64_gnu 0.53.1 9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499 \ windows_x86_64_gnullvm 0.42.2 26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3 \ windows_x86_64_gnullvm 0.52.6 24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d \ windows_x86_64_gnullvm 0.53.1 0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1 \ windows_x86_64_msvc 0.42.2 9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0 \ windows_x86_64_msvc 0.52.6 589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec \ windows_x86_64_msvc 0.53.1 d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650 \ winnow 0.7.14 5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829 \ winsafe 0.0.19 d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904 \ wit-bindgen 0.51.0 d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5 \ wit-bindgen-core 0.51.0 ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc \ wit-bindgen-rust 0.51.0 b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21 \ wit-bindgen-rust-macro 0.51.0 0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a \ wit-component 0.244.0 9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2 \ wit-parser 0.244.0 ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736 \ writeable 0.6.2 9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9 \ xattr 1.6.1 32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156 \ yansi 1.0.1 cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049 \ yoke 0.8.1 72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954 \ yoke-derive 0.8.1 b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d \ zerocopy 0.8.40 a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5 \ zerocopy-derive 0.8.40 f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953 \ zerofrom 0.1.6 50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5 \ zerofrom-derive 0.1.6 d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502 \ zeroize 1.8.2 b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0 \ zerotrie 0.2.3 2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851 \ zerovec 0.11.5 6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002 \ zerovec-derive 0.11.2 eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3 \ zmij 1.0.21 b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa ================================================ FILE: scripts/update-macports-portfile.py ================================================ # /// script # requires-python = ">=3.14" # dependencies = [ # "httpx>=0.28.1", # ] # /// from __future__ import annotations import os import re import shutil import subprocess import sys from pathlib import Path import httpx def run(cmd: list[str], *, capture: bool = False) -> str: result = subprocess.run( cmd, check=True, text=True, capture_output=capture, ) if capture: return result.stdout.strip() return "" def repo_root() -> Path: root = run(["git", "rev-parse", "--show-toplevel"], capture=True) return Path(root) def read_version(cargo_toml: Path) -> str: content = cargo_toml.read_text(encoding="utf-8") match = re.search(r'^version\s*=\s*"([^"]+)"', content, flags=re.MULTILINE) if not match: raise RuntimeError(f"Failed to read version from {cargo_toml}") return match.group(1) def replace_github_setup_version(portfile_text: str, version: str) -> str: updated = re.sub( r'^(github\.setup\s+\S+\s+\S+\s+)\S+', rf'\g<1>{version}', portfile_text, count=1, flags=re.MULTILINE, ) if updated == portfile_text: raise RuntimeError("Could not locate github.setup line in Portfile") return updated def download_distfile(version: str) -> Path: distfile = Path(f"/tmp/prek-v{version}.tar.gz") url = f"https://github.com/j178/prek/archive/v{version}.tar.gz" with httpx.Client(follow_redirects=True, timeout=60.0) as client: response = client.get(url) response.raise_for_status() distfile.write_bytes(response.content) return distfile def openssl_digest(algorithm: str, file_path: Path) -> str: out = run(["openssl", "dgst", f"-{algorithm}", str(file_path)], capture=True) if "= " not in out: raise RuntimeError(f"Unexpected openssl output: {out}") return out.split("= ", 1)[1].strip() def update_checksums_block(portfile_text: str, rmd160: str, sha256: str, size: int) -> str: updated = re.sub( r"(^\s*rmd160\s+)\S+(\s*\\\s*$)", rf"\g<1>{rmd160}\g<2>", portfile_text, count=1, flags=re.MULTILINE, ) updated = re.sub( r"(^\s*sha256\s+)\S+(\s*\\\s*$)", rf"\g<1>{sha256}\g<2>", updated, count=1, flags=re.MULTILINE, ) updated = re.sub( r"(^\s*size\s+)\d+(\s*$)", rf"\g<1>{size}\g<2>", updated, count=1, flags=re.MULTILINE, ) if updated == portfile_text or "rmd160" not in updated or "sha256" not in updated: raise RuntimeError("Could not locate checksum lines in Portfile") return updated def ensure_cargo2port() -> None: if shutil.which("cargo2port"): return run( [ "cargo", "install", "--locked", "--git", "https://github.com/l2dy/cargo2port", "cargo2port", ] ) def generated_cargo_crates(cargo_lock: Path) -> str: return run(["cargo2port", str(cargo_lock)], capture=True) def replace_cargo_crates_block(portfile_text: str, crates_block: str) -> str: marker = "cargo.crates" idx = portfile_text.find(marker) if idx == -1: raise RuntimeError("Could not locate cargo.crates block in Portfile") prefix = portfile_text[:idx] return prefix + crates_block.rstrip() + "\n" def main() -> None: root = repo_root() default_portfile = root / "scripts" / "macports" / "Portfile" portfile = Path(os.environ.get("PORTFILE", str(default_portfile))) if not portfile.is_file(): raise RuntimeError(f"Portfile not found at {portfile}") version = read_version(root / "Cargo.toml") text = portfile.read_text(encoding="utf-8") text = replace_github_setup_version(text, version) distfile = download_distfile(version) rmd160 = openssl_digest("rmd160", distfile) sha256 = openssl_digest("sha256", distfile) size = distfile.stat().st_size text = update_checksums_block(text, rmd160, sha256, size) ensure_cargo2port() crates_block = generated_cargo_crates(root / "Cargo.lock") text = replace_cargo_crates_block(text, crates_block) portfile.write_text(text, encoding="utf-8") print(f"Updated {portfile} for version {version}") print("To open a PR with the updated Portfile, run:") print(f" git clone --depth=1 --branch=main https://github.com/macports/macports-ports.git /tmp/macports-ports") print(f" cp {portfile} /tmp/macports-ports/devel/prek/Portfile") print(f" cd /tmp/macports-ports") print(f" git add devel/prek/Portfile") print(f" git commit -m 'prek: update to {version}'") print(f" gh pr create --title 'prek: update to {version}'") if __name__ == "__main__": try: main() except Exception as exc: print(f"error: {exc}", file=sys.stderr) raise SystemExit(1)